Refactor notifications (#4883)
Also fixes https://github.com/tuskyapp/Tusky/issues/4858. But apart from that there should be no functional change.
This commit is contained in:
parent
6c85f72a35
commit
3a3e056572
19 changed files with 1072 additions and 1217 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import androidx.work.Constraints
|
|||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.settings.AppTheme
|
||||
import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
|
|
@ -75,6 +74,7 @@ class TuskyApplication : Application(), Configuration.Provider {
|
|||
NEW_INSTALL_SCHEMA_VERSION
|
||||
)
|
||||
if (oldVersion != SCHEMA_VERSION) {
|
||||
// TODO SCHEMA_VERSION is outdated / not updated in code
|
||||
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
|
||||
}
|
||||
|
||||
|
|
@ -89,8 +89,6 @@ class TuskyApplication : Application(), Configuration.Provider {
|
|||
|
||||
localeManager.setLocale()
|
||||
|
||||
NotificationHelper.createWorkerNotificationChannel(this)
|
||||
|
||||
// Prune the database every ~ 12 hours when the device is idle.
|
||||
val pruneCacheWorker = PeriodicWorkRequestBuilder<PruneCacheWorker>(12, TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
package com.keylesspalace.tusky.components.systemnotifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.filterNotification
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.entity.Marker
|
||||
|
|
@ -18,7 +11,6 @@ import com.keylesspalace.tusky.entity.Notification
|
|||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import com.keylesspalace.tusky.util.isLessThan
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Models next/prev links from the "Links" header in an API response */
|
||||
|
|
@ -50,65 +42,22 @@ data class Links(val next: String?, val prev: String?) {
|
|||
class NotificationFetcher @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val eventHub: EventHub
|
||||
private val eventHub: EventHub,
|
||||
private val notificationService: NotificationService,
|
||||
) {
|
||||
suspend fun fetchAndShow() {
|
||||
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
for (account in accountManager.accounts) {
|
||||
if (account.notificationsEnabled) {
|
||||
try {
|
||||
val notificationManager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
|
||||
val notificationManagerCompat = NotificationManagerCompat.from(context)
|
||||
|
||||
// Create sorted list of new notifications
|
||||
val notifications = fetchNewNotifications(account)
|
||||
.filter { filterNotification(notificationManager, account, it) }
|
||||
.filter { notificationService.filterNotification(account, it.type) }
|
||||
.sortedWith(
|
||||
compareBy({ it.id.length }, { it.id })
|
||||
) // oldest notifications first
|
||||
|
||||
// TODO do this before filter above? But one could argue that (for example) a tab badge is also a notification
|
||||
// (and should therefore adhere to the notification config).
|
||||
eventHub.dispatch(NewNotificationsEvent(account.accountId, notifications))
|
||||
|
||||
val newNotifications = ArrayList<NotificationManagerCompat.NotificationWithIdAndTag>()
|
||||
|
||||
val notificationsByType: Map<Notification.Type, List<Notification>> = notifications.groupBy { it.type }
|
||||
notificationsByType.forEach { notificationsGroup: Map.Entry<Notification.Type, List<Notification>> ->
|
||||
// NOTE Enqueue the summary first: Needed to avoid rate limit problems:
|
||||
// ie. single notification is enqueued but that later summary one is filtered and thus no grouping
|
||||
// takes place.
|
||||
newNotifications.add(
|
||||
NotificationHelper.makeSummaryNotification(
|
||||
context,
|
||||
notificationManager,
|
||||
account,
|
||||
notificationsGroup.key,
|
||||
notificationsGroup.value
|
||||
)
|
||||
)
|
||||
|
||||
notificationsGroup.value.forEach { notification ->
|
||||
newNotifications.add(
|
||||
NotificationHelper.make(
|
||||
context,
|
||||
notificationManager,
|
||||
notification,
|
||||
account
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE having multiple summary notifications this here should still collapse them in only one occurrence
|
||||
notificationManagerCompat.notify(newNotifications)
|
||||
notificationService.show(account, notifications)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error while fetching notifications", e)
|
||||
}
|
||||
|
|
@ -142,7 +91,7 @@ class NotificationFetcher @Inject constructor(
|
|||
// - The Mastodon marker API (if the server supports it)
|
||||
// - account.notificationMarkerId
|
||||
// - account.lastNotificationId
|
||||
Log.d(TAG, "getting notification marker for ${account.fullName}")
|
||||
Log.d(TAG, "Getting notification marker for ${account.fullName}.")
|
||||
val remoteMarkerId = fetchMarker(authHeader, account)?.lastReadId ?: "0"
|
||||
val localMarkerId = account.notificationMarkerId
|
||||
val markerId = if (remoteMarkerId.isLessThan(
|
||||
|
|
@ -160,10 +109,10 @@ class NotificationFetcher @Inject constructor(
|
|||
Log.d(TAG, " localMarkerId: $localMarkerId")
|
||||
Log.d(TAG, " readingPosition: $readingPosition")
|
||||
|
||||
Log.d(TAG, "getting Notifications for ${account.fullName}, min_id: $minId")
|
||||
Log.d(TAG, "Getting Notifications for ${account.fullName}, min_id: $minId.")
|
||||
|
||||
// Fetch all outstanding notifications
|
||||
val notifications = buildList {
|
||||
val notifications: List<Notification> = buildList {
|
||||
while (minId != null) {
|
||||
val response = mastodonApi.notificationsWithAuth(
|
||||
authHeader,
|
||||
|
|
@ -197,6 +146,8 @@ class NotificationFetcher @Inject constructor(
|
|||
accountManager.updateAccount(account) { copy(notificationMarkerId = newMarkerId) }
|
||||
}
|
||||
|
||||
Log.d(TAG, "Got ${notifications.size} Notifications.")
|
||||
|
||||
return notifications
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,852 +0,0 @@
|
|||
/* Copyright 2018 Jeremiasz Nelz <remi6397(a)gmail.com>
|
||||
* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.systemnotifications;
|
||||
|
||||
import static com.keylesspalace.tusky.BuildConfig.APPLICATION_ID;
|
||||
import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
|
||||
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationChannelGroup;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.service.notification.StatusBarNotification;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.app.RemoteInput;
|
||||
import androidx.core.app.TaskStackBuilder;
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.OutOfQuotaPolicy;
|
||||
import androidx.work.PeriodicWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
import androidx.work.WorkRequest;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
|
||||
import com.bumptech.glide.request.FutureTarget;
|
||||
import com.keylesspalace.tusky.MainActivity;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity;
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity;
|
||||
import com.keylesspalace.tusky.db.AccountManager;
|
||||
import com.keylesspalace.tusky.entity.Notification;
|
||||
import com.keylesspalace.tusky.entity.Poll;
|
||||
import com.keylesspalace.tusky.entity.PollOption;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
|
||||
import com.keylesspalace.tusky.util.StringUtils;
|
||||
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
|
||||
import com.keylesspalace.tusky.worker.NotificationWorker;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class NotificationHelper {
|
||||
|
||||
/** ID of notification shown when fetching notifications */
|
||||
public static final int NOTIFICATION_ID_FETCH_NOTIFICATION = 0;
|
||||
/** ID of notification shown when pruning the cache */
|
||||
public static final int NOTIFICATION_ID_PRUNE_CACHE = 1;
|
||||
/** Dynamic notification IDs start here */
|
||||
private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1;
|
||||
|
||||
private static final String TAG = "NotificationHelper";
|
||||
|
||||
public static final String REPLY_ACTION = "REPLY_ACTION";
|
||||
|
||||
public static final String KEY_REPLY = "KEY_REPLY";
|
||||
|
||||
public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID";
|
||||
|
||||
public static final String KEY_SENDER_ACCOUNT_IDENTIFIER = "KEY_SENDER_ACCOUNT_IDENTIFIER";
|
||||
|
||||
public static final String KEY_SENDER_ACCOUNT_FULL_NAME = "KEY_SENDER_ACCOUNT_FULL_NAME";
|
||||
|
||||
public static final String KEY_SERVER_NOTIFICATION_ID = "KEY_SERVER_NOTIFICATION_ID";
|
||||
|
||||
public static final String KEY_CITED_STATUS_ID = "KEY_CITED_STATUS_ID";
|
||||
|
||||
public static final String KEY_VISIBILITY = "KEY_VISIBILITY";
|
||||
|
||||
public static final String KEY_SPOILER = "KEY_SPOILER";
|
||||
|
||||
public static final String KEY_MENTIONS = "KEY_MENTIONS";
|
||||
|
||||
/**
|
||||
* notification channels used on Android O+
|
||||
**/
|
||||
public static final String CHANNEL_MENTION = "CHANNEL_MENTION";
|
||||
public static final String CHANNEL_FOLLOW = "CHANNEL_FOLLOW";
|
||||
public static final String CHANNEL_FOLLOW_REQUEST = "CHANNEL_FOLLOW_REQUEST";
|
||||
public static final String CHANNEL_BOOST = "CHANNEL_BOOST";
|
||||
public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE";
|
||||
public static final String CHANNEL_POLL = "CHANNEL_POLL";
|
||||
public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS";
|
||||
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
|
||||
public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
|
||||
public static final String CHANNEL_REPORT = "CHANNEL_REPORT";
|
||||
public static final String CHANNEL_BACKGROUND_TASKS = "CHANNEL_BACKGROUND_TASKS";
|
||||
|
||||
/**
|
||||
* WorkManager Tag
|
||||
*/
|
||||
private static final String NOTIFICATION_PULL_TAG = "pullNotifications";
|
||||
|
||||
/** Tag for the summary notification */
|
||||
private static final String GROUP_SUMMARY_TAG = APPLICATION_ID + ".notification.group_summary";
|
||||
|
||||
/** The name of the account that caused the notification, for use in a summary */
|
||||
private static final String EXTRA_ACCOUNT_NAME = APPLICATION_ID + ".notification.extra.account_name";
|
||||
|
||||
/** The notification's type (string representation of a Notification.Type) */
|
||||
private static final String EXTRA_NOTIFICATION_TYPE = APPLICATION_ID + ".notification.extra.notification_type";
|
||||
|
||||
/**
|
||||
* Takes a given Mastodon notification and creates a new Android notification or updates the
|
||||
* existing Android notification.
|
||||
* <p>
|
||||
* The Android notification has it's tag set to the Mastodon notification ID, and it's ID set
|
||||
* to the ID of the account that received the notification.
|
||||
*
|
||||
* @param context to access application preferences and services
|
||||
* @param body a new Mastodon notification
|
||||
* @param account the account for which the notification should be shown
|
||||
* @return the new notification
|
||||
*/
|
||||
@NonNull
|
||||
public static NotificationManagerCompat.NotificationWithIdAndTag make(final @NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull Notification body, @NonNull AccountEntity account) {
|
||||
return new NotificationManagerCompat.NotificationWithIdAndTag(
|
||||
body.getId(),
|
||||
(int)account.getId(),
|
||||
NotificationHelper.makeBaseNotification(context, notificationManager, body, account)
|
||||
);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static android.app.Notification makeBaseNotification(final @NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull Notification body, @NonNull AccountEntity account) {
|
||||
body = body.rewriteToStatusTypeIfNeeded(account.getAccountId());
|
||||
String mastodonNotificationId = body.getId();
|
||||
int accountId = (int) account.getId();
|
||||
|
||||
// Check for an existing notification with this Mastodon Notification ID
|
||||
android.app.Notification existingAndroidNotification = null;
|
||||
StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications();
|
||||
for (StatusBarNotification androidNotification : activeNotifications) {
|
||||
if (mastodonNotificationId.equals(androidNotification.getTag()) && accountId == androidNotification.getId()) {
|
||||
existingAndroidNotification = androidNotification.getNotification();
|
||||
}
|
||||
}
|
||||
|
||||
notificationId++;
|
||||
// Create the notification -- either create a new one, or use the existing one.
|
||||
NotificationCompat.Builder builder;
|
||||
if (existingAndroidNotification == null) {
|
||||
builder = newAndroidNotification(context, body, account);
|
||||
} else {
|
||||
builder = new NotificationCompat.Builder(context, existingAndroidNotification);
|
||||
}
|
||||
|
||||
builder.setContentTitle(titleForType(context, body, account))
|
||||
.setContentText(bodyForType(body, context, account.getAlwaysOpenSpoiler()));
|
||||
|
||||
if (body.getType() == Notification.Type.MENTION || body.getType() == Notification.Type.POLL) {
|
||||
builder.setStyle(new NotificationCompat.BigTextStyle()
|
||||
.bigText(bodyForType(body, context, account.getAlwaysOpenSpoiler())));
|
||||
}
|
||||
|
||||
//load the avatar synchronously
|
||||
Bitmap accountAvatar;
|
||||
try {
|
||||
FutureTarget<Bitmap> target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.load(body.getAccount().getAvatar())
|
||||
.transform(new RoundedCorners(20))
|
||||
.submit();
|
||||
|
||||
accountAvatar = target.get();
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.d(TAG, "error loading account avatar", e);
|
||||
accountAvatar = BitmapFactory.decodeResource(context.getResources(), R.drawable.avatar_default);
|
||||
}
|
||||
|
||||
builder.setLargeIcon(accountAvatar);
|
||||
|
||||
// Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat
|
||||
if (body.getType() == Notification.Type.MENTION) {
|
||||
RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY)
|
||||
.setLabel(context.getString(R.string.label_quick_reply))
|
||||
.build();
|
||||
|
||||
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(context, body, account);
|
||||
|
||||
NotificationCompat.Action quickReplyAction =
|
||||
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
|
||||
context.getString(R.string.action_quick_reply),
|
||||
quickReplyPendingIntent)
|
||||
.addRemoteInput(replyRemoteInput)
|
||||
.build();
|
||||
|
||||
builder.addAction(quickReplyAction);
|
||||
|
||||
PendingIntent composeIntent = getStatusComposeIntent(context, body, account);
|
||||
|
||||
NotificationCompat.Action composeAction =
|
||||
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
|
||||
context.getString(R.string.action_compose_shortcut),
|
||||
composeIntent)
|
||||
.setShowsUserInterface(true)
|
||||
.build();
|
||||
|
||||
builder.addAction(composeAction);
|
||||
}
|
||||
|
||||
builder.setSubText(account.getFullName());
|
||||
builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
|
||||
builder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
|
||||
builder.setOnlyAlertOnce(true);
|
||||
|
||||
Bundle extras = new Bundle();
|
||||
// Add the sending account's name, so it can be used when summarising this notification
|
||||
extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName());
|
||||
extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().name());
|
||||
builder.addExtras(extras);
|
||||
|
||||
// Only ever alert for the summary notification
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY);
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the summary notifications for a notification type.
|
||||
* <p>
|
||||
* Notifications are sent to channels. Within each channel they are grouped and the group has a summary.
|
||||
* <p>
|
||||
* Tusky uses N notification channels for each account, each channel corresponds to a type
|
||||
* of notification (follow, reblog, mention, etc). Each channel also has a
|
||||
* summary notifications along with its regular notifications.
|
||||
* <p>
|
||||
* The group key is the same as the channel ID.
|
||||
*
|
||||
* @see <a href="https://developer.android.com/develop/ui/views/notifications/group">Create a
|
||||
* notification group</a>
|
||||
*/
|
||||
public static NotificationManagerCompat.NotificationWithIdAndTag makeSummaryNotification(@NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type, @NonNull List<Notification> additionalNotifications) {
|
||||
int accountId = (int) account.getId();
|
||||
|
||||
String typeChannelId = getChannelId(account, type);
|
||||
|
||||
if (typeChannelId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a notification that summarises the other notifications in this group
|
||||
//
|
||||
// NOTE: We always create a summary notification (even for activeNotificationsForType.size() == 1):
|
||||
// - No need to especially track the grouping
|
||||
// - No need to change an existing single notification when there arrives another one of its group
|
||||
// - Only the summary one will get announced
|
||||
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
|
||||
summaryStackBuilder.addParentStack(MainActivity.class);
|
||||
Intent summaryResultIntent = MainActivity.openNotificationIntent(context, accountId, type);
|
||||
summaryStackBuilder.addNextIntent(summaryResultIntent);
|
||||
|
||||
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
|
||||
pendingIntentFlags(false));
|
||||
|
||||
List<StatusBarNotification> activeNotifications = getActiveNotifications(notificationManager.getActiveNotifications(), accountId, typeChannelId);
|
||||
|
||||
int notificationCount = activeNotifications.size() + additionalNotifications.size();
|
||||
|
||||
String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, notificationCount, notificationCount);
|
||||
String text = joinNames(context, activeNotifications, additionalNotifications);
|
||||
|
||||
NotificationCompat.Builder summaryBuilder = new NotificationCompat.Builder(context, typeChannelId)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setContentIntent(summaryResultPendingIntent)
|
||||
.setColor(context.getColor(R.color.notification_color))
|
||||
.setAutoCancel(true)
|
||||
.setShortcutId(Long.toString(account.getId()))
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setSubText(account.getFullName())
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
.setGroup(typeChannelId)
|
||||
.setGroupSummary(true)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||
;
|
||||
|
||||
setSoundVibrationLight(account, summaryBuilder);
|
||||
|
||||
String summaryTag = GROUP_SUMMARY_TAG + "." + typeChannelId;
|
||||
|
||||
return new NotificationManagerCompat.NotificationWithIdAndTag(summaryTag, accountId, summaryBuilder.build());
|
||||
}
|
||||
|
||||
private static List<StatusBarNotification> getActiveNotifications(StatusBarNotification[] allNotifications, int accountId, String typeChannelId) {
|
||||
// Return all active notifications, ignoring notifications that:
|
||||
// - belong to a different account
|
||||
// - belong to a different type
|
||||
// - are summary notifications
|
||||
List<StatusBarNotification> activeNotificationsForType = new ArrayList<>();
|
||||
for (StatusBarNotification sn : allNotifications) {
|
||||
if (sn.getId() != accountId)
|
||||
continue;
|
||||
|
||||
String channelId = sn.getNotification().getGroup();
|
||||
|
||||
if (!channelId.equals(typeChannelId))
|
||||
continue;
|
||||
|
||||
String summaryTag = GROUP_SUMMARY_TAG + "." + channelId;
|
||||
if (summaryTag.equals(sn.getTag()))
|
||||
continue;
|
||||
|
||||
activeNotificationsForType.add(sn);
|
||||
}
|
||||
|
||||
return activeNotificationsForType;
|
||||
}
|
||||
|
||||
private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) {
|
||||
|
||||
Intent eventResultIntent = MainActivity.openNotificationIntent(context, account.getId(), body.getType());
|
||||
|
||||
TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context);
|
||||
eventStackBuilder.addParentStack(MainActivity.class);
|
||||
eventStackBuilder.addNextIntent(eventResultIntent);
|
||||
|
||||
PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(),
|
||||
pendingIntentFlags(false));
|
||||
|
||||
String channelId = getChannelId(account, body);
|
||||
assert channelId != null;
|
||||
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setContentIntent(eventResultPendingIntent)
|
||||
.setColor(context.getColor(R.color.notification_color))
|
||||
.setGroup(channelId)
|
||||
.setAutoCancel(true)
|
||||
.setShortcutId(Long.toString(account.getId()))
|
||||
.setDefaults(0); // So it doesn't ring twice, notify only in Target callback
|
||||
|
||||
setSoundVibrationLight(account, builder);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static PendingIntent getStatusReplyIntent(Context context, Notification body, AccountEntity account) {
|
||||
Status status = body.getStatus();
|
||||
|
||||
String inReplyToId = status.getId();
|
||||
Status actionableStatus = status.getActionableStatus();
|
||||
Status.Visibility replyVisibility = actionableStatus.getVisibility();
|
||||
String contentWarning = actionableStatus.getSpoilerText();
|
||||
List<Status.Mention> mentions = actionableStatus.getMentions();
|
||||
List<String> mentionedUsernames = new ArrayList<>();
|
||||
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
|
||||
for (Status.Mention mention : mentions) {
|
||||
mentionedUsernames.add(mention.getUsername());
|
||||
}
|
||||
mentionedUsernames.removeAll(Collections.singleton(account.getUsername()));
|
||||
mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames));
|
||||
|
||||
Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class)
|
||||
.setAction(REPLY_ACTION)
|
||||
.putExtra(KEY_SENDER_ACCOUNT_ID, account.getId())
|
||||
.putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier())
|
||||
.putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName())
|
||||
.putExtra(KEY_SERVER_NOTIFICATION_ID, body.getId())
|
||||
.putExtra(KEY_CITED_STATUS_ID, inReplyToId)
|
||||
.putExtra(KEY_VISIBILITY, replyVisibility)
|
||||
.putExtra(KEY_SPOILER, contentWarning)
|
||||
.putExtra(KEY_MENTIONS, mentionedUsernames.toArray(new String[0]));
|
||||
|
||||
return PendingIntent.getBroadcast(context.getApplicationContext(),
|
||||
notificationId,
|
||||
replyIntent,
|
||||
pendingIntentFlags(true));
|
||||
}
|
||||
|
||||
private static PendingIntent getStatusComposeIntent(Context context, Notification body, AccountEntity account) {
|
||||
Status status = body.getStatus();
|
||||
|
||||
String citedLocalAuthor = status.getAccount().getLocalUsername();
|
||||
String citedText = parseAsMastodonHtml(status.getContent()).toString();
|
||||
String inReplyToId = status.getId();
|
||||
Status actionableStatus = status.getActionableStatus();
|
||||
Status.Visibility replyVisibility = actionableStatus.getVisibility();
|
||||
String contentWarning = actionableStatus.getSpoilerText();
|
||||
List<Status.Mention> mentions = actionableStatus.getMentions();
|
||||
Set<String> mentionedUsernames = new LinkedHashSet<>();
|
||||
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
|
||||
for (Status.Mention mention : mentions) {
|
||||
String mentionedUsername = mention.getUsername();
|
||||
if (!mentionedUsername.equals(account.getUsername())) {
|
||||
mentionedUsernames.add(mention.getUsername());
|
||||
}
|
||||
}
|
||||
|
||||
ComposeActivity.ComposeOptions composeOptions = new ComposeActivity.ComposeOptions();
|
||||
composeOptions.setInReplyToId(inReplyToId);
|
||||
composeOptions.setReplyVisibility(replyVisibility);
|
||||
composeOptions.setContentWarning(contentWarning);
|
||||
composeOptions.setReplyingStatusAuthor(citedLocalAuthor);
|
||||
composeOptions.setReplyingStatusContent(citedText);
|
||||
composeOptions.setMentionedUsernames(mentionedUsernames);
|
||||
composeOptions.setModifiedInitialState(true);
|
||||
composeOptions.setLanguage(actionableStatus.getLanguage());
|
||||
composeOptions.setKind(ComposeActivity.ComposeKind.NEW);
|
||||
|
||||
Intent composeIntent = MainActivity.composeIntent(context, composeOptions, account.getId(), body.getId(), (int)account.getId());
|
||||
|
||||
// make sure a new instance of MainActivity is started and old ones get destroyed
|
||||
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
|
||||
return PendingIntent.getActivity(context.getApplicationContext(),
|
||||
notificationId,
|
||||
composeIntent,
|
||||
pendingIntentFlags(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a notification channel for notifications for background work that should not
|
||||
* disturb the user.
|
||||
*
|
||||
* @param context context
|
||||
*/
|
||||
public static void createWorkerNotificationChannel(@NonNull Context context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_BACKGROUND_TASKS,
|
||||
context.getString(R.string.notification_listenable_worker_name),
|
||||
NotificationManager.IMPORTANCE_NONE
|
||||
);
|
||||
|
||||
channel.setDescription(context.getString(R.string.notification_listenable_worker_description));
|
||||
channel.enableLights(false);
|
||||
channel.enableVibration(false);
|
||||
channel.setShowBadge(false);
|
||||
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a notification for a background worker.
|
||||
*
|
||||
* @param context context
|
||||
* @param titleResource String resource to use as the notification's title
|
||||
* @return the notification
|
||||
*/
|
||||
@NonNull
|
||||
public static android.app.Notification createWorkerNotification(@NonNull Context context, @StringRes int titleResource) {
|
||||
String title = context.getString(titleResource);
|
||||
return new NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setOngoing(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
String[] channelIds = new String[]{
|
||||
CHANNEL_MENTION + account.getIdentifier(),
|
||||
CHANNEL_FOLLOW + account.getIdentifier(),
|
||||
CHANNEL_FOLLOW_REQUEST + account.getIdentifier(),
|
||||
CHANNEL_BOOST + account.getIdentifier(),
|
||||
CHANNEL_FAVOURITE + account.getIdentifier(),
|
||||
CHANNEL_POLL + account.getIdentifier(),
|
||||
CHANNEL_SUBSCRIPTIONS + account.getIdentifier(),
|
||||
CHANNEL_SIGN_UP + account.getIdentifier(),
|
||||
CHANNEL_UPDATES + account.getIdentifier(),
|
||||
CHANNEL_REPORT + account.getIdentifier(),
|
||||
};
|
||||
int[] channelNames = {
|
||||
R.string.notification_mention_name,
|
||||
R.string.notification_follow_name,
|
||||
R.string.notification_follow_request_name,
|
||||
R.string.notification_boost_name,
|
||||
R.string.notification_favourite_name,
|
||||
R.string.notification_poll_name,
|
||||
R.string.notification_subscription_name,
|
||||
R.string.notification_sign_up_name,
|
||||
R.string.notification_update_name,
|
||||
R.string.notification_report_name,
|
||||
};
|
||||
int[] channelDescriptions = {
|
||||
R.string.notification_mention_descriptions,
|
||||
R.string.notification_follow_description,
|
||||
R.string.notification_follow_request_description,
|
||||
R.string.notification_boost_description,
|
||||
R.string.notification_favourite_description,
|
||||
R.string.notification_poll_description,
|
||||
R.string.notification_subscription_description,
|
||||
R.string.notification_sign_up_description,
|
||||
R.string.notification_update_description,
|
||||
R.string.notification_report_description,
|
||||
};
|
||||
|
||||
List<NotificationChannel> channels = new ArrayList<>(6);
|
||||
|
||||
NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName());
|
||||
|
||||
notificationManager.createNotificationChannelGroup(channelGroup);
|
||||
|
||||
for (int i = 0; i < channelIds.length; i++) {
|
||||
String id = channelIds[i];
|
||||
String name = context.getString(channelNames[i]);
|
||||
String description = context.getString(channelDescriptions[i]);
|
||||
int importance = NotificationManager.IMPORTANCE_DEFAULT;
|
||||
NotificationChannel channel = new NotificationChannel(id, name, importance);
|
||||
|
||||
channel.setDescription(description);
|
||||
channel.enableLights(true);
|
||||
channel.setLightColor(0xFF2B90D9);
|
||||
channel.enableVibration(true);
|
||||
channel.setShowBadge(true);
|
||||
channel.setGroup(account.getIdentifier());
|
||||
channels.add(channel);
|
||||
}
|
||||
|
||||
notificationManager.createNotificationChannels(channels);
|
||||
}
|
||||
}
|
||||
|
||||
public static void deleteNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
notificationManager.deleteNotificationChannelGroup(account.getIdentifier());
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean areNotificationsEnabled(@NonNull Context context, @NonNull AccountManager accountManager) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
// on Android >= O, notifications are enabled, if at least one channel is enabled
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
if (notificationManager.areNotificationsEnabled()) {
|
||||
for (NotificationChannel channel : notificationManager.getNotificationChannels()) {
|
||||
if (channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE) {
|
||||
Log.d(TAG, "NotificationsEnabled");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "NotificationsDisabled");
|
||||
|
||||
return false;
|
||||
|
||||
} else {
|
||||
// on Android < O, notifications are enabled, if at least one account has notification enabled
|
||||
return accountManager.areNotificationsEnabled();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static void enablePullNotifications(@NonNull Context context) {
|
||||
WorkManager workManager = WorkManager.getInstance(context);
|
||||
workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
|
||||
|
||||
// Periodic work requests are supposed to start running soon after being enqueued. In
|
||||
// practice that may not be soon enough, so create and enqueue an expedited one-time
|
||||
// request to get new notifications immediately.
|
||||
WorkRequest fetchNotifications = new OneTimeWorkRequest.Builder(NotificationWorker.class)
|
||||
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
|
||||
.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build();
|
||||
workManager.enqueue(fetchNotifications);
|
||||
|
||||
WorkRequest workRequest = new PeriodicWorkRequest.Builder(
|
||||
NotificationWorker.class,
|
||||
PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS,
|
||||
PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS, TimeUnit.MILLISECONDS
|
||||
)
|
||||
.addTag(NOTIFICATION_PULL_TAG)
|
||||
.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.setInitialDelay(5, TimeUnit.MINUTES)
|
||||
.build();
|
||||
|
||||
workManager.enqueue(workRequest);
|
||||
|
||||
Log.d(TAG, "enabled notification checks with "+ PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval");
|
||||
}
|
||||
|
||||
public static void disablePullNotifications(@NonNull Context context) {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
|
||||
Log.d(TAG, "disabled notification checks");
|
||||
}
|
||||
|
||||
public static void clearNotificationsForAccount(@NonNull Context context, @NonNull AccountEntity account) {
|
||||
int accountId = (int) account.getId();
|
||||
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
for (StatusBarNotification androidNotification : notificationManager.getActiveNotifications()) {
|
||||
if (accountId == androidNotification.getId()) {
|
||||
notificationManager.cancel(androidNotification.getTag(), androidNotification.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification notification) {
|
||||
return filterNotification(notificationManager, account, notification.getType());
|
||||
}
|
||||
|
||||
public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
String channelId = getChannelId(account, type);
|
||||
if(channelId == null) {
|
||||
// unknown notificationtype
|
||||
return false;
|
||||
}
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
|
||||
return channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case MENTION:
|
||||
return account.getNotificationsMentioned();
|
||||
case STATUS:
|
||||
return account.getNotificationsSubscriptions();
|
||||
case FOLLOW:
|
||||
return account.getNotificationsFollowed();
|
||||
case FOLLOW_REQUEST:
|
||||
return account.getNotificationsFollowRequested();
|
||||
case REBLOG:
|
||||
return account.getNotificationsReblogged();
|
||||
case FAVOURITE:
|
||||
return account.getNotificationsFavorited();
|
||||
case POLL:
|
||||
return account.getNotificationsPolls();
|
||||
case SIGN_UP:
|
||||
return account.getNotificationsSignUps();
|
||||
case UPDATE:
|
||||
return account.getNotificationsUpdates();
|
||||
case REPORT:
|
||||
return account.getNotificationsReports();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String getChannelId(AccountEntity account, Notification notification) {
|
||||
return getChannelId(account, notification.getType());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String getChannelId(AccountEntity account, Notification.Type type) {
|
||||
switch (type) {
|
||||
case MENTION:
|
||||
return CHANNEL_MENTION + account.getIdentifier();
|
||||
case STATUS:
|
||||
return CHANNEL_SUBSCRIPTIONS + account.getIdentifier();
|
||||
case FOLLOW:
|
||||
return CHANNEL_FOLLOW + account.getIdentifier();
|
||||
case FOLLOW_REQUEST:
|
||||
return CHANNEL_FOLLOW_REQUEST + account.getIdentifier();
|
||||
case REBLOG:
|
||||
return CHANNEL_BOOST + account.getIdentifier();
|
||||
case FAVOURITE:
|
||||
return CHANNEL_FAVOURITE + account.getIdentifier();
|
||||
case POLL:
|
||||
return CHANNEL_POLL + account.getIdentifier();
|
||||
case SIGN_UP:
|
||||
return CHANNEL_SIGN_UP + account.getIdentifier();
|
||||
case UPDATE:
|
||||
return CHANNEL_UPDATES + account.getIdentifier();
|
||||
case REPORT:
|
||||
return CHANNEL_REPORT + account.getIdentifier();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static void setSoundVibrationLight(AccountEntity account, NotificationCompat.Builder builder) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
return; //do nothing on Android O or newer, the system uses the channel settings anyway
|
||||
}
|
||||
|
||||
if (account.getNotificationSound()) {
|
||||
builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI);
|
||||
}
|
||||
|
||||
if (account.getNotificationVibration()) {
|
||||
builder.setVibrate(new long[]{500, 500});
|
||||
}
|
||||
|
||||
if (account.getNotificationLight()) {
|
||||
builder.setLights(0xFF2B90D9, 300, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private static String joinNames(Context context, List<StatusBarNotification> notifications1, List<Notification> notifications2) {
|
||||
List<String> names = new ArrayList<>(notifications1.size() + notifications2.size());
|
||||
|
||||
for (StatusBarNotification notification: notifications1) {
|
||||
names.add(notification.getNotification().extras.getString(EXTRA_ACCOUNT_NAME));
|
||||
}
|
||||
|
||||
for (Notification notification : notifications2) {
|
||||
names.add(notification.getAccount().getName());
|
||||
}
|
||||
|
||||
return joinNames(context, names);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String joinNames(Context context, List<String> names) {
|
||||
if (names.size() > 3) {
|
||||
int length = names.size();
|
||||
return context.getString(R.string.notification_summary_large,
|
||||
StringUtils.unicodeWrap(names.get(length - 1)),
|
||||
StringUtils.unicodeWrap(names.get(length - 2)),
|
||||
StringUtils.unicodeWrap(names.get(length - 3)),
|
||||
length - 3
|
||||
);
|
||||
} else if (names.size() == 3) {
|
||||
return context.getString(R.string.notification_summary_medium,
|
||||
StringUtils.unicodeWrap(names.get(2)),
|
||||
StringUtils.unicodeWrap(names.get(1)),
|
||||
StringUtils.unicodeWrap(names.get(0))
|
||||
);
|
||||
} else if (names.size() == 2) {
|
||||
return context.getString(R.string.notification_summary_small,
|
||||
StringUtils.unicodeWrap(names.get(1)),
|
||||
StringUtils.unicodeWrap(names.get(0))
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String titleForType(Context context, Notification notification, AccountEntity account) {
|
||||
String accountName = StringUtils.unicodeWrap(notification.getAccount().getName());
|
||||
switch (notification.getType()) {
|
||||
case MENTION:
|
||||
return context.getString(R.string.notification_mention_format, accountName);
|
||||
case STATUS:
|
||||
return context.getString(R.string.notification_subscription_format, accountName);
|
||||
case FOLLOW:
|
||||
return context.getString(R.string.notification_follow_format, accountName);
|
||||
case FOLLOW_REQUEST:
|
||||
return context.getString(R.string.notification_follow_request_format, accountName);
|
||||
case FAVOURITE:
|
||||
return context.getString(R.string.notification_favourite_format, accountName);
|
||||
case REBLOG:
|
||||
return context.getString(R.string.notification_reblog_format, accountName);
|
||||
case POLL:
|
||||
if(notification.getStatus().getAccount().getId().equals(account.getAccountId())) {
|
||||
return context.getString(R.string.poll_ended_created);
|
||||
} else {
|
||||
return context.getString(R.string.poll_ended_voted);
|
||||
}
|
||||
case SIGN_UP:
|
||||
return context.getString(R.string.notification_sign_up_format, accountName);
|
||||
case UPDATE:
|
||||
return context.getString(R.string.notification_update_format, accountName);
|
||||
case REPORT:
|
||||
return context.getString(R.string.notification_report_format, account.getDomain());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String bodyForType(Notification notification, Context context, Boolean alwaysOpenSpoiler) {
|
||||
switch (notification.getType()) {
|
||||
case FOLLOW:
|
||||
case FOLLOW_REQUEST:
|
||||
case SIGN_UP:
|
||||
return "@" + notification.getAccount().getUsername();
|
||||
case MENTION:
|
||||
case FAVOURITE:
|
||||
case REBLOG:
|
||||
case STATUS:
|
||||
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) {
|
||||
return notification.getStatus().getSpoilerText();
|
||||
} else {
|
||||
return parseAsMastodonHtml(notification.getStatus().getContent()).toString();
|
||||
}
|
||||
case POLL:
|
||||
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) {
|
||||
return notification.getStatus().getSpoilerText();
|
||||
} else {
|
||||
StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent()));
|
||||
builder.append('\n');
|
||||
Poll poll = notification.getStatus().getPoll();
|
||||
List<PollOption> options = poll.getOptions();
|
||||
for(int i = 0; i < options.size(); ++i) {
|
||||
PollOption option = options.get(i);
|
||||
builder.append(buildDescription(option.getTitle(),
|
||||
PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()),
|
||||
poll.getOwnVotes().contains(i),
|
||||
context));
|
||||
builder.append('\n');
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
case REPORT:
|
||||
return context.getString(
|
||||
R.string.notification_header_report_format,
|
||||
StringUtils.unicodeWrap(notification.getAccount().getName()),
|
||||
StringUtils.unicodeWrap(notification.getReport().getTargetAccount().getName())
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static int pendingIntentFlags(boolean mutable) {
|
||||
if (mutable) {
|
||||
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0);
|
||||
} else {
|
||||
return PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,910 @@
|
|||
package com.keylesspalace.tusky.components.systemnotifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.service.notification.StatusBarNotification
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import at.connyduck.calladapter.networkresult.onFailure
|
||||
import at.connyduck.calladapter.networkresult.onSuccess
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.MainActivity
|
||||
import com.keylesspalace.tusky.MainActivity.Companion.composeIntent
|
||||
import com.keylesspalace.tusky.MainActivity.Companion.openNotificationIntent
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver
|
||||
import com.keylesspalace.tusky.util.CryptoUtil
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.viewdata.buildDescription
|
||||
import com.keylesspalace.tusky.viewdata.calculatePercent
|
||||
import com.keylesspalace.tusky.worker.NotificationWorker
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
@Singleton
|
||||
class NotificationService @Inject constructor(
|
||||
private val notificationManager: NotificationManager,
|
||||
private val accountManager: AccountManager,
|
||||
private val api: MastodonApi,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private var notificationId: Int = NOTIFICATION_ID_PRUNE_CACHE + 1
|
||||
|
||||
init {
|
||||
createWorkerNotificationChannel()
|
||||
}
|
||||
|
||||
fun areNotificationsEnabled(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// on Android >= O, notifications are enabled, if at least one channel is enabled
|
||||
|
||||
if (notificationManager.areNotificationsEnabled()) {
|
||||
for (channel in notificationManager.notificationChannels) {
|
||||
if (channel != null && channel.importance > NotificationManager.IMPORTANCE_NONE) {
|
||||
Log.d(TAG, "Notifications enabled for app by the system.")
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Notifications disabled for app by the system.")
|
||||
|
||||
return false
|
||||
} else {
|
||||
// on Android < O, notifications are enabled, if at least one account has notification enabled
|
||||
return accountManager.areNotificationsEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
fun createNotificationChannelsForAccount(account: AccountEntity) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
data class ChannelData(
|
||||
val id: String,
|
||||
@StringRes val name: Int,
|
||||
@StringRes val description: Int,
|
||||
)
|
||||
|
||||
// TODO REBLOG and esp. STATUS have very different names than the type itself.
|
||||
val channelData = arrayOf(
|
||||
ChannelData(
|
||||
getChannelId(account, Notification.Type.MENTION)!!,
|
||||
R.string.notification_mention_name,
|
||||
R.string.notification_mention_descriptions,
|
||||
),
|
||||
ChannelData(
|
||||
getChannelId(account, Notification.Type.FOLLOW)!!,
|
||||
R.string.notification_follow_name,
|
||||
R.string.notification_follow_description,
|
||||
),
|
||||
ChannelData(
|
||||
getChannelId(account, Notification.Type.FOLLOW_REQUEST)!!,
|
||||
R.string.notification_follow_request_name,
|
||||
R.string.notification_follow_request_description,
|
||||
),
|
||||
ChannelData(
|
||||
getChannelId(account, Notification.Type.REBLOG)!!,
|
||||
R.string.notification_boost_name,
|
||||
R.string.notification_boost_description,
|
||||
),
|
||||
ChannelData(
|
||||
getChannelId(account, Notification.Type.FAVOURITE)!!,
|
||||
R.string.notification_favourite_name,
|
||||
R.string.notification_favourite_description,
|
||||
),
|
||||
ChannelData(
|
||||
getChannelId(account, Notification.Type.POLL)!!,
|
||||
R.string.notification_poll_name,
|
||||
R.string.notification_poll_description,
|
||||
),
|
||||
ChannelData(
|
||||
getChannelId(account, Notification.Type.STATUS)!!,
|
||||
R.string.notification_subscription_name,
|
||||
R.string.notification_subscription_description,
|
||||
),
|
||||
ChannelData(
|
||||
getChannelId(account, Notification.Type.SIGN_UP)!!,
|
||||
R.string.notification_sign_up_name,
|
||||
R.string.notification_sign_up_description,
|
||||
),
|
||||
ChannelData(
|
||||
getChannelId(account, Notification.Type.UPDATE)!!,
|
||||
R.string.notification_update_name,
|
||||
R.string.notification_update_description,
|
||||
),
|
||||
ChannelData(
|
||||
getChannelId(account, Notification.Type.REPORT)!!,
|
||||
R.string.notification_report_name,
|
||||
R.string.notification_report_description,
|
||||
),
|
||||
)
|
||||
// TODO enumerate all keys of Notification.Type and check if one is missing here?
|
||||
|
||||
val channelGroup = NotificationChannelGroup(account.identifier, account.fullName)
|
||||
notificationManager.createNotificationChannelGroup(channelGroup)
|
||||
|
||||
val channels = channelData.map {
|
||||
NotificationChannel(it.id, context.getString(it.name), NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = context.getString(it.description)
|
||||
enableLights(true)
|
||||
lightColor = -0xd46f27
|
||||
enableVibration(true)
|
||||
setShowBadge(true)
|
||||
group = account.identifier
|
||||
}
|
||||
}
|
||||
|
||||
notificationManager.createNotificationChannels(channels)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNotificationChannelsForAccount(account: AccountEntity) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.deleteNotificationChannelGroup(account.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
fun enablePullNotifications() {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG)
|
||||
|
||||
// Periodic work requests are supposed to start running soon after being enqueued. In
|
||||
// practice that may not be soon enough, so create and enqueue an expedited one-time
|
||||
// request to get new notifications immediately.
|
||||
val fetchNotifications: WorkRequest = OneTimeWorkRequest.Builder(NotificationWorker::class.java)
|
||||
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
workManager.enqueue(fetchNotifications)
|
||||
|
||||
val workRequest: WorkRequest = PeriodicWorkRequest.Builder(
|
||||
NotificationWorker::class.java,
|
||||
PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS,
|
||||
TimeUnit.MILLISECONDS,
|
||||
PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS,
|
||||
TimeUnit.MILLISECONDS,
|
||||
)
|
||||
.addTag(NOTIFICATION_PULL_TAG)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.setInitialDelay(5, TimeUnit.MINUTES)
|
||||
.build()
|
||||
|
||||
workManager.enqueue(workRequest)
|
||||
|
||||
Log.d(TAG, "Enabled pull checks with " + PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval.")
|
||||
}
|
||||
|
||||
fun disablePullNotifications() {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG)
|
||||
Log.d(TAG, "Disabled pull checks.")
|
||||
}
|
||||
|
||||
fun clearNotificationsForAccount(account: AccountEntity) {
|
||||
for (androidNotification in notificationManager.activeNotifications) {
|
||||
if (account.id.toInt() == androidNotification.id) {
|
||||
notificationManager.cancel(androidNotification.tag, androidNotification.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun filterNotification(account: AccountEntity, type: Notification.Type): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channelId = getChannelId(account, type)
|
||||
?: // unknown notificationtype
|
||||
return false
|
||||
val channel = notificationManager.getNotificationChannel(channelId)
|
||||
|
||||
return channel != null && channel.importance > NotificationManager.IMPORTANCE_NONE
|
||||
}
|
||||
|
||||
return when (type) {
|
||||
Notification.Type.MENTION -> account.notificationsMentioned
|
||||
Notification.Type.STATUS -> account.notificationsSubscriptions
|
||||
Notification.Type.FOLLOW -> account.notificationsFollowed
|
||||
Notification.Type.FOLLOW_REQUEST -> account.notificationsFollowRequested
|
||||
Notification.Type.REBLOG -> account.notificationsReblogged
|
||||
Notification.Type.FAVOURITE -> account.notificationsFavorited
|
||||
Notification.Type.POLL -> account.notificationsPolls
|
||||
Notification.Type.SIGN_UP -> account.notificationsSignUps
|
||||
Notification.Type.UPDATE -> account.notificationsUpdates
|
||||
Notification.Type.REPORT -> account.notificationsReports
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun show(account: AccountEntity, notifications: List<Notification>) {
|
||||
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
if (notifications.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val newNotifications = ArrayList<NotificationWithIdAndTag>()
|
||||
|
||||
val notificationsByType: Map<Notification.Type, List<Notification>> = notifications.groupBy { it.type }
|
||||
for ((type, notificationsForOneType) in notificationsByType) {
|
||||
val summary = createSummaryNotification(account, type, notificationsForOneType) ?: continue
|
||||
|
||||
// NOTE Enqueue the summary first: Needed to avoid rate limit problems:
|
||||
// ie. single notification is enqueued but that later summary one is filtered and thus no grouping
|
||||
// takes place.
|
||||
newNotifications.add(summary)
|
||||
|
||||
for (notification in notificationsForOneType) {
|
||||
val single = createNotification(notification, account) ?: continue
|
||||
newNotifications.add(single)
|
||||
}
|
||||
}
|
||||
|
||||
val notificationManagerCompat = NotificationManagerCompat.from(context)
|
||||
// NOTE having multiple summary notifications: this here should still collapse them in only one occurrence
|
||||
notificationManagerCompat.notify(newNotifications)
|
||||
}
|
||||
|
||||
private fun createNotification(apiNotification: Notification, account: AccountEntity): NotificationWithIdAndTag? {
|
||||
val baseNotification = createBaseNotification(apiNotification, account) ?: return null
|
||||
|
||||
return NotificationWithIdAndTag(
|
||||
apiNotification.id,
|
||||
account.id.toInt(),
|
||||
baseNotification
|
||||
)
|
||||
}
|
||||
|
||||
// Only public for one test...
|
||||
fun createBaseNotification(apiNotification: Notification, account: AccountEntity): android.app.Notification? {
|
||||
val channelId = getChannelId(account, apiNotification.type) ?: return null
|
||||
|
||||
val body = apiNotification.rewriteToStatusTypeIfNeeded(account.accountId)
|
||||
|
||||
// Check for an existing notification matching this account and api notification
|
||||
var existingAndroidNotification: android.app.Notification? = null
|
||||
val activeNotifications = notificationManager.activeNotifications
|
||||
for (androidNotification in activeNotifications) {
|
||||
if (body.id == androidNotification.tag && account.id.toInt() == androidNotification.id) {
|
||||
existingAndroidNotification = androidNotification.notification
|
||||
}
|
||||
}
|
||||
|
||||
notificationId++
|
||||
|
||||
val builder = if (existingAndroidNotification == null) {
|
||||
getNotificationBuilder(body.type, account, channelId)
|
||||
} else {
|
||||
NotificationCompat.Builder(context, existingAndroidNotification)
|
||||
}
|
||||
|
||||
builder
|
||||
.setContentTitle(titleForType(body, account))
|
||||
.setContentText(bodyForType(body, account.alwaysOpenSpoiler))
|
||||
|
||||
if (body.type == Notification.Type.MENTION || body.type == Notification.Type.POLL) {
|
||||
builder.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(bodyForType(body, account.alwaysOpenSpoiler))
|
||||
)
|
||||
}
|
||||
|
||||
val accountAvatar = try {
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.load(body.account.avatar)
|
||||
.transform(RoundedCorners(20))
|
||||
.submit()
|
||||
.get()
|
||||
} catch (e: ExecutionException) {
|
||||
Log.d(TAG, "Error loading account avatar", e)
|
||||
BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default)
|
||||
} catch (e: InterruptedException) {
|
||||
Log.d(TAG, "Error loading account avatar", e)
|
||||
BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default)
|
||||
}
|
||||
|
||||
builder.setLargeIcon(accountAvatar)
|
||||
|
||||
// Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat
|
||||
if (body.type == Notification.Type.MENTION) {
|
||||
val replyRemoteInput = RemoteInput.Builder(KEY_REPLY)
|
||||
.setLabel(context.getString(R.string.label_quick_reply))
|
||||
.build()
|
||||
|
||||
val quickReplyPendingIntent = getStatusReplyIntent(body, account, notificationId)
|
||||
|
||||
val quickReplyAction =
|
||||
NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_reply_24dp,
|
||||
context.getString(R.string.action_quick_reply),
|
||||
quickReplyPendingIntent
|
||||
)
|
||||
.addRemoteInput(replyRemoteInput)
|
||||
.build()
|
||||
|
||||
builder.addAction(quickReplyAction)
|
||||
|
||||
val composeIntent = getStatusComposeIntent(body, account, notificationId)
|
||||
|
||||
val composeAction =
|
||||
NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_reply_24dp,
|
||||
context.getString(R.string.action_compose_shortcut),
|
||||
composeIntent
|
||||
)
|
||||
.setShowsUserInterface(true)
|
||||
.build()
|
||||
|
||||
builder.addAction(composeAction)
|
||||
}
|
||||
|
||||
builder.addExtras(
|
||||
Bundle().apply {
|
||||
// Add the sending account's name, so it can be used also later when summarising this notification
|
||||
putString(EXTRA_ACCOUNT_NAME, body.account.name)
|
||||
putString(EXTRA_NOTIFICATION_TYPE, body.type.name)
|
||||
}
|
||||
)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a notification that summarises the other notifications in this group.
|
||||
*
|
||||
* NOTE: We always create a summary notification (even for activeNotificationsForType.size() == 1):
|
||||
* - No need to especially track the grouping
|
||||
* - No need to change an existing single notification when there arrives another one of its group
|
||||
* - Only the summary one will get announced
|
||||
*/
|
||||
private fun createSummaryNotification(account: AccountEntity, type: Notification.Type, additionalNotifications: List<Notification>): NotificationWithIdAndTag? {
|
||||
val typeChannelId = getChannelId(account, type) ?: return null
|
||||
|
||||
val summaryStackBuilder = TaskStackBuilder.create(context)
|
||||
summaryStackBuilder.addParentStack(MainActivity::class.java)
|
||||
val summaryResultIntent = openNotificationIntent(context, account.id, type)
|
||||
summaryStackBuilder.addNextIntent(summaryResultIntent)
|
||||
|
||||
val summaryResultPendingIntent = summaryStackBuilder.getPendingIntent(
|
||||
(notificationId + account.id * 10000).toInt(),
|
||||
pendingIntentFlags(false)
|
||||
)
|
||||
|
||||
val activeNotifications = getActiveNotifications(account.id, typeChannelId)
|
||||
|
||||
val notificationCount = activeNotifications.size + additionalNotifications.size
|
||||
|
||||
val title = context.resources.getQuantityString(R.plurals.notification_title_summary, notificationCount, notificationCount)
|
||||
val text = joinNames(activeNotifications, additionalNotifications)
|
||||
|
||||
val summaryBuilder = NotificationCompat.Builder(context, typeChannelId)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setContentIntent(summaryResultPendingIntent)
|
||||
.setColor(context.getColor(R.color.notification_color))
|
||||
.setAutoCancel(true)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setShortcutId(account.id.toString())
|
||||
.setSubText(account.fullName)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
.setGroup(typeChannelId)
|
||||
.setGroupSummary(true)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||
|
||||
setSoundVibrationLight(account, summaryBuilder)
|
||||
|
||||
val summaryTag = "$GROUP_SUMMARY_TAG.$typeChannelId"
|
||||
|
||||
return NotificationWithIdAndTag(summaryTag, account.id.toInt(), summaryBuilder.build())
|
||||
}
|
||||
|
||||
fun createWorkerNotification(@StringRes titleResource: Int): android.app.Notification {
|
||||
val title = context.getString(titleResource)
|
||||
return NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getChannelId(account: AccountEntity, type: Notification.Type): String? {
|
||||
return when (type) {
|
||||
Notification.Type.MENTION -> CHANNEL_MENTION + account.identifier
|
||||
Notification.Type.STATUS -> "CHANNEL_SUBSCRIPTIONS" + account.identifier
|
||||
Notification.Type.FOLLOW -> "CHANNEL_FOLLOW" + account.identifier
|
||||
Notification.Type.FOLLOW_REQUEST -> "CHANNEL_FOLLOW_REQUEST" + account.identifier
|
||||
Notification.Type.REBLOG -> "CHANNEL_BOOST" + account.identifier
|
||||
Notification.Type.FAVOURITE -> "CHANNEL_FAVOURITE" + account.identifier
|
||||
Notification.Type.POLL -> "CHANNEL_POLL" + account.identifier
|
||||
Notification.Type.SIGN_UP -> "CHANNEL_SIGN_UP" + account.identifier
|
||||
Notification.Type.UPDATE -> "CHANNEL_UPDATES" + account.identifier
|
||||
Notification.Type.REPORT -> "CHANNEL_REPORT" + account.identifier
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all active notifications, ignoring notifications that:
|
||||
* - belong to a different account
|
||||
* - belong to a different type
|
||||
* - are summary notifications
|
||||
*/
|
||||
private fun getActiveNotifications(accountId: Long, typeChannelId: String): List<StatusBarNotification> {
|
||||
return notificationManager.activeNotifications.filter {
|
||||
val channelId = it.notification.group
|
||||
it.id == accountId.toInt() && channelId == typeChannelId && it.tag != "$GROUP_SUMMARY_TAG.$channelId"
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNotificationBuilder(notificationType: Notification.Type, account: AccountEntity, channelId: String): NotificationCompat.Builder {
|
||||
val eventResultIntent = openNotificationIntent(context, account.id, notificationType)
|
||||
|
||||
val eventStackBuilder = TaskStackBuilder.create(context)
|
||||
eventStackBuilder.addParentStack(MainActivity::class.java)
|
||||
eventStackBuilder.addNextIntent(eventResultIntent)
|
||||
|
||||
val eventResultPendingIntent = eventStackBuilder.getPendingIntent(
|
||||
account.id.toInt(),
|
||||
pendingIntentFlags(false)
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setContentIntent(eventResultPendingIntent)
|
||||
.setColor(context.getColor(R.color.notification_color))
|
||||
.setAutoCancel(true)
|
||||
.setShortcutId(account.id.toString())
|
||||
.setSubText(account.fullName)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(channelId)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) // Only ever alert for the summary notification
|
||||
|
||||
setSoundVibrationLight(account, builder)
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
private fun titleForType(notification: Notification, account: AccountEntity): String? {
|
||||
if (notification.status == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val accountName = notification.account.name.unicodeWrap()
|
||||
when (notification.type) {
|
||||
Notification.Type.MENTION -> return context.getString(R.string.notification_mention_format, accountName)
|
||||
Notification.Type.STATUS -> return context.getString(R.string.notification_subscription_format, accountName)
|
||||
Notification.Type.FOLLOW -> return context.getString(R.string.notification_follow_format, accountName)
|
||||
Notification.Type.FOLLOW_REQUEST -> return context.getString(R.string.notification_follow_request_format, accountName)
|
||||
Notification.Type.FAVOURITE -> return context.getString(R.string.notification_favourite_format, accountName)
|
||||
Notification.Type.REBLOG -> return context.getString(R.string.notification_reblog_format, accountName)
|
||||
Notification.Type.POLL -> return if (notification.status.account.id == account.accountId) {
|
||||
context.getString(R.string.poll_ended_created)
|
||||
} else {
|
||||
context.getString(R.string.poll_ended_voted)
|
||||
}
|
||||
Notification.Type.SIGN_UP -> return context.getString(R.string.notification_sign_up_format, accountName)
|
||||
Notification.Type.UPDATE -> return context.getString(R.string.notification_update_format, accountName)
|
||||
Notification.Type.REPORT -> return context.getString(R.string.notification_report_format, account.domain)
|
||||
Notification.Type.UNKNOWN -> return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun bodyForType(notification: Notification, alwaysOpenSpoiler: Boolean): String? {
|
||||
if (notification.status == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
when (notification.type) {
|
||||
Notification.Type.FOLLOW, Notification.Type.FOLLOW_REQUEST, Notification.Type.SIGN_UP -> return "@" + notification.account.username
|
||||
Notification.Type.MENTION, Notification.Type.FAVOURITE, Notification.Type.REBLOG, Notification.Type.STATUS -> return if (!TextUtils.isEmpty(notification.status.spoilerText) && !alwaysOpenSpoiler) {
|
||||
notification.status.spoilerText
|
||||
} else {
|
||||
notification.status.content.parseAsMastodonHtml().toString()
|
||||
}
|
||||
Notification.Type.POLL -> if (!TextUtils.isEmpty(notification.status.spoilerText) && !alwaysOpenSpoiler) {
|
||||
return notification.status.spoilerText
|
||||
} else {
|
||||
val poll = notification.status.poll ?: return null
|
||||
|
||||
val builder = StringBuilder(notification.status.content.parseAsMastodonHtml())
|
||||
builder.append('\n')
|
||||
|
||||
poll.options.forEachIndexed { i, option ->
|
||||
builder.append(
|
||||
buildDescription(
|
||||
option.title,
|
||||
calculatePercent(option.votesCount, poll.votersCount, poll.votesCount),
|
||||
poll.ownVotes.contains(i),
|
||||
context
|
||||
)
|
||||
)
|
||||
builder.append('\n')
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
Notification.Type.REPORT -> return context.getString(
|
||||
R.string.notification_header_report_format,
|
||||
notification.account.name.unicodeWrap(),
|
||||
notification.report!!.targetAccount.name.unicodeWrap()
|
||||
)
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun createWorkerNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_BACKGROUND_TASKS,
|
||||
context.getString(R.string.notification_listenable_worker_name),
|
||||
NotificationManager.IMPORTANCE_NONE
|
||||
)
|
||||
|
||||
channel.description = context.getString(R.string.notification_listenable_worker_description)
|
||||
channel.enableLights(false)
|
||||
channel.enableVibration(false)
|
||||
channel.setShowBadge(false)
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun setSoundVibrationLight(account: AccountEntity, builder: NotificationCompat.Builder) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
return // Do nothing on Android O or newer, the system uses only the channel settings
|
||||
}
|
||||
|
||||
builder.setDefaults(0)
|
||||
|
||||
if (account.notificationSound) {
|
||||
builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI)
|
||||
}
|
||||
|
||||
if (account.notificationVibration) {
|
||||
builder.setVibrate(longArrayOf(500, 500))
|
||||
}
|
||||
|
||||
if (account.notificationLight) {
|
||||
builder.setLights(-0xd46f27, 300, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
private fun joinNames(notifications1: List<StatusBarNotification>, notifications2: List<Notification>): String? {
|
||||
val names = java.util.ArrayList<String>(notifications1.size + notifications2.size)
|
||||
|
||||
for (notification in notifications1) {
|
||||
val author = notification.notification.extras.getString(EXTRA_ACCOUNT_NAME) ?: continue
|
||||
names.add(author)
|
||||
}
|
||||
|
||||
for (noti in notifications2) {
|
||||
names.add(noti.account.name)
|
||||
}
|
||||
|
||||
if (names.size > 3) {
|
||||
val length = names.size
|
||||
return context.getString(
|
||||
R.string.notification_summary_large,
|
||||
names[length - 1].unicodeWrap(),
|
||||
names[length - 2].unicodeWrap(),
|
||||
names[length - 3].unicodeWrap(),
|
||||
length - 3
|
||||
)
|
||||
} else if (names.size == 3) {
|
||||
return context.getString(
|
||||
R.string.notification_summary_medium,
|
||||
names[2].unicodeWrap(),
|
||||
names[1].unicodeWrap(),
|
||||
names[0].unicodeWrap()
|
||||
)
|
||||
} else if (names.size == 2) {
|
||||
return context.getString(
|
||||
R.string.notification_summary_small,
|
||||
names[1].unicodeWrap(),
|
||||
names[0].unicodeWrap()
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getStatusReplyIntent(apiNotification: Notification, account: AccountEntity, requestCode: Int): PendingIntent {
|
||||
val status = checkNotNull(apiNotification.status)
|
||||
|
||||
val inReplyToId = status.id
|
||||
val actionableStatus = status.actionableStatus
|
||||
val replyVisibility = actionableStatus.visibility
|
||||
val contentWarning = actionableStatus.spoilerText
|
||||
val mentions = actionableStatus.mentions
|
||||
|
||||
val mentionedUsernames = buildSet {
|
||||
add(actionableStatus.account.username)
|
||||
for (mention in mentions) {
|
||||
add(mention.username)
|
||||
}
|
||||
remove(account.username)
|
||||
}
|
||||
|
||||
val replyIntent = Intent(context, SendStatusBroadcastReceiver::class.java)
|
||||
.setAction(REPLY_ACTION)
|
||||
.putExtra(KEY_SENDER_ACCOUNT_ID, account.id)
|
||||
.putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.identifier)
|
||||
.putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.fullName)
|
||||
.putExtra(KEY_SERVER_NOTIFICATION_ID, apiNotification.id)
|
||||
.putExtra(KEY_CITED_STATUS_ID, inReplyToId)
|
||||
.putExtra(KEY_VISIBILITY, replyVisibility)
|
||||
.putExtra(KEY_SPOILER, contentWarning)
|
||||
.putExtra(KEY_MENTIONS, mentionedUsernames.toTypedArray<String?>())
|
||||
|
||||
return PendingIntent.getBroadcast(
|
||||
context.applicationContext,
|
||||
requestCode,
|
||||
replyIntent,
|
||||
pendingIntentFlags(true)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getStatusComposeIntent(apiNotification: Notification, account: AccountEntity, requestCode: Int): PendingIntent {
|
||||
val status = checkNotNull(apiNotification.status)
|
||||
|
||||
val citedLocalAuthor = status.account.localUsername
|
||||
val citedText = status.content.parseAsMastodonHtml().toString()
|
||||
val inReplyToId = status.id
|
||||
val actionableStatus = status.actionableStatus
|
||||
val replyVisibility = actionableStatus.visibility
|
||||
val contentWarning = actionableStatus.spoilerText
|
||||
val mentions = actionableStatus.mentions
|
||||
|
||||
val mentionedUsernames = buildSet {
|
||||
add(actionableStatus.account.username)
|
||||
for (mention in mentions) {
|
||||
add(mention.username)
|
||||
}
|
||||
remove(account.username)
|
||||
}
|
||||
|
||||
val composeOptions = ComposeOptions()
|
||||
composeOptions.inReplyToId = inReplyToId
|
||||
composeOptions.replyVisibility = replyVisibility
|
||||
composeOptions.contentWarning = contentWarning
|
||||
composeOptions.replyingStatusAuthor = citedLocalAuthor
|
||||
composeOptions.replyingStatusContent = citedText
|
||||
composeOptions.mentionedUsernames = mentionedUsernames
|
||||
composeOptions.modifiedInitialState = true
|
||||
composeOptions.language = actionableStatus.language
|
||||
composeOptions.kind = ComposeActivity.ComposeKind.NEW
|
||||
|
||||
val composeIntent = composeIntent(context, composeOptions, account.id, apiNotification.id, account.id.toInt())
|
||||
|
||||
// make sure a new instance of MainActivity is started and old ones get destroyed
|
||||
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
context.applicationContext,
|
||||
requestCode,
|
||||
composeIntent,
|
||||
pendingIntentFlags(false)
|
||||
)
|
||||
}
|
||||
|
||||
private fun pendingIntentFlags(mutable: Boolean): Int {
|
||||
return if (mutable) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
}
|
||||
}
|
||||
|
||||
fun disableAllNotifications() {
|
||||
disablePushNotificationsForAllAccounts()
|
||||
disablePullNotifications()
|
||||
}
|
||||
|
||||
//
|
||||
// Push notification section
|
||||
//
|
||||
|
||||
fun isUnifiedPushAvailable(): Boolean =
|
||||
UnifiedPush.getDistributors(context).isNotEmpty()
|
||||
|
||||
suspend fun enablePushNotificationsWithFallback() {
|
||||
if (!isUnifiedPushAvailable()) {
|
||||
// No distributors
|
||||
enablePullNotifications()
|
||||
return
|
||||
}
|
||||
|
||||
accountManager.accounts.forEach {
|
||||
val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 ||
|
||||
notificationManager.getNotificationChannelGroup(it.identifier)?.isBlocked == false
|
||||
val shouldEnable = it.notificationsEnabled && notificationGroupEnabled
|
||||
|
||||
if (shouldEnable) {
|
||||
enableUnifiedPushNotificationsForAccount(it)
|
||||
} else {
|
||||
disableUnifiedPushNotificationsForAccount(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun enableUnifiedPushNotificationsForAccount(account: AccountEntity) {
|
||||
if (account.isPushNotificationsEnabled()) {
|
||||
// Already registered, update the subscription to match notification settings
|
||||
updateUnifiedPushSubscription(account)
|
||||
} else {
|
||||
UnifiedPush.registerAppWithDialog(
|
||||
context,
|
||||
account.id.toString(),
|
||||
features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun disablePushNotificationsForAllAccounts() {
|
||||
accountManager.accounts.forEach {
|
||||
disableUnifiedPushNotificationsForAccount(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableUnifiedPushNotificationsForAccount(account: AccountEntity) {
|
||||
if (!account.isPushNotificationsEnabled()) {
|
||||
// Not registered
|
||||
return
|
||||
}
|
||||
|
||||
UnifiedPush.unregisterApp(context, account.id.toString())
|
||||
}
|
||||
|
||||
private fun buildSubscriptionData(account: AccountEntity): Map<String, Boolean> =
|
||||
buildMap {
|
||||
Notification.Type.visibleTypes.forEach {
|
||||
put(
|
||||
"data[alerts][${it.presentation}]",
|
||||
filterNotification(account, it)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Called by UnifiedPush callback in UnifiedPushBroadcastReceiver
|
||||
suspend fun registerUnifiedPushEndpoint(
|
||||
account: AccountEntity,
|
||||
endpoint: String
|
||||
) = withContext(Dispatchers.IO) {
|
||||
// Generate a prime256v1 key pair for WebPush
|
||||
// Decryption is unimplemented for now, since Mastodon uses an old WebPush
|
||||
// standard which does not send needed information for decryption in the payload
|
||||
// This makes it not directly compatible with UnifiedPush
|
||||
// As of now, we use it purely as a way to trigger a pull
|
||||
// TODO that is still correct?
|
||||
val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1)
|
||||
val auth = CryptoUtil.secureRandomBytesEncoded(16)
|
||||
|
||||
api.subscribePushNotifications(
|
||||
"Bearer ${account.accessToken}",
|
||||
account.domain,
|
||||
endpoint,
|
||||
keyPair.pubkey,
|
||||
auth,
|
||||
buildSubscriptionData(account)
|
||||
).onFailure { throwable ->
|
||||
Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable)
|
||||
disableUnifiedPushNotificationsForAccount(account)
|
||||
}.onSuccess {
|
||||
Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}")
|
||||
|
||||
accountManager.updateAccount(account) {
|
||||
copy(
|
||||
pushPubKey = keyPair.pubkey,
|
||||
pushPrivKey = keyPair.privKey,
|
||||
pushAuth = auth,
|
||||
pushServerKey = it.serverKey,
|
||||
unifiedPushUrl = endpoint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronize the enabled / disabled state of notifications with server-side subscription
|
||||
suspend fun updateUnifiedPushSubscription(account: AccountEntity) {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.updatePushNotificationSubscription(
|
||||
"Bearer ${account.accessToken}",
|
||||
account.domain,
|
||||
buildSubscriptionData(account)
|
||||
).onSuccess {
|
||||
Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}")
|
||||
accountManager.updateAccount(account) {
|
||||
copy(pushServerKey = it.serverKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unregisterUnifiedPushEndpoint(account: AccountEntity) {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain)
|
||||
.onFailure { throwable ->
|
||||
Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable)
|
||||
}
|
||||
.onSuccess {
|
||||
Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)
|
||||
// Clear the URL in database
|
||||
accountManager.updateAccount(account) {
|
||||
copy(
|
||||
pushPubKey = "",
|
||||
pushPrivKey = "",
|
||||
pushAuth = "",
|
||||
pushServerKey = "",
|
||||
unifiedPushUrl = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "NotificationService"
|
||||
|
||||
const val CHANNEL_MENTION: String = "CHANNEL_MENTION"
|
||||
const val KEY_CITED_STATUS_ID: String = "KEY_CITED_STATUS_ID"
|
||||
const val KEY_MENTIONS: String = "KEY_MENTIONS"
|
||||
const val KEY_REPLY: String = "KEY_REPLY"
|
||||
const val KEY_SENDER_ACCOUNT_FULL_NAME: String = "KEY_SENDER_ACCOUNT_FULL_NAME"
|
||||
const val KEY_SENDER_ACCOUNT_ID: String = "KEY_SENDER_ACCOUNT_ID"
|
||||
const val KEY_SENDER_ACCOUNT_IDENTIFIER: String = "KEY_SENDER_ACCOUNT_IDENTIFIER"
|
||||
const val KEY_SERVER_NOTIFICATION_ID: String = "KEY_SERVER_NOTIFICATION_ID"
|
||||
const val KEY_SPOILER: String = "KEY_SPOILER"
|
||||
const val KEY_VISIBILITY: String = "KEY_VISIBILITY"
|
||||
const val NOTIFICATION_ID_FETCH_NOTIFICATION: Int = 0
|
||||
const val NOTIFICATION_ID_PRUNE_CACHE: Int = 1
|
||||
const val REPLY_ACTION: String = "REPLY_ACTION"
|
||||
|
||||
private const val CHANNEL_BACKGROUND_TASKS: String = "CHANNEL_BACKGROUND_TASKS"
|
||||
private const val EXTRA_ACCOUNT_NAME = BuildConfig.APPLICATION_ID + ".notification.extra.account_name"
|
||||
private const val EXTRA_NOTIFICATION_TYPE = BuildConfig.APPLICATION_ID + ".notification.extra.notification_type"
|
||||
private const val GROUP_SUMMARY_TAG = BuildConfig.APPLICATION_ID + ".notification.group_summary"
|
||||
private const val NOTIFICATION_PULL_TAG = "pullNotifications"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
/* Copyright 2022 Tusky contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
@file:JvmName("PushNotificationHelper")
|
||||
|
||||
package com.keylesspalace.tusky.components.systemnotifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import at.connyduck.calladapter.networkresult.onFailure
|
||||
import at.connyduck.calladapter.networkresult.onSuccess
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.CryptoUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
private const val TAG = "PushNotificationHelper"
|
||||
|
||||
private suspend fun enableUnifiedPushNotificationsForAccount(
|
||||
context: Context,
|
||||
api: MastodonApi,
|
||||
accountManager: AccountManager,
|
||||
account: AccountEntity
|
||||
) {
|
||||
if (isUnifiedPushNotificationEnabledForAccount(account)) {
|
||||
// Already registered, update the subscription to match notification settings
|
||||
updateUnifiedPushSubscription(context, api, accountManager, account)
|
||||
} else {
|
||||
UnifiedPush.registerAppWithDialog(
|
||||
context,
|
||||
account.id.toString(),
|
||||
features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableUnifiedPushNotificationsForAccount(context: Context, account: AccountEntity) {
|
||||
if (!isUnifiedPushNotificationEnabledForAccount(account)) {
|
||||
// Not registered
|
||||
return
|
||||
}
|
||||
|
||||
UnifiedPush.unregisterApp(context, account.id.toString())
|
||||
}
|
||||
|
||||
fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean =
|
||||
account.unifiedPushUrl.isNotEmpty()
|
||||
|
||||
fun isUnifiedPushAvailable(context: Context): Boolean =
|
||||
UnifiedPush.getDistributors(context).isNotEmpty()
|
||||
|
||||
suspend fun enablePushNotificationsWithFallback(
|
||||
context: Context,
|
||||
api: MastodonApi,
|
||||
accountManager: AccountManager
|
||||
) {
|
||||
if (!isUnifiedPushAvailable(context)) {
|
||||
// No UP distributors
|
||||
NotificationHelper.enablePullNotifications(context)
|
||||
return
|
||||
}
|
||||
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
accountManager.accounts.forEach {
|
||||
val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 ||
|
||||
nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false
|
||||
val shouldEnable = it.notificationsEnabled && notificationGroupEnabled
|
||||
|
||||
if (shouldEnable) {
|
||||
enableUnifiedPushNotificationsForAccount(context, api, accountManager, it)
|
||||
} else {
|
||||
disableUnifiedPushNotificationsForAccount(context, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun disablePushNotifications(context: Context, accountManager: AccountManager) {
|
||||
accountManager.accounts.forEach {
|
||||
disableUnifiedPushNotificationsForAccount(context, it)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableAllNotifications(context: Context, accountManager: AccountManager) {
|
||||
disablePushNotifications(context, accountManager)
|
||||
NotificationHelper.disablePullNotifications(context)
|
||||
}
|
||||
|
||||
private fun buildSubscriptionData(context: Context, account: AccountEntity): Map<String, Boolean> =
|
||||
buildMap {
|
||||
val notificationManager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
Notification.Type.visibleTypes.forEach {
|
||||
put(
|
||||
"data[alerts][${it.presentation}]",
|
||||
NotificationHelper.filterNotification(notificationManager, account, it)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Called by UnifiedPush callback
|
||||
suspend fun registerUnifiedPushEndpoint(
|
||||
context: Context,
|
||||
api: MastodonApi,
|
||||
accountManager: AccountManager,
|
||||
account: AccountEntity,
|
||||
endpoint: String
|
||||
) = withContext(Dispatchers.IO) {
|
||||
// Generate a prime256v1 key pair for WebPush
|
||||
// Decryption is unimplemented for now, since Mastodon uses an old WebPush
|
||||
// standard which does not send needed information for decryption in the payload
|
||||
// This makes it not directly compatible with UnifiedPush
|
||||
// As of now, we use it purely as a way to trigger a pull
|
||||
val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1)
|
||||
val auth = CryptoUtil.secureRandomBytesEncoded(16)
|
||||
|
||||
api.subscribePushNotifications(
|
||||
"Bearer ${account.accessToken}",
|
||||
account.domain,
|
||||
endpoint,
|
||||
keyPair.pubkey,
|
||||
auth,
|
||||
buildSubscriptionData(context, account)
|
||||
).onFailure { throwable ->
|
||||
Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable)
|
||||
disableUnifiedPushNotificationsForAccount(context, account)
|
||||
}.onSuccess {
|
||||
Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}")
|
||||
|
||||
accountManager.updateAccount(account) {
|
||||
copy(
|
||||
pushPubKey = keyPair.pubkey,
|
||||
pushPrivKey = keyPair.privKey,
|
||||
pushAuth = auth,
|
||||
pushServerKey = it.serverKey,
|
||||
unifiedPushUrl = endpoint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronize the enabled / disabled state of notifications with server-side subscription
|
||||
suspend fun updateUnifiedPushSubscription(
|
||||
context: Context,
|
||||
api: MastodonApi,
|
||||
accountManager: AccountManager,
|
||||
account: AccountEntity
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.updatePushNotificationSubscription(
|
||||
"Bearer ${account.accessToken}",
|
||||
account.domain,
|
||||
buildSubscriptionData(context, account)
|
||||
).onSuccess {
|
||||
Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}")
|
||||
accountManager.updateAccount(account) {
|
||||
copy(pushServerKey = it.serverKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unregisterUnifiedPushEndpoint(
|
||||
api: MastodonApi,
|
||||
accountManager: AccountManager,
|
||||
account: AccountEntity
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain)
|
||||
.onFailure { throwable ->
|
||||
Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable)
|
||||
}
|
||||
.onSuccess {
|
||||
Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)
|
||||
// Clear the URL in database
|
||||
accountManager.updateAccount(account) {
|
||||
copy(
|
||||
pushPubKey = "",
|
||||
pushPrivKey = "",
|
||||
pushAuth = "",
|
||||
pushServerKey = "",
|
||||
unifiedPushUrl = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
/* Copyright 2025 Tusky contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.di
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NotificationManagerModule {
|
||||
@Provides
|
||||
fun providesNotificationManager(@ApplicationContext appContext: Context): NotificationManager {
|
||||
return appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2018 Jeremiasz Nelz <remi6397(a)gmail.com>
|
||||
/* Copyright 2018 Tusky contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
|
@ -24,7 +24,7 @@ import androidx.core.app.NotificationCompat
|
|||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.service.SendStatusService
|
||||
|
|
@ -34,8 +34,6 @@ import com.keylesspalace.tusky.util.randomAlphanumericString
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val TAG = "SendStatusBR"
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
||||
|
||||
|
|
@ -44,20 +42,20 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
|||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == NotificationHelper.REPLY_ACTION) {
|
||||
val serverNotificationId = intent.getStringExtra(NotificationHelper.KEY_SERVER_NOTIFICATION_ID)
|
||||
val senderId = intent.getLongExtra(NotificationHelper.KEY_SENDER_ACCOUNT_ID, -1)
|
||||
if (intent.action == NotificationService.REPLY_ACTION) {
|
||||
val serverNotificationId = intent.getStringExtra(NotificationService.KEY_SERVER_NOTIFICATION_ID)
|
||||
val senderId = intent.getLongExtra(NotificationService.KEY_SENDER_ACCOUNT_ID, -1)
|
||||
val senderIdentifier = intent.getStringExtra(
|
||||
NotificationHelper.KEY_SENDER_ACCOUNT_IDENTIFIER
|
||||
NotificationService.KEY_SENDER_ACCOUNT_IDENTIFIER
|
||||
)
|
||||
val senderFullName = intent.getStringExtra(
|
||||
NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME
|
||||
NotificationService.KEY_SENDER_ACCOUNT_FULL_NAME
|
||||
)
|
||||
val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID)
|
||||
val citedStatusId = intent.getStringExtra(NotificationService.KEY_CITED_STATUS_ID)
|
||||
val visibility =
|
||||
intent.getSerializableExtraCompat<Status.Visibility>(NotificationHelper.KEY_VISIBILITY)!!
|
||||
val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER).orEmpty()
|
||||
val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS).orEmpty()
|
||||
intent.getSerializableExtraCompat<Status.Visibility>(NotificationService.KEY_VISIBILITY)!!
|
||||
val spoiler = intent.getStringExtra(NotificationService.KEY_SPOILER).orEmpty()
|
||||
val mentions = intent.getStringArrayExtra(NotificationService.KEY_MENTIONS).orEmpty()
|
||||
|
||||
val account = accountManager.getAccountById(senderId)
|
||||
|
||||
|
|
@ -70,7 +68,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
|||
|
||||
val notification = NotificationCompat.Builder(
|
||||
context,
|
||||
NotificationHelper.CHANNEL_MENTION + senderIdentifier
|
||||
NotificationService.CHANNEL_MENTION + senderIdentifier
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setColor(context.getColor(R.color.tusky_blue))
|
||||
|
|
@ -115,7 +113,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
|||
// Notifications with remote input active can't be cancelled, so let's replace it with another one that will dismiss automatically
|
||||
val notification = NotificationCompat.Builder(
|
||||
context,
|
||||
NotificationHelper.CHANNEL_MENTION + senderIdentifier
|
||||
NotificationService.CHANNEL_MENTION + senderIdentifier
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setColor(context.getColor(R.color.notification_color))
|
||||
|
|
@ -138,6 +136,10 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
|||
private fun getReplyMessage(intent: Intent): CharSequence {
|
||||
val remoteInput = RemoteInput.getResultsFromIntent(intent)
|
||||
|
||||
return remoteInput?.getCharSequence(NotificationHelper.KEY_REPLY, "") ?: ""
|
||||
return remoteInput?.getCharSequence(NotificationService.KEY_REPLY, "") ?: ""
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "SendStatusBroadcastReceiver"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue