1package eu.siacs.conversations.services;
2
3import android.app.Notification;
4import android.app.NotificationManager;
5import android.app.PendingIntent;
6import android.content.Context;
7import android.content.Intent;
8import android.content.SharedPreferences;
9import android.graphics.Bitmap;
10import android.net.Uri;
11import android.os.PowerManager;
12import android.os.SystemClock;
13import android.support.v4.app.NotificationCompat;
14import android.support.v4.app.NotificationCompat.BigPictureStyle;
15import android.support.v4.app.NotificationCompat.Builder;
16import android.support.v4.app.TaskStackBuilder;
17import android.text.Html;
18import android.util.DisplayMetrics;
19import android.util.Log;
20
21import java.io.FileNotFoundException;
22import java.util.ArrayList;
23import java.util.Calendar;
24import java.util.HashMap;
25import java.util.LinkedHashMap;
26import java.util.List;
27import java.util.regex.Matcher;
28import java.util.regex.Pattern;
29
30import org.json.JSONArray;
31import org.json.JSONObject;
32
33import eu.siacs.conversations.Config;
34import eu.siacs.conversations.R;
35import eu.siacs.conversations.entities.Account;
36import eu.siacs.conversations.entities.Conversation;
37import eu.siacs.conversations.entities.Downloadable;
38import eu.siacs.conversations.entities.DownloadableFile;
39import eu.siacs.conversations.entities.Message;
40import eu.siacs.conversations.ui.ConversationActivity;
41import eu.siacs.conversations.ui.ManageAccountActivity;
42import eu.siacs.conversations.ui.TimePreference;
43
44public class NotificationService {
45
46 private XmppConnectionService mXmppConnectionService;
47
48 private final LinkedHashMap<String, ArrayList<Message>> notifications = new LinkedHashMap<>();
49
50 public static final int NOTIFICATION_ID = 0x2342;
51 public static final int FOREGROUND_NOTIFICATION_ID = 0x8899;
52 public static final int ERROR_NOTIFICATION_ID = 0x5678;
53
54 private Conversation mOpenConversation;
55 private boolean mIsInForeground;
56 private long mLastNotification;
57
58 public NotificationService(XmppConnectionService service) {
59 this.mXmppConnectionService = service;
60 }
61
62 public boolean notify(final Message message) {
63 return (message.getStatus() == Message.STATUS_RECEIVED)
64 && notificationsEnabled()
65 && !message.getConversation().isMuted()
66 && (message.getConversation().getMode() == Conversation.MODE_SINGLE
67 || conferenceNotificationsEnabled()
68 || wasHighlightedOrPrivate(message)
69 );
70 }
71
72 public void notifyPebble(Message message) {
73 final Intent i = new Intent("com.getpebble.action.SEND_NOTIFICATION");
74
75 final HashMap data = new HashMap();
76 final Conversation conversation = message.getConversation();
77 data.put("title", conversation.getName());
78 data.put("body", message.getBody());
79 final JSONObject jsonData = new JSONObject(data);
80 final String notificationData = new JSONArray().put(jsonData).toString();
81
82 i.putExtra("messageType", "PEBBLE_ALERT");
83 i.putExtra("sender", "Conversations"); /* XXX: Shouldn't be hardcoded, e.g., AbstractGenerator.APP_NAME); */
84 i.putExtra("notificationData", notificationData);
85
86 mXmppConnectionService.sendBroadcast(i);
87 }
88
89
90 public boolean notificationsEnabled() {
91 return mXmppConnectionService.getPreferences().getBoolean("show_notification", true);
92 }
93
94 public boolean isQuietHours() {
95 if (!mXmppConnectionService.getPreferences().getBoolean("enable_quiet_hours", false)) {
96 return false;
97 }
98 final long startTime = mXmppConnectionService.getPreferences().getLong("quiet_hours_start", TimePreference.DEFAULT_VALUE) % Config.MILLISECONDS_IN_DAY;
99 final long endTime = mXmppConnectionService.getPreferences().getLong("quiet_hours_end", TimePreference.DEFAULT_VALUE) % Config.MILLISECONDS_IN_DAY;
100 final long nowTime = Calendar.getInstance().getTimeInMillis() % Config.MILLISECONDS_IN_DAY;
101
102 if (endTime < startTime) {
103 return nowTime > startTime || nowTime < endTime;
104 } else {
105 return nowTime > startTime && nowTime < endTime;
106 }
107 }
108
109 public boolean conferenceNotificationsEnabled() {
110 return mXmppConnectionService.getPreferences().getBoolean("always_notify_in_conference", false);
111 }
112
113 public void push(final Message message) {
114 if (!notify(message)) {
115 return;
116 }
117 final PowerManager pm = (PowerManager) mXmppConnectionService
118 .getSystemService(Context.POWER_SERVICE);
119 final boolean isScreenOn = pm.isScreenOn();
120
121 if (this.mIsInForeground && isScreenOn
122 && this.mOpenConversation == message.getConversation()) {
123 return;
124 }
125 synchronized (notifications) {
126 final String conversationUuid = message.getConversationUuid();
127 if (notifications.containsKey(conversationUuid)) {
128 notifications.get(conversationUuid).add(message);
129 } else {
130 final ArrayList<Message> mList = new ArrayList<>();
131 mList.add(message);
132 notifications.put(conversationUuid, mList);
133 }
134 final Account account = message.getConversation().getAccount();
135 final boolean doNotify = (!(this.mIsInForeground && this.mOpenConversation == null) || !isScreenOn)
136 && !account.inGracePeriod()
137 && !this.inMiniGracePeriod(account);
138 updateNotification(doNotify);
139 if (doNotify) {
140 notifyPebble(message);
141 }
142 }
143
144 }
145
146 public void clear() {
147 synchronized (notifications) {
148 notifications.clear();
149 updateNotification(false);
150 }
151 }
152
153 public void clear(final Conversation conversation) {
154 synchronized (notifications) {
155 notifications.remove(conversation.getUuid());
156 updateNotification(false);
157 }
158 }
159
160 private void updateNotification(final boolean notify) {
161 final NotificationManager notificationManager = (NotificationManager) mXmppConnectionService
162 .getSystemService(Context.NOTIFICATION_SERVICE);
163 final SharedPreferences preferences = mXmppConnectionService.getPreferences();
164
165 final String ringtone = preferences.getString("notification_ringtone", null);
166 final boolean vibrate = preferences.getBoolean("vibrate_on_notification", true);
167
168 if (notifications.size() == 0) {
169 notificationManager.cancel(NOTIFICATION_ID);
170 } else {
171 if (notify) {
172 this.markLastNotification();
173 }
174 final Builder mBuilder;
175 if (notifications.size() == 1) {
176 mBuilder = buildSingleConversations(notify);
177 } else {
178 mBuilder = buildMultipleConversation();
179 }
180 if (notify && !isQuietHours()) {
181 if (vibrate) {
182 final int dat = 70;
183 final long[] pattern = {0, 3 * dat, dat, dat};
184 mBuilder.setVibrate(pattern);
185 }
186 if (ringtone != null) {
187 mBuilder.setSound(Uri.parse(ringtone));
188 }
189 }
190 mBuilder.setSmallIcon(R.drawable.ic_notification);
191 mBuilder.setDeleteIntent(createDeleteIntent());
192 mBuilder.setLights(0xffffffff, 2000, 4000);
193 final Notification notification = mBuilder.build();
194 notificationManager.notify(NOTIFICATION_ID, notification);
195 }
196 }
197
198 private Builder buildMultipleConversation() {
199 final Builder mBuilder = new NotificationCompat.Builder(
200 mXmppConnectionService);
201 NotificationCompat.InboxStyle style = new NotificationCompat.InboxStyle();
202 style.setBigContentTitle(notifications.size()
203 + " "
204 + mXmppConnectionService
205 .getString(R.string.unread_conversations));
206 final StringBuilder names = new StringBuilder();
207 Conversation conversation = null;
208 for (ArrayList<Message> messages : notifications.values()) {
209 if (messages.size() > 0) {
210 conversation = messages.get(0).getConversation();
211 String name = conversation.getName();
212 style.addLine(Html.fromHtml("<b>" + name + "</b> "
213 + getReadableBody(messages.get(0))));
214 names.append(name);
215 names.append(", ");
216 }
217 }
218 if (names.length() >= 2) {
219 names.delete(names.length() - 2, names.length());
220 }
221 mBuilder.setContentTitle(notifications.size()
222 + " "
223 + mXmppConnectionService
224 .getString(R.string.unread_conversations));
225 mBuilder.setContentText(names.toString());
226 mBuilder.setStyle(style);
227 if (conversation != null) {
228 mBuilder.setContentIntent(createContentIntent(conversation
229 .getUuid()));
230 }
231 return mBuilder;
232 }
233
234 private Builder buildSingleConversations(final boolean notify) {
235 final Builder mBuilder = new NotificationCompat.Builder(
236 mXmppConnectionService);
237 final ArrayList<Message> messages = notifications.values().iterator().next();
238 if (messages.size() >= 1) {
239 final Conversation conversation = messages.get(0).getConversation();
240 mBuilder.setLargeIcon(mXmppConnectionService.getAvatarService()
241 .get(conversation, getPixel(64)));
242 mBuilder.setContentTitle(conversation.getName());
243 final Message message;
244 if ((message = getImage(messages)) != null) {
245 modifyForImage(mBuilder, message, messages, notify);
246 } else {
247 modifyForTextOnly(mBuilder, messages, notify);
248 }
249 mBuilder.setContentIntent(createContentIntent(conversation
250 .getUuid()));
251 }
252 return mBuilder;
253 }
254
255 private void modifyForImage(final Builder builder, final Message message,
256 final ArrayList<Message> messages, final boolean notify) {
257 try {
258 final Bitmap bitmap = mXmppConnectionService.getFileBackend()
259 .getThumbnail(message, getPixel(288), false);
260 final ArrayList<Message> tmp = new ArrayList<>();
261 for (final Message msg : messages) {
262 if (msg.getType() == Message.TYPE_TEXT
263 && msg.getDownloadable() == null) {
264 tmp.add(msg);
265 }
266 }
267 final BigPictureStyle bigPictureStyle = new NotificationCompat.BigPictureStyle();
268 bigPictureStyle.bigPicture(bitmap);
269 if (tmp.size() > 0) {
270 bigPictureStyle.setSummaryText(getMergedBodies(tmp));
271 builder.setContentText(getReadableBody(tmp.get(0)));
272 } else {
273 builder.setContentText(mXmppConnectionService.getString(R.string.image_file));
274 }
275 builder.setStyle(bigPictureStyle);
276 } catch (final FileNotFoundException e) {
277 modifyForTextOnly(builder, messages, notify);
278 }
279 }
280
281 private void modifyForTextOnly(final Builder builder,
282 final ArrayList<Message> messages, final boolean notify) {
283 builder.setStyle(new NotificationCompat.BigTextStyle()
284 .bigText(getMergedBodies(messages)));
285 builder.setContentText(getReadableBody(messages.get(0)));
286 if (notify) {
287 builder.setTicker(getReadableBody(messages.get(messages.size() - 1)));
288 }
289 }
290
291 private Message getImage(final ArrayList<Message> messages) {
292 for (final Message message : messages) {
293 if (message.getType() == Message.TYPE_IMAGE
294 && message.getDownloadable() == null
295 && message.getEncryption() != Message.ENCRYPTION_PGP) {
296 return message;
297 }
298 }
299 return null;
300 }
301
302 private String getMergedBodies(final ArrayList<Message> messages) {
303 final StringBuilder text = new StringBuilder();
304 for (int i = 0; i < messages.size(); ++i) {
305 text.append(getReadableBody(messages.get(i)));
306 if (i != messages.size() - 1) {
307 text.append("\n");
308 }
309 }
310 return text.toString();
311 }
312
313 private String getReadableBody(final Message message) {
314 if (message.getDownloadable() != null
315 && (message.getDownloadable().getStatus() == Downloadable.STATUS_OFFER || message
316 .getDownloadable().getStatus() == Downloadable.STATUS_OFFER_CHECK_FILESIZE)) {
317 if (message.getType() == Message.TYPE_FILE) {
318 return mXmppConnectionService.getString(R.string.file_offered_for_download);
319 } else {
320 return mXmppConnectionService.getText(
321 R.string.image_offered_for_download).toString();
322 }
323 } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
324 return mXmppConnectionService.getText(
325 R.string.encrypted_message_received).toString();
326 } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
327 return mXmppConnectionService.getText(R.string.decryption_failed)
328 .toString();
329 } else if (message.getType() == Message.TYPE_FILE) {
330 DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message);
331 return mXmppConnectionService.getString(R.string.file,file.getMimeType());
332 } else if (message.getType() == Message.TYPE_IMAGE) {
333 return mXmppConnectionService.getText(R.string.image_file)
334 .toString();
335 } else {
336 return message.getBody().trim();
337 }
338 }
339
340 private PendingIntent createContentIntent(final String conversationUuid) {
341 final TaskStackBuilder stackBuilder = TaskStackBuilder
342 .create(mXmppConnectionService);
343 stackBuilder.addParentStack(ConversationActivity.class);
344
345 final Intent viewConversationIntent = new Intent(mXmppConnectionService,
346 ConversationActivity.class);
347 viewConversationIntent.setAction(Intent.ACTION_VIEW);
348 if (conversationUuid != null) {
349 viewConversationIntent.putExtra(ConversationActivity.CONVERSATION,
350 conversationUuid);
351 viewConversationIntent.setType(ConversationActivity.VIEW_CONVERSATION);
352 }
353
354 stackBuilder.addNextIntent(viewConversationIntent);
355
356 return stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT);
357 }
358
359 private PendingIntent createDeleteIntent() {
360 final Intent intent = new Intent(mXmppConnectionService,
361 XmppConnectionService.class);
362 intent.setAction(XmppConnectionService.ACTION_CLEAR_NOTIFICATION);
363 return PendingIntent.getService(mXmppConnectionService, 0, intent, 0);
364 }
365
366 private PendingIntent createDisableForeground() {
367 final Intent intent = new Intent(mXmppConnectionService,
368 XmppConnectionService.class);
369 intent.setAction(XmppConnectionService.ACTION_DISABLE_FOREGROUND);
370 return PendingIntent.getService(mXmppConnectionService, 0, intent, 0);
371 }
372
373 private boolean wasHighlightedOrPrivate(final Message message) {
374 final String nick = message.getConversation().getMucOptions().getActualNick();
375 final Pattern highlight = generateNickHighlightPattern(nick);
376 if (message.getBody() == null || nick == null) {
377 return false;
378 }
379 final Matcher m = highlight.matcher(message.getBody());
380 return (m.find() || message.getType() == Message.TYPE_PRIVATE);
381 }
382
383 private static Pattern generateNickHighlightPattern(final String nick) {
384 // We expect a word boundary, i.e. space or start of string, followed by
385 // the
386 // nick (matched in case-insensitive manner), followed by optional
387 // punctuation (for example "bob: i disagree" or "how are you alice?"),
388 // followed by another word boundary.
389 return Pattern.compile("\\b" + nick + "\\p{Punct}?\\b",
390 Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
391 }
392
393 public void setOpenConversation(final Conversation conversation) {
394 this.mOpenConversation = conversation;
395 }
396
397 public void setIsInForeground(final boolean foreground) {
398 if (foreground != this.mIsInForeground) {
399 Log.d(Config.LOGTAG,"setIsInForeground("+Boolean.toString(foreground)+")");
400 }
401 this.mIsInForeground = foreground;
402 }
403
404 private int getPixel(final int dp) {
405 final DisplayMetrics metrics = mXmppConnectionService.getResources()
406 .getDisplayMetrics();
407 return ((int) (dp * metrics.density));
408 }
409
410 private void markLastNotification() {
411 this.mLastNotification = SystemClock.elapsedRealtime();
412 }
413
414 private boolean inMiniGracePeriod(final Account account) {
415 final int miniGrace = account.getStatus() == Account.State.ONLINE ? Config.MINI_GRACE_PERIOD
416 : Config.MINI_GRACE_PERIOD * 2;
417 return SystemClock.elapsedRealtime() < (this.mLastNotification + miniGrace);
418 }
419
420 public Notification createForegroundNotification() {
421 final NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(mXmppConnectionService);
422 mBuilder.setSmallIcon(R.drawable.ic_stat_communication_import_export);
423 mBuilder.setContentTitle(mXmppConnectionService.getString(R.string.conversations_foreground_service));
424 mBuilder.setContentText(mXmppConnectionService.getString(R.string.touch_to_disable));
425 mBuilder.setContentIntent(createDisableForeground());
426 mBuilder.setWhen(0);
427 mBuilder.setPriority(NotificationCompat.PRIORITY_MIN);
428 return mBuilder.build();
429 }
430
431 public void updateErrorNotification() {
432 final NotificationManager mNotificationManager = (NotificationManager) mXmppConnectionService.getSystemService(Context.NOTIFICATION_SERVICE);
433 final List<Account> errors = new ArrayList<>();
434 for (final Account account : mXmppConnectionService.getAccounts()) {
435 if (account.hasErrorStatus()) {
436 errors.add(account);
437 }
438 }
439 final NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(mXmppConnectionService);
440 if (errors.size() == 0) {
441 mNotificationManager.cancel(ERROR_NOTIFICATION_ID);
442 return;
443 } else if (errors.size() == 1) {
444 mBuilder.setContentTitle(mXmppConnectionService.getString(R.string.problem_connecting_to_account));
445 mBuilder.setContentText(errors.get(0).getJid().toBareJid().toString());
446 } else {
447 mBuilder.setContentTitle(mXmppConnectionService.getString(R.string.problem_connecting_to_accounts));
448 mBuilder.setContentText(mXmppConnectionService.getString(R.string.touch_to_fix));
449 }
450 mBuilder.setOngoing(true);
451 mBuilder.setLights(0xffffffff, 2000, 4000);
452 mBuilder.setSmallIcon(R.drawable.ic_stat_alert_warning);
453 TaskStackBuilder stackBuilder = TaskStackBuilder.create(mXmppConnectionService);
454 stackBuilder.addParentStack(ConversationActivity.class);
455
456 final Intent manageAccountsIntent = new Intent(mXmppConnectionService,ManageAccountActivity.class);
457 stackBuilder.addNextIntent(manageAccountsIntent);
458
459 final PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0,PendingIntent.FLAG_UPDATE_CURRENT);
460
461 mBuilder.setContentIntent(resultPendingIntent);
462 mNotificationManager.notify(ERROR_NOTIFICATION_ID, mBuilder.build());
463 }
464}