/* * Copyright (C) 2015 Actor LLC. <https://actor.im> */ package im.actor.core.modules.notifications; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import im.actor.core.PlatformType; import im.actor.core.entity.ContentDescription; import im.actor.core.entity.GroupType; import im.actor.core.entity.Notification; import im.actor.core.entity.Peer; import im.actor.core.entity.PeerType; import im.actor.core.modules.ModuleContext; import im.actor.core.events.AppVisibleChanged; import im.actor.core.events.PeerChatClosed; import im.actor.core.events.PeerChatOpened; import im.actor.core.modules.notifications.entity.PendingNotification; import im.actor.core.modules.notifications.entity.PendingStorage; import im.actor.core.modules.notifications.entity.ReadState; import im.actor.core.modules.ModuleActor; import im.actor.runtime.Storage; import im.actor.runtime.eventbus.Event; import im.actor.runtime.storage.SyncKeyValue; import static im.actor.core.util.JavaUtil.last; /** * Actor that controls all notifications in application * <p> * NotificationsActor keeps all unread messages for showing last unread messages in notifications * Actor also control sound effects playing logic */ public class NotificationsActor extends ModuleActor { /** * Maximum amount of messages in notification */ private static final int MAX_NOTIFICATION_COUNT = 10; /** * KeyValue storage name for actor state */ private static final String STORAGE_NOTIFICATIONS_DEPRECATED = "notifications"; private static final String STORAGE_NOTIFICATIONS = "limited_notifications"; /** * Storage for Actor internal state */ private SyncKeyValue storage; /** * BSer object for pending notifications storage */ private PendingStorage pendingStorage; /** * in memory not limited pending storage */ private ArrayList<PendingNotification> allPendingNotifications = new ArrayList<PendingNotification>(); /** * Cached read states */ private HashMap<Peer, Long> readStates = new HashMap<>(); /** * Current visible peer */ private Peer visiblePeer; /** * Is Application visible state */ private boolean isAppVisible = false; /** * Is Notifications paused */ private boolean isNotificationsPaused = false; /** * Stored notifications during pause */ private HashMap<Peer, Boolean> notificationsDuringPause = new HashMap<>(); /** * Is current platform is mobile */ private boolean isMobilePlatform = false; /** * Constructor of Actor * * @param context Module context */ public NotificationsActor(ModuleContext context) { super(context); } @Override public void preStart() { isMobilePlatform = config().getPlatformType() == PlatformType.ANDROID || config().getPlatformType() == PlatformType.IOS; // Building storage storage = new SyncKeyValue(Storage.createKeyValue(STORAGE_NOTIFICATIONS)); // Loading pending messages pendingStorage = new PendingStorage(MAX_NOTIFICATION_COUNT); byte[] storage = this.storage.get(0); if (storage != null) { try { pendingStorage = PendingStorage.fromBytes(storage, MAX_NOTIFICATION_COUNT); } catch (IOException e) { e.printStackTrace(); } } subscribe(AppVisibleChanged.EVENT); subscribe(PeerChatOpened.EVENT); subscribe(PeerChatClosed.EVENT); } /** * Handling event about incoming notification * * @param peer peer of message * @param sender sender uid of message * @param date date of message * @param description content description of message * @param hasCurrentUserMention does message have user mention */ public void onNewMessage(Peer peer, int sender, long date, ContentDescription description, boolean hasCurrentUserMention, int messagesCount, int dialogsCount) { // Check if message already read to avoid incorrect notifications // for already read messages if (date <= getLastReadDate(peer)) { return; } // If wee need to play "out-app" notification for message boolean isEnabled = isNotificationsEnabled(peer, hasCurrentUserMention); // Save to pending storage if (isEnabled) { List<PendingNotification> pendingNotifications = getNotifications(); PendingNotification pendingNotification = new PendingNotification(peer, sender, date, description); pendingNotifications.add(pendingNotification); pendingStorage.setMessagesCount(messagesCount); pendingStorage.setDialogsCount(dialogsCount); allPendingNotifications.add(pendingNotification); saveStorage(); } // Adding to paused cache if notifications paused // This peers will be released during call onNotificationsResumed if (isNotificationsPaused) { if (!notificationsDuringPause.containsKey(peer)) { notificationsDuringPause.put(peer, hasCurrentUserMention); } else { if (hasCurrentUserMention && !notificationsDuringPause.get(peer)) { notificationsDuringPause.put(peer, true); } } return; } // Performing notification // // There are two separate modes of notifications: // First one is when app is visible, and second one when not. // // For in app notifications app need to be more careful as people already pay // full attention to app or may be particular chat. // For "in-app" notifications playing only sound effects for new messages. // // For "out-app" just playing default notifications. // // WARRING: This implementation is copied to onNotificationsResumed with small changes // to fit logic if (isAppVisible) { // If application is visible application play only sound effects and // doesn't show any "out-app" notifications if (visiblePeer != null && visiblePeer.equals(peer)) { // If message arrive to visible chat if (isMobilePlatform) { // Play sound effect on mobile playEffectIfEnabled(); } else { // Do nothing on desktop as it is very distracting // Most of the people use mobile messengers occasionally and // with disabled sounds and it doesn't make problems as much as on // non-mobile platforms } } else { if (isMobilePlatform) { // Don't play any sounds not in chat screen on mobile phone // We done this because we have unread badge in chat screen on mobile } else { // For desktop environments play sounds for all chats that // doesn't explicitly disabled if (isEnabled) { playEffectIfEnabled(); } } } } else { // If application is not visible play regular notification for enabled chats if (isEnabled) { showNotification(); } } } /** * Processing event about messages read * * @param peer peer * @param fromDate read from date */ public void onMessagesRead(Peer peer, long fromDate) { // Filtering obsolete read events if (fromDate < getLastReadDate(peer)) { return; } // Removing read messages from pending storage getNotifications().clear(); pendingStorage.setMessagesCount(0); pendingStorage.setDialogsCount(0); allPendingNotifications.clear(); saveStorage(); updateNotification(); // Setting last read date setLastReadDate(peer, fromDate); } /** * Pausing notifications */ public void onNotificationsPaused() { isNotificationsPaused = true; } /** * Resuming notifications. * Checking all pending notification peers and play notifications if it is required. * WARRING: Implementation contains modified copy of code of onNewMessage method */ public void onNotificationsResumed() { isNotificationsPaused = false; // If there are notifications during pause if (notificationsDuringPause.size() > 0) { if (isAppVisible) { if (visiblePeer != null && notificationsDuringPause.containsKey(visiblePeer)) { // If there was message from visible chat if (isMobilePlatform) { // Playing sound effects for mobile platforms playEffectIfEnabled(); } else { // Don't play sounds in chat on non-mobile platforms } } else { // If there are no messages from visible peer if (isMobilePlatform) { // Don't play sounds not from current chat on mobile platforms } else { // Find any suitable peer and if found play sound effect for (Peer p : notificationsDuringPause.keySet()) { if (isNotificationsEnabled(p, notificationsDuringPause.get(p))) { playEffectIfEnabled(); break; // Can't do return because we need to make more work later } } } } } else { // Just show out-app notification for (Peer p : notificationsDuringPause.keySet()) { if (isNotificationsEnabled(p, notificationsDuringPause.get(p))) { showNotification(); break; } } } // Clearing of notifications notificationsDuringPause.clear(); } } /** * Processing Conversation visible event * * @param peer peer */ public void onConversationVisible(Peer peer) { this.visiblePeer = peer; } /** * Processing Conversation hidden event * * @param peer peer */ public void onConversationHidden(Peer peer) { if (visiblePeer != null && visiblePeer.equals(peer)) { this.visiblePeer = null; } } /** * Processing Application visible event */ public void onAppVisible() { isAppVisible = true; // Hiding all notifications right after opening application hideNotification(); } /** * Processing Application hidden event */ public void onAppHidden() { isAppVisible = false; } /** * Performing notifications */ /** * Playing sound effects */ private void playEffectIfEnabled() { if (isEffectsEnabled()) { config().getNotificationProvider().onMessageArriveInApp(context().getMessenger()); } } /** * Updating notifications */ private void updateNotification() { performNotificationImp(true); } /** * Showing new notifications */ private void showNotification() { performNotificationImp(false); } /** * Hiding notifications */ private void hideNotification() { config().getNotificationProvider().hideAllNotifications(); } /** * Method for showing/updating notifications * * @param performUpdate is need to perform update instead of showing */ private void performNotificationImp(boolean performUpdate) { // Getting pending notifications list List<PendingNotification> destNotifications = getNotifications(); if (destNotifications.size() == 0) { hideNotification(); return; } // Converting to PendingNotifications List<Notification> res = new ArrayList<>(); for (PendingNotification p : destNotifications) { boolean isChannel = false; if (p.getPeer().getPeerType() == PeerType.GROUP) { isChannel = groups().getValue(p.getPeer().getPeerId()).getGroupType() == GroupType.CHANNEL; } res.add(new Notification(p.getPeer(), isChannel, p.getSender(), p.getContent())); } // Performing notifications if (performUpdate) { config().getNotificationProvider().onUpdateNotification(context().getMessenger(), res, pendingStorage.getMessagesCount(), pendingStorage.getDialogsCount()); } else { config().getNotificationProvider().onNotification(context().getMessenger(), res, pendingStorage.getMessagesCount(), pendingStorage.getDialogsCount()); } } /** * Storage and settings methods */ /** * Convenience method for checking if sound effects are enabled * * @return is sound effects are enabled */ private boolean isEffectsEnabled() { return context().getSettingsModule().isConversationTonesEnabled(); } /** * Testing if notifications enabled for message * * @param peer peer of message * @param hasMention does peer have mention * @return is notification enabled for peer */ private boolean isNotificationsEnabled(Peer peer, boolean hasMention) { // If notifications doesn't enabled at all if (!context().getSettingsModule().isNotificationsEnabled()) { return false; } // Notifications for groups if (peer.getPeerType() == PeerType.GROUP) { // Disable notifications for hidden groups if (getGroup(peer.getPeerId()).isHidden()) { return false; } if (context().getSettingsModule().isGroupNotificationsEnabled()) { // If there are mention in group always allow notification if (hasMention) { return true; } if (context().getSettingsModule().isNotificationsEnabled(peer)) { // If forced only mentions if (context().getSettingsModule().isGroupNotificationsOnlyMentionsEnabled()) { return false; // hasMention always false at this line } else { return true; } } else { // Notifications are not enabled in group return false; } } else { // All group notifications are disabled return false; } } else if (peer.getPeerType() == PeerType.PRIVATE) { // For private conversations only check if peer notifications enabled return context().getSettingsModule().isNotificationsEnabled(peer); } else { // Never happens throw new RuntimeException("Unknown peer type"); } } /** * Convenience method for getting all notifications * * @return all pending notifications */ private List<PendingNotification> getNotifications() { return pendingStorage.getNotifications(); } /** * Saving pending messages storage */ private void saveStorage() { this.storage.put(0, pendingStorage.toByteArray()); } /** * Getting last read sort key for peer * * @param peer peer for key * @return sort key, 0 if not available */ private long getLastReadDate(Peer peer) { if (readStates.containsKey(peer)) { return readStates.get(peer); } byte[] data = storage.get(peer.getUnuqueId()); if (data != null) { try { return ReadState.fromBytes(data).getSortDate(); } catch (IOException e) { e.printStackTrace(); } } return 0; } /** * Setting last read date for peer * * @param peer peer * @param date date */ private void setLastReadDate(Peer peer, long date) { storage.put(peer.getUnuqueId(), new ReadState(date).toByteArray()); readStates.put(peer, date); } /** * Actor stuff */ /** * Receiving messages * * @param message message */ @Override public void onReceive(Object message) { if (message instanceof NewMessage) { NewMessage newMessage = (NewMessage) message; onNewMessage(newMessage.getPeer(), newMessage.getSender(), newMessage.getSortDate(), newMessage.getContentDescription(), newMessage.getHasCurrentUserMention(), newMessage.getUnreadMessagesCount(), newMessage.getUnreadDialogsCount()); } else if (message instanceof MessagesRead) { MessagesRead read = (MessagesRead) message; onMessagesRead(read.getPeer(), read.getFromDate()); } else if (message instanceof PauseNotifications) { onNotificationsPaused(); } else if (message instanceof ResumeNotifications) { onNotificationsResumed(); } else { super.onReceive(message); } } /** * Receiving bus events * * @param event event */ @Override public void onBusEvent(Event event) { if (event instanceof AppVisibleChanged) { AppVisibleChanged visibleChanged = (AppVisibleChanged) event; if (visibleChanged.isVisible()) { onAppVisible(); } else { onAppHidden(); } } else if (event instanceof PeerChatOpened) { onConversationVisible(((PeerChatOpened) event).getPeer()); } else if (event instanceof PeerChatClosed) { onConversationHidden(((PeerChatClosed) event).getPeer()); } } public static class NewMessage { private Peer peer; private int sender; private long sortDate; private ContentDescription contentDescription; private boolean hasCurrentUserMention; private int unreadMessagesCount; private int unreadDialogsCount; public NewMessage(Peer peer, int sender, long sortDate, ContentDescription contentDescription, boolean hasCurrentUserMention, int unreadMessagesCount, int unreadDialogsCount) { this.peer = peer; this.sender = sender; this.sortDate = sortDate; this.contentDescription = contentDescription; this.hasCurrentUserMention = hasCurrentUserMention; this.unreadMessagesCount = unreadMessagesCount; this.unreadDialogsCount = unreadDialogsCount; } public Peer getPeer() { return peer; } public int getSender() { return sender; } public long getSortDate() { return sortDate; } public ContentDescription getContentDescription() { return contentDescription; } public boolean getHasCurrentUserMention() { return hasCurrentUserMention; } public int getUnreadMessagesCount() { return unreadMessagesCount; } public int getUnreadDialogsCount() { return unreadDialogsCount; } } public static class MessagesRead { private Peer peer; private long fromDate; public MessagesRead(Peer peer, long fromDate) { this.peer = peer; this.fromDate = fromDate; } public Peer getPeer() { return peer; } public long getFromDate() { return fromDate; } } public static class PauseNotifications { } public static class ResumeNotifications { } }