NotificationHelper.java 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880
  1. /* Copyright 2018 Jeremiasz Nelz <remi6397(a)gmail.com>
  2. * Copyright 2017 Andrew Dawson
  3. *
  4. * This file is a part of Tusky.
  5. *
  6. * This program is free software; you can redistribute it and/or modify it under the terms of the
  7. * GNU General Public License as published by the Free Software Foundation; either version 3 of the
  8. * License, or (at your option) any later version.
  9. *
  10. * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
  11. * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
  12. * Public License for more details.
  13. *
  14. * You should have received a copy of the GNU General Public License along with Tusky; if not,
  15. * see <http://www.gnu.org/licenses>. */
  16. package com.keylesspalace.tusky.components.notifications;
  17. import static com.keylesspalace.tusky.BuildConfig.APPLICATION_ID;
  18. import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
  19. import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
  20. import android.app.NotificationChannel;
  21. import android.app.NotificationChannelGroup;
  22. import android.app.NotificationManager;
  23. import android.app.PendingIntent;
  24. import android.content.Context;
  25. import android.content.Intent;
  26. import android.graphics.Bitmap;
  27. import android.graphics.BitmapFactory;
  28. import android.os.Build;
  29. import android.os.Bundle;
  30. import android.provider.Settings;
  31. import android.service.notification.StatusBarNotification;
  32. import android.text.TextUtils;
  33. import android.util.Log;
  34. import androidx.annotation.NonNull;
  35. import androidx.annotation.Nullable;
  36. import androidx.annotation.StringRes;
  37. import androidx.core.app.NotificationCompat;
  38. import androidx.core.app.RemoteInput;
  39. import androidx.core.app.TaskStackBuilder;
  40. import androidx.work.Constraints;
  41. import androidx.work.NetworkType;
  42. import androidx.work.OneTimeWorkRequest;
  43. import androidx.work.OutOfQuotaPolicy;
  44. import androidx.work.PeriodicWorkRequest;
  45. import androidx.work.WorkManager;
  46. import androidx.work.WorkRequest;
  47. import com.bumptech.glide.Glide;
  48. import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
  49. import com.bumptech.glide.request.FutureTarget;
  50. import com.keylesspalace.tusky.MainActivity;
  51. import com.keylesspalace.tusky.R;
  52. import com.keylesspalace.tusky.components.compose.ComposeActivity;
  53. import com.keylesspalace.tusky.db.AccountEntity;
  54. import com.keylesspalace.tusky.db.AccountManager;
  55. import com.keylesspalace.tusky.entity.Notification;
  56. import com.keylesspalace.tusky.entity.Poll;
  57. import com.keylesspalace.tusky.entity.PollOption;
  58. import com.keylesspalace.tusky.entity.Status;
  59. import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
  60. import com.keylesspalace.tusky.util.StringUtils;
  61. import com.keylesspalace.tusky.viewdata.PollViewDataKt;
  62. import com.keylesspalace.tusky.worker.NotificationWorker;
  63. import java.util.ArrayList;
  64. import java.util.Collections;
  65. import java.util.HashMap;
  66. import java.util.LinkedHashSet;
  67. import java.util.List;
  68. import java.util.Map;
  69. import java.util.Set;
  70. import java.util.concurrent.ExecutionException;
  71. import java.util.concurrent.TimeUnit;
  72. public class NotificationHelper {
  73. /** ID of notification shown when fetching notifications */
  74. public static final int NOTIFICATION_ID_FETCH_NOTIFICATION = 0;
  75. /** ID of notification shown when pruning the cache */
  76. public static final int NOTIFICATION_ID_PRUNE_CACHE = 1;
  77. /** Dynamic notification IDs start here */
  78. private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1;
  79. /**
  80. * constants used in Intents
  81. */
  82. public static final String ACCOUNT_ID = "account_id";
  83. public static final String TYPE = APPLICATION_ID + ".notification.type";
  84. private static final String TAG = "NotificationHelper";
  85. public static final String REPLY_ACTION = "REPLY_ACTION";
  86. public static final String KEY_REPLY = "KEY_REPLY";
  87. public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID";
  88. public static final String KEY_SENDER_ACCOUNT_IDENTIFIER = "KEY_SENDER_ACCOUNT_IDENTIFIER";
  89. public static final String KEY_SENDER_ACCOUNT_FULL_NAME = "KEY_SENDER_ACCOUNT_FULL_NAME";
  90. public static final String KEY_NOTIFICATION_ID = "KEY_NOTIFICATION_ID";
  91. public static final String KEY_CITED_STATUS_ID = "KEY_CITED_STATUS_ID";
  92. public static final String KEY_VISIBILITY = "KEY_VISIBILITY";
  93. public static final String KEY_SPOILER = "KEY_SPOILER";
  94. public static final String KEY_MENTIONS = "KEY_MENTIONS";
  95. /**
  96. * notification channels used on Android O+
  97. **/
  98. public static final String CHANNEL_MENTION = "CHANNEL_MENTION";
  99. public static final String CHANNEL_FOLLOW = "CHANNEL_FOLLOW";
  100. public static final String CHANNEL_FOLLOW_REQUEST = "CHANNEL_FOLLOW_REQUEST";
  101. public static final String CHANNEL_BOOST = "CHANNEL_BOOST";
  102. public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE";
  103. public static final String CHANNEL_POLL = "CHANNEL_POLL";
  104. public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS";
  105. public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
  106. public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
  107. public static final String CHANNEL_REPORT = "CHANNEL_REPORT";
  108. public static final String CHANNEL_BACKGROUND_TASKS = "CHANNEL_BACKGROUND_TASKS";
  109. /**
  110. * WorkManager Tag
  111. */
  112. private static final String NOTIFICATION_PULL_TAG = "pullNotifications";
  113. /** Tag for the summary notification */
  114. private static final String GROUP_SUMMARY_TAG = APPLICATION_ID + ".notification.group_summary";
  115. /** The name of the account that caused the notification, for use in a summary */
  116. private static final String EXTRA_ACCOUNT_NAME = APPLICATION_ID + ".notification.extra.account_name";
  117. /** The notification's type (string representation of a Notification.Type) */
  118. private static final String EXTRA_NOTIFICATION_TYPE = APPLICATION_ID + ".notification.extra.notification_type";
  119. /**
  120. * Takes a given Mastodon notification and creates a new Android notification or updates the
  121. * existing Android notification.
  122. * <p>
  123. * The Android notification has it's tag set to the Mastodon notification ID, and it's ID set
  124. * to the ID of the account that received the notification.
  125. *
  126. * @param context to access application preferences and services
  127. * @param body a new Mastodon notification
  128. * @param account the account for which the notification should be shown
  129. * @return the new notification
  130. */
  131. @NonNull
  132. public static android.app.Notification make(final Context context, NotificationManager notificationManager, Notification body, AccountEntity account, boolean isFirstOfBatch) {
  133. body = body.rewriteToStatusTypeIfNeeded(account.getAccountId());
  134. String mastodonNotificationId = body.getId();
  135. int accountId = (int) account.getId();
  136. // Check for an existing notification with this Mastodon Notification ID
  137. android.app.Notification existingAndroidNotification = null;
  138. StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications();
  139. for (StatusBarNotification androidNotification : activeNotifications) {
  140. if (mastodonNotificationId.equals(androidNotification.getTag()) && accountId == androidNotification.getId()) {
  141. existingAndroidNotification = androidNotification.getNotification();
  142. }
  143. }
  144. // Notification group member
  145. // =========================
  146. notificationId++;
  147. // Create the notification -- either create a new one, or use the existing one.
  148. NotificationCompat.Builder builder;
  149. if (existingAndroidNotification == null) {
  150. builder = newAndroidNotification(context, body, account);
  151. } else {
  152. builder = new NotificationCompat.Builder(context, existingAndroidNotification);
  153. }
  154. builder.setContentTitle(titleForType(context, body, account))
  155. .setContentText(bodyForType(body, context, account.getAlwaysOpenSpoiler()));
  156. if (body.getType() == Notification.Type.MENTION || body.getType() == Notification.Type.POLL) {
  157. builder.setStyle(new NotificationCompat.BigTextStyle()
  158. .bigText(bodyForType(body, context, account.getAlwaysOpenSpoiler())));
  159. }
  160. //load the avatar synchronously
  161. Bitmap accountAvatar;
  162. try {
  163. FutureTarget<Bitmap> target = Glide.with(context)
  164. .asBitmap()
  165. .load(body.getAccount().getAvatar())
  166. .transform(new RoundedCorners(20))
  167. .submit();
  168. accountAvatar = target.get();
  169. } catch (ExecutionException | InterruptedException e) {
  170. Log.d(TAG, "error loading account avatar", e);
  171. accountAvatar = BitmapFactory.decodeResource(context.getResources(), R.drawable.avatar_default);
  172. }
  173. builder.setLargeIcon(accountAvatar);
  174. // Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat
  175. if (body.getType() == Notification.Type.MENTION
  176. && android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
  177. RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY)
  178. .setLabel(context.getString(R.string.label_quick_reply))
  179. .build();
  180. PendingIntent quickReplyPendingIntent = getStatusReplyIntent(context, body, account);
  181. NotificationCompat.Action quickReplyAction =
  182. new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
  183. context.getString(R.string.action_quick_reply),
  184. quickReplyPendingIntent)
  185. .addRemoteInput(replyRemoteInput)
  186. .build();
  187. builder.addAction(quickReplyAction);
  188. PendingIntent composeIntent = getStatusComposeIntent(context, body, account);
  189. NotificationCompat.Action composeAction =
  190. new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
  191. context.getString(R.string.action_compose_shortcut),
  192. composeIntent)
  193. .setShowsUserInterface(true)
  194. .build();
  195. builder.addAction(composeAction);
  196. }
  197. builder.setSubText(account.getFullName());
  198. builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
  199. builder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
  200. builder.setOnlyAlertOnce(true);
  201. Bundle extras = new Bundle();
  202. // Add the sending account's name, so it can be used when summarising this notification
  203. extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName());
  204. extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().toString());
  205. builder.addExtras(extras);
  206. // Only alert for the first notification of a batch to avoid multiple alerts at once
  207. if(!isFirstOfBatch) {
  208. builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY);
  209. }
  210. return builder.build();
  211. }
  212. /**
  213. * Updates the summary notifications for each notification group.
  214. * <p>
  215. * Notifications are sent to channels. Within each channel they may be grouped, and the group
  216. * may have a summary.
  217. * <p>
  218. * Tusky uses N notification channels for each account, each channel corresponds to a type
  219. * of notification (follow, reblog, mention, etc). Therefore each channel also has exactly
  220. * 0 or 1 summary notifications along with its regular notifications.
  221. * <p>
  222. * The group key is the same as the channel ID.
  223. * <p>
  224. * Regnerates the summary notifications for all active Tusky notifications for `account`.
  225. * This may delete the summary notification if there are no active notifications for that
  226. * account in a group.
  227. *
  228. * @see <a href="https://developer.android.com/develop/ui/views/notifications/group">Create a
  229. * notification group</a>
  230. * @param context to access application preferences and services
  231. * @param notificationManager the system's NotificationManager
  232. * @param account the account for which the notification should be shown
  233. */
  234. public static void updateSummaryNotifications(Context context, NotificationManager notificationManager, AccountEntity account) {
  235. // Map from the channel ID to a list of notifications in that channel. Those are the
  236. // notifications that will be summarised.
  237. Map<String, List<StatusBarNotification>> channelGroups = new HashMap<>();
  238. int accountId = (int) account.getId();
  239. // Initialise the map with all channel IDs.
  240. for (Notification.Type ty : Notification.Type.values()) {
  241. channelGroups.put(getChannelId(account, ty), new ArrayList<>());
  242. }
  243. // Fetch all existing notifications. Add them to the map, ignoring notifications that:
  244. // - belong to a different account
  245. // - are summary notifications
  246. for (StatusBarNotification sn : notificationManager.getActiveNotifications()) {
  247. if (sn.getId() != accountId) continue;
  248. String channelId = sn.getNotification().getGroup();
  249. String summaryTag = GROUP_SUMMARY_TAG + "." + channelId;
  250. if (summaryTag.equals(sn.getTag())) continue;
  251. // TODO: API 26 supports getting the channel ID directly (sn.getNotification().getChannelId()).
  252. // This works here because the channelId and the groupKey are the same.
  253. List<StatusBarNotification> members = channelGroups.get(channelId);
  254. if (members == null) { // can't happen, but just in case...
  255. Log.e(TAG, "members == null for channel ID " + channelId);
  256. continue;
  257. }
  258. members.add(sn);
  259. }
  260. // Create, update, or cancel the summary notifications for each group.
  261. for (Map.Entry<String, List<StatusBarNotification>> channelGroup : channelGroups.entrySet()) {
  262. String channelId = channelGroup.getKey();
  263. List<StatusBarNotification> members = channelGroup.getValue();
  264. String summaryTag = GROUP_SUMMARY_TAG + "." + channelId;
  265. // If there are 0-1 notifications in this group then the additional summary
  266. // notification is not needed and can be cancelled.
  267. if (members.size() <= 1) {
  268. notificationManager.cancel(summaryTag, accountId);
  269. continue;
  270. }
  271. // Create a notification that summarises the other notifications in this group
  272. // All notifications in this group have the same type, so get it from the first.
  273. String notificationType = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE);
  274. Intent summaryResultIntent = new Intent(context, MainActivity.class);
  275. summaryResultIntent.putExtra(ACCOUNT_ID, (long) accountId);
  276. summaryResultIntent.putExtra(TYPE, notificationType);
  277. TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
  278. summaryStackBuilder.addParentStack(MainActivity.class);
  279. summaryStackBuilder.addNextIntent(summaryResultIntent);
  280. PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
  281. pendingIntentFlags(false));
  282. String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, members.size(), members.size());
  283. String text = joinNames(context, members);
  284. NotificationCompat.Builder summaryBuilder = new NotificationCompat.Builder(context, channelId)
  285. .setSmallIcon(R.drawable.ic_notify)
  286. .setContentIntent(summaryResultPendingIntent)
  287. .setColor(context.getColor(R.color.notification_color))
  288. .setAutoCancel(true)
  289. .setShortcutId(Long.toString(account.getId()))
  290. .setDefaults(0) // So it doesn't ring twice, notify only in Target callback
  291. .setContentTitle(title)
  292. .setContentText(text)
  293. .setSubText(account.getFullName())
  294. .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
  295. .setCategory(NotificationCompat.CATEGORY_SOCIAL)
  296. .setOnlyAlertOnce(true)
  297. .setGroup(channelId)
  298. .setGroupSummary(true);
  299. setSoundVibrationLight(account, summaryBuilder);
  300. // TODO: Use the batch notification API available in NotificationManagerCompat
  301. // 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01)
  302. // when it is released.
  303. notificationManager.notify(summaryTag, accountId, summaryBuilder.build());
  304. // Android will rate limit / drop notifications if they're posted too
  305. // quickly. There is no indication to the user that this happened.
  306. // See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664
  307. try { Thread.sleep(1000); } catch (InterruptedException ignored) { }
  308. }
  309. }
  310. private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) {
  311. // we have to switch account here
  312. Intent eventResultIntent = new Intent(context, MainActivity.class);
  313. eventResultIntent.putExtra(ACCOUNT_ID, account.getId());
  314. eventResultIntent.putExtra(TYPE, body.getType().name());
  315. TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context);
  316. eventStackBuilder.addParentStack(MainActivity.class);
  317. eventStackBuilder.addNextIntent(eventResultIntent);
  318. PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(),
  319. pendingIntentFlags(false));
  320. String channelId = getChannelId(account, body);
  321. assert channelId != null;
  322. NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
  323. .setSmallIcon(R.drawable.ic_notify)
  324. .setContentIntent(eventResultPendingIntent)
  325. .setColor(context.getColor(R.color.notification_color))
  326. .setGroup(channelId)
  327. .setAutoCancel(true)
  328. .setShortcutId(Long.toString(account.getId()))
  329. .setDefaults(0); // So it doesn't ring twice, notify only in Target callback
  330. setSoundVibrationLight(account, builder);
  331. return builder;
  332. }
  333. private static PendingIntent getStatusReplyIntent(Context context, Notification body, AccountEntity account) {
  334. Status status = body.getStatus();
  335. String inReplyToId = status.getId();
  336. Status actionableStatus = status.getActionableStatus();
  337. Status.Visibility replyVisibility = actionableStatus.getVisibility();
  338. String contentWarning = actionableStatus.getSpoilerText();
  339. List<Status.Mention> mentions = actionableStatus.getMentions();
  340. List<String> mentionedUsernames = new ArrayList<>();
  341. mentionedUsernames.add(actionableStatus.getAccount().getUsername());
  342. for (Status.Mention mention : mentions) {
  343. mentionedUsernames.add(mention.getUsername());
  344. }
  345. mentionedUsernames.removeAll(Collections.singleton(account.getUsername()));
  346. mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames));
  347. Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class)
  348. .setAction(REPLY_ACTION)
  349. .putExtra(KEY_SENDER_ACCOUNT_ID, account.getId())
  350. .putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier())
  351. .putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName())
  352. .putExtra(KEY_NOTIFICATION_ID, notificationId)
  353. .putExtra(KEY_CITED_STATUS_ID, inReplyToId)
  354. .putExtra(KEY_VISIBILITY, replyVisibility)
  355. .putExtra(KEY_SPOILER, contentWarning)
  356. .putExtra(KEY_MENTIONS, mentionedUsernames.toArray(new String[0]));
  357. return PendingIntent.getBroadcast(context.getApplicationContext(),
  358. notificationId,
  359. replyIntent,
  360. pendingIntentFlags(true));
  361. }
  362. private static PendingIntent getStatusComposeIntent(Context context, Notification body, AccountEntity account) {
  363. Status status = body.getStatus();
  364. String citedLocalAuthor = status.getAccount().getLocalUsername();
  365. String citedText = parseAsMastodonHtml(status.getContent()).toString();
  366. String inReplyToId = status.getId();
  367. Status actionableStatus = status.getActionableStatus();
  368. Status.Visibility replyVisibility = actionableStatus.getVisibility();
  369. String contentWarning = actionableStatus.getSpoilerText();
  370. List<Status.Mention> mentions = actionableStatus.getMentions();
  371. Set<String> mentionedUsernames = new LinkedHashSet<>();
  372. mentionedUsernames.add(actionableStatus.getAccount().getUsername());
  373. for (Status.Mention mention : mentions) {
  374. String mentionedUsername = mention.getUsername();
  375. if (!mentionedUsername.equals(account.getUsername())) {
  376. mentionedUsernames.add(mention.getUsername());
  377. }
  378. }
  379. ComposeActivity.ComposeOptions composeOptions = new ComposeActivity.ComposeOptions();
  380. composeOptions.setInReplyToId(inReplyToId);
  381. composeOptions.setReplyVisibility(replyVisibility);
  382. composeOptions.setContentWarning(contentWarning);
  383. composeOptions.setReplyingStatusAuthor(citedLocalAuthor);
  384. composeOptions.setReplyingStatusContent(citedText);
  385. composeOptions.setMentionedUsernames(mentionedUsernames);
  386. composeOptions.setModifiedInitialState(true);
  387. composeOptions.setLanguage(actionableStatus.getLanguage());
  388. composeOptions.setKind(ComposeActivity.ComposeKind.NEW);
  389. Intent composeIntent = ComposeActivity.startIntent(
  390. context,
  391. composeOptions,
  392. notificationId,
  393. account.getId()
  394. );
  395. composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
  396. return PendingIntent.getActivity(context.getApplicationContext(),
  397. notificationId,
  398. composeIntent,
  399. pendingIntentFlags(false));
  400. }
  401. /**
  402. * Creates a notification channel for notifications for background work that should not
  403. * disturb the user.
  404. *
  405. * @param context context
  406. */
  407. public static void createWorkerNotificationChannel(@NonNull Context context) {
  408. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
  409. NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
  410. NotificationChannel channel = new NotificationChannel(
  411. CHANNEL_BACKGROUND_TASKS,
  412. context.getString(R.string.notification_listenable_worker_name),
  413. NotificationManager.IMPORTANCE_NONE
  414. );
  415. channel.setDescription(context.getString(R.string.notification_listenable_worker_description));
  416. channel.enableLights(false);
  417. channel.enableVibration(false);
  418. channel.setShowBadge(false);
  419. notificationManager.createNotificationChannel(channel);
  420. }
  421. /**
  422. * Creates a notification for a background worker.
  423. *
  424. * @param context context
  425. * @param titleResource String resource to use as the notification's title
  426. * @return the notification
  427. */
  428. @NonNull
  429. public static android.app.Notification createWorkerNotification(@NonNull Context context, @StringRes int titleResource) {
  430. String title = context.getString(titleResource);
  431. return new NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS)
  432. .setContentTitle(title)
  433. .setTicker(title)
  434. .setSmallIcon(R.drawable.ic_notify)
  435. .setOngoing(true)
  436. .build();
  437. }
  438. public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
  439. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
  440. NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
  441. String[] channelIds = new String[]{
  442. CHANNEL_MENTION + account.getIdentifier(),
  443. CHANNEL_FOLLOW + account.getIdentifier(),
  444. CHANNEL_FOLLOW_REQUEST + account.getIdentifier(),
  445. CHANNEL_BOOST + account.getIdentifier(),
  446. CHANNEL_FAVOURITE + account.getIdentifier(),
  447. CHANNEL_POLL + account.getIdentifier(),
  448. CHANNEL_SUBSCRIPTIONS + account.getIdentifier(),
  449. CHANNEL_SIGN_UP + account.getIdentifier(),
  450. CHANNEL_UPDATES + account.getIdentifier(),
  451. CHANNEL_REPORT + account.getIdentifier(),
  452. };
  453. int[] channelNames = {
  454. R.string.notification_mention_name,
  455. R.string.notification_follow_name,
  456. R.string.notification_follow_request_name,
  457. R.string.notification_boost_name,
  458. R.string.notification_favourite_name,
  459. R.string.notification_poll_name,
  460. R.string.notification_subscription_name,
  461. R.string.notification_sign_up_name,
  462. R.string.notification_update_name,
  463. R.string.notification_report_name,
  464. };
  465. int[] channelDescriptions = {
  466. R.string.notification_mention_descriptions,
  467. R.string.notification_follow_description,
  468. R.string.notification_follow_request_description,
  469. R.string.notification_boost_description,
  470. R.string.notification_favourite_description,
  471. R.string.notification_poll_description,
  472. R.string.notification_subscription_description,
  473. R.string.notification_sign_up_description,
  474. R.string.notification_update_description,
  475. R.string.notification_report_description,
  476. };
  477. List<NotificationChannel> channels = new ArrayList<>(6);
  478. NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName());
  479. notificationManager.createNotificationChannelGroup(channelGroup);
  480. for (int i = 0; i < channelIds.length; i++) {
  481. String id = channelIds[i];
  482. String name = context.getString(channelNames[i]);
  483. String description = context.getString(channelDescriptions[i]);
  484. int importance = NotificationManager.IMPORTANCE_DEFAULT;
  485. NotificationChannel channel = new NotificationChannel(id, name, importance);
  486. channel.setDescription(description);
  487. channel.enableLights(true);
  488. channel.setLightColor(0xFF2B90D9);
  489. channel.enableVibration(true);
  490. channel.setShowBadge(true);
  491. channel.setGroup(account.getIdentifier());
  492. channels.add(channel);
  493. }
  494. notificationManager.createNotificationChannels(channels);
  495. }
  496. }
  497. public static void deleteNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
  498. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
  499. NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
  500. notificationManager.deleteNotificationChannelGroup(account.getIdentifier());
  501. }
  502. }
  503. public static boolean areNotificationsEnabled(@NonNull Context context, @NonNull AccountManager accountManager) {
  504. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
  505. // on Android >= O, notifications are enabled, if at least one channel is enabled
  506. NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
  507. if (notificationManager.areNotificationsEnabled()) {
  508. for (NotificationChannel channel : notificationManager.getNotificationChannels()) {
  509. if (channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE) {
  510. Log.d(TAG, "NotificationsEnabled");
  511. return true;
  512. }
  513. }
  514. }
  515. Log.d(TAG, "NotificationsDisabled");
  516. return false;
  517. } else {
  518. // on Android < O, notifications are enabled, if at least one account has notification enabled
  519. return accountManager.areNotificationsEnabled();
  520. }
  521. }
  522. public static void enablePullNotifications(Context context) {
  523. WorkManager workManager = WorkManager.getInstance(context);
  524. workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
  525. // Periodic work requests are supposed to start running soon after being enqueued. In
  526. // practice that may not be soon enough, so create and enqueue an expedited one-time
  527. // request to get new notifications immediately.
  528. WorkRequest fetchNotifications = new OneTimeWorkRequest.Builder(NotificationWorker.class)
  529. .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
  530. .setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
  531. .build();
  532. workManager.enqueue(fetchNotifications);
  533. WorkRequest workRequest = new PeriodicWorkRequest.Builder(
  534. NotificationWorker.class,
  535. PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS,
  536. PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS, TimeUnit.MILLISECONDS
  537. )
  538. .addTag(NOTIFICATION_PULL_TAG)
  539. .setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
  540. .setInitialDelay(5, TimeUnit.MINUTES)
  541. .build();
  542. workManager.enqueue(workRequest);
  543. Log.d(TAG, "enabled notification checks with "+ PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval");
  544. }
  545. public static void disablePullNotifications(Context context) {
  546. WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
  547. Log.d(TAG, "disabled notification checks");
  548. }
  549. public static void clearNotificationsForAccount(@NonNull Context context, @NonNull AccountEntity account) {
  550. int accountId = (int) account.getId();
  551. NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
  552. for (StatusBarNotification androidNotification : notificationManager.getActiveNotifications()) {
  553. if (accountId == androidNotification.getId()) {
  554. notificationManager.cancel(androidNotification.getTag(), androidNotification.getId());
  555. }
  556. }
  557. }
  558. public static boolean filterNotification(NotificationManager notificationManager, AccountEntity account, @NonNull Notification notification) {
  559. return filterNotification(notificationManager, account, notification.getType());
  560. }
  561. public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type) {
  562. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
  563. String channelId = getChannelId(account, type);
  564. if(channelId == null) {
  565. // unknown notificationtype
  566. return false;
  567. }
  568. NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
  569. return channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
  570. }
  571. switch (type) {
  572. case MENTION:
  573. return account.getNotificationsMentioned();
  574. case STATUS:
  575. return account.getNotificationsSubscriptions();
  576. case FOLLOW:
  577. return account.getNotificationsFollowed();
  578. case FOLLOW_REQUEST:
  579. return account.getNotificationsFollowRequested();
  580. case REBLOG:
  581. return account.getNotificationsReblogged();
  582. case FAVOURITE:
  583. return account.getNotificationsFavorited();
  584. case POLL:
  585. return account.getNotificationsPolls();
  586. case SIGN_UP:
  587. return account.getNotificationsSignUps();
  588. case UPDATE:
  589. return account.getNotificationsUpdates();
  590. case REPORT:
  591. return account.getNotificationsReports();
  592. default:
  593. return false;
  594. }
  595. }
  596. @Nullable
  597. private static String getChannelId(AccountEntity account, Notification notification) {
  598. return getChannelId(account, notification.getType());
  599. }
  600. @Nullable
  601. private static String getChannelId(AccountEntity account, Notification.Type type) {
  602. switch (type) {
  603. case MENTION:
  604. return CHANNEL_MENTION + account.getIdentifier();
  605. case STATUS:
  606. return CHANNEL_SUBSCRIPTIONS + account.getIdentifier();
  607. case FOLLOW:
  608. return CHANNEL_FOLLOW + account.getIdentifier();
  609. case FOLLOW_REQUEST:
  610. return CHANNEL_FOLLOW_REQUEST + account.getIdentifier();
  611. case REBLOG:
  612. return CHANNEL_BOOST + account.getIdentifier();
  613. case FAVOURITE:
  614. return CHANNEL_FAVOURITE + account.getIdentifier();
  615. case POLL:
  616. return CHANNEL_POLL + account.getIdentifier();
  617. case SIGN_UP:
  618. return CHANNEL_SIGN_UP + account.getIdentifier();
  619. case UPDATE:
  620. return CHANNEL_UPDATES + account.getIdentifier();
  621. case REPORT:
  622. return CHANNEL_REPORT + account.getIdentifier();
  623. default:
  624. return null;
  625. }
  626. }
  627. private static void setSoundVibrationLight(AccountEntity account, NotificationCompat.Builder builder) {
  628. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
  629. return; //do nothing on Android O or newer, the system uses the channel settings anyway
  630. }
  631. if (account.getNotificationSound()) {
  632. builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI);
  633. }
  634. if (account.getNotificationVibration()) {
  635. builder.setVibrate(new long[]{500, 500});
  636. }
  637. if (account.getNotificationLight()) {
  638. builder.setLights(0xFF2B90D9, 300, 1000);
  639. }
  640. }
  641. private static String wrapItemAt(StatusBarNotification notification) {
  642. return StringUtils.unicodeWrap(notification.getNotification().extras.getString(EXTRA_ACCOUNT_NAME));//getAccount().getName());
  643. }
  644. @Nullable
  645. private static String joinNames(Context context, List<StatusBarNotification> notifications) {
  646. if (notifications.size() > 3) {
  647. int length = notifications.size();
  648. //notifications.get(0).getNotification().extras.getString(EXTRA_ACCOUNT_NAME);
  649. return String.format(context.getString(R.string.notification_summary_large),
  650. wrapItemAt(notifications.get(length - 1)),
  651. wrapItemAt(notifications.get(length - 2)),
  652. wrapItemAt(notifications.get(length - 3)),
  653. length - 3);
  654. } else if (notifications.size() == 3) {
  655. return String.format(context.getString(R.string.notification_summary_medium),
  656. wrapItemAt(notifications.get(2)),
  657. wrapItemAt(notifications.get(1)),
  658. wrapItemAt(notifications.get(0)));
  659. } else if (notifications.size() == 2) {
  660. return String.format(context.getString(R.string.notification_summary_small),
  661. wrapItemAt(notifications.get(1)),
  662. wrapItemAt(notifications.get(0)));
  663. }
  664. return null;
  665. }
  666. @Nullable
  667. private static String titleForType(Context context, Notification notification, AccountEntity account) {
  668. String accountName = StringUtils.unicodeWrap(notification.getAccount().getName());
  669. switch (notification.getType()) {
  670. case MENTION:
  671. return String.format(context.getString(R.string.notification_mention_format),
  672. accountName);
  673. case STATUS:
  674. return String.format(context.getString(R.string.notification_subscription_format),
  675. accountName);
  676. case FOLLOW:
  677. return String.format(context.getString(R.string.notification_follow_format),
  678. accountName);
  679. case FOLLOW_REQUEST:
  680. return String.format(context.getString(R.string.notification_follow_request_format),
  681. accountName);
  682. case FAVOURITE:
  683. return String.format(context.getString(R.string.notification_favourite_format),
  684. accountName);
  685. case REBLOG:
  686. return String.format(context.getString(R.string.notification_reblog_format),
  687. accountName);
  688. case POLL:
  689. if(notification.getStatus().getAccount().getId().equals(account.getAccountId())) {
  690. return context.getString(R.string.poll_ended_created);
  691. } else {
  692. return context.getString(R.string.poll_ended_voted);
  693. }
  694. case SIGN_UP:
  695. return String.format(context.getString(R.string.notification_sign_up_format), accountName);
  696. case UPDATE:
  697. return String.format(context.getString(R.string.notification_update_format), accountName);
  698. case REPORT:
  699. return context.getString(R.string.notification_report_format, account.getDomain());
  700. }
  701. return null;
  702. }
  703. private static String bodyForType(Notification notification, Context context, Boolean alwaysOpenSpoiler) {
  704. switch (notification.getType()) {
  705. case FOLLOW:
  706. case FOLLOW_REQUEST:
  707. case SIGN_UP:
  708. return "@" + notification.getAccount().getUsername();
  709. case MENTION:
  710. case FAVOURITE:
  711. case REBLOG:
  712. case STATUS:
  713. if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) {
  714. return notification.getStatus().getSpoilerText();
  715. } else {
  716. return parseAsMastodonHtml(notification.getStatus().getContent()).toString();
  717. }
  718. case POLL:
  719. if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) {
  720. return notification.getStatus().getSpoilerText();
  721. } else {
  722. StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent()));
  723. builder.append('\n');
  724. Poll poll = notification.getStatus().getPoll();
  725. List<PollOption> options = poll.getOptions();
  726. for(int i = 0; i < options.size(); ++i) {
  727. PollOption option = options.get(i);
  728. builder.append(buildDescription(option.getTitle(),
  729. PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()),
  730. poll.getOwnVotes() != null && poll.getOwnVotes().contains(i),
  731. context));
  732. builder.append('\n');
  733. }
  734. return builder.toString();
  735. }
  736. case REPORT:
  737. return context.getString(
  738. R.string.notification_header_report_format,
  739. StringUtils.unicodeWrap(notification.getAccount().getName()),
  740. StringUtils.unicodeWrap(notification.getReport().getTargetAccount().getName())
  741. );
  742. }
  743. return null;
  744. }
  745. public static int pendingIntentFlags(boolean mutable) {
  746. if (mutable) {
  747. return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0);
  748. } else {
  749. return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
  750. }
  751. }
  752. }