package org.indywidualni.fblite.service;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.preference.PreferenceManager;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.TaskStackBuilder;
import android.webkit.CookieManager;
import android.webkit.CookieSyncManager;
import android.widget.Toast;
import org.indywidualni.fblite.MyApplication;
import org.indywidualni.fblite.R;
import org.indywidualni.fblite.activity.MainActivity;
import org.indywidualni.fblite.util.Connectivity;
import org.indywidualni.fblite.util.Miscellany;
import org.indywidualni.fblite.util.logger.Logger;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.io.IOException;
public class NotificationsService extends Service {
// Facebook URL constants
private static final String BASE_URL = "https://mobile.facebook.com";
private static final String NOTIFICATIONS_URL = "https://m.facebook.com/notifications.php";
private static final String MESSAGES_URL = "https://m.facebook.com/messages/?more";
private static final String MESSAGES_URL_BACKUP = "https://mobile.facebook.com/messages";
private static final String NOTIFICATION_MESSAGE_URL = "https://www.messenger.com/login";
private static final String NOTIFICATION_OLD_MESSAGE_URL = "https://m.facebook.com/messages#";
// number of trials during notifications or messages checking
private static final int MAX_RETRY = 3;
private static final int JSOUP_TIMEOUT = 10000;
private static final String TAG;
// HandlerThread, Handler (final to allow synchronization) and its runnable
private final HandlerThread handlerThread;
private final Handler handler;
private static Runnable runnable;
// volatile boolean to safely skip checking while service is being stopped
private volatile boolean shouldContinue = true;
private static String userAgent;
private SharedPreferences preferences;
/* Well, bad practice. Object name starting with a capital, but it's convenient.
In order to use my custom logger I just removed Log import and I'm getting an
instance of my Logger here. Its usage is exactly the same as the usage of Log */
private final Logger Log;
// static initializer
static {
TAG = NotificationsService.class.getSimpleName();
}
// class constructor, starts a new thread in which checkers are being run
public NotificationsService() {
handlerThread = new HandlerThread("Handler Thread");
handlerThread.start();
handler = new Handler(handlerThread.getLooper());
Log = Logger.getInstance();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onCreate() {
Log.i(TAG, "********** Service created! **********");
super.onCreate();
preferences = PreferenceManager.getDefaultSharedPreferences(this);
// create a runnable needed by a Handler
runnable = new HandlerRunnable();
// start a repeating checking, first run delay (3 seconds)
handler.postDelayed(runnable, 3000);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
return START_STICKY;
}
@Override
public void onDestroy() {
Log.i(TAG, "onDestroy: Service stopping...");
super.onDestroy();
synchronized (handler) {
shouldContinue = false;
handler.notify();
}
handler.removeCallbacksAndMessages(null);
handlerThread.quit();
}
/** A runnable used by the Handler to schedule checking. */
private class HandlerRunnable implements Runnable {
public void run() {
try {
// get time interval from tray preferences
final int timeInterval = Integer.parseInt(preferences.getString("interval_pref", "1800000"));
Log.i(TAG, "Time interval: " + (timeInterval / 1000) + " seconds");
// time since last check = now - last check
final long now = System.currentTimeMillis();
final long sinceLastCheck = now - preferences.getLong("last_check", now);
final boolean ntfLastStatus = preferences.getBoolean("ntf_last_status", false);
final boolean msgLastStatus = preferences.getBoolean("msg_last_status", false);
if ((sinceLastCheck < timeInterval) && ntfLastStatus && msgLastStatus) {
final long waitTime = timeInterval - sinceLastCheck;
if (waitTime >= 1000) { // waiting less than a second is just stupid
Log.i(TAG, "I'm going to wait. Resuming in: " + (waitTime / 1000) + " seconds");
synchronized (handler) {
try {
handler.wait(waitTime);
} catch (InterruptedException ex) {
Log.i(TAG, "Thread interrupted");
} finally {
Log.i(TAG, "Lock is now released");
}
}
}
}
// when onDestroy() is run and lock is released, don't go on
if (shouldContinue) {
// start AsyncTasks if there is internet connection
if (Connectivity.isConnected(getApplicationContext())) {
Log.i(TAG, "Internet connection active. Starting AsyncTask...");
String connectionType = "Wi-Fi";
if (Connectivity.isConnectedMobile(getApplicationContext()))
connectionType = "Mobile";
Log.i(TAG, "Connection Type: " + connectionType);
userAgent = preferences.getString("webview_user_agent", System.getProperty("http.agent"));
Log.i(TAG, "User Agent: " + userAgent);
if (preferences.getBoolean("notifications_activated", false))
new CheckNotificationsTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void) null);
if (preferences.getBoolean("message_notifications", false))
new CheckMessagesTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void) null);
// save current time (last potentially successful checking)
preferences.edit().putLong("last_check", System.currentTimeMillis()).apply();
} else
Log.i(TAG, "No internet connection. Skip checking.");
// set repeat time interval
handler.postDelayed(runnable, timeInterval);
} else
Log.i(TAG, "Notified to stop running. Exiting...");
} catch (RuntimeException re) {
Log.i(TAG, "RuntimeException caught");
restartItself();
}
}
}
/** Notifications checker task: it checks Facebook notifications only. */
private class CheckNotificationsTask extends AsyncTask<Void, Void, Element> {
boolean syncProblemOccurred = false;
private Element getElement(String connectUrl) {
try {
return Jsoup.connect(connectUrl)
.userAgent(userAgent).timeout(JSOUP_TIMEOUT)
.cookie("https://mobile.facebook.com", CookieManager.getInstance().getCookie("https://mobile.facebook.com"))
.get()
.select("div.touchable-notification")
.not("a._19no")
.not("a.button")
.first();
} catch (IllegalArgumentException ex) {
Log.i("CheckNotificationsTask", "Cookie sync problem occurred");
if (!syncProblemOccurred) {
syncProblemToast();
syncProblemOccurred = true;
}
} catch (IOException ex) {
ex.printStackTrace();
}
return null;
}
@Override
protected Element doInBackground(Void... params) {
Element result = null;
int tries = 0;
syncCookies();
while (tries++ < MAX_RETRY && result == null) {
Log.i("CheckNotificationsTask", "doInBackground: Processing... Trial: " + tries);
Log.i("CheckNotificationsTask", "Trying: " + NOTIFICATIONS_URL);
Element notification = getElement(NOTIFICATIONS_URL);
if (notification != null)
result = notification;
}
return result;
}
@Override
protected void onPostExecute(final Element result) {
try {
if (result == null)
return;
if (result.text() == null)
return;
final String time = result.select("span.mfss.fcg").text();
final String text = result.text().replace(time, "");
final String pictureStyle = result.select("i.img.l.profpic").attr("style");
if (!preferences.getBoolean("activity_visible", false) || preferences.getBoolean("notifications_everywhere", true)) {
if (!preferences.getString("last_notification_text", "").equals(text)) {
// try to download a picture and send the notification
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground (Void[] params){
Bitmap picture = Miscellany.getBitmapFromURL(Miscellany.extractUrl(pictureStyle));
notifier(text, BASE_URL + result.attr("href"), false, picture);
return null;
}
}.execute();
}
}
// save as shown (or ignored) to avoid showing it again
preferences.edit().putString("last_notification_text", text).apply();
// save this check status
preferences.edit().putBoolean("ntf_last_status", true).apply();
Log.i("CheckNotificationsTask", "onPostExecute: Aight biatch ;)");
} catch (Exception ex) {
// save this check status
preferences.edit().putBoolean("ntf_last_status", false).apply();
Log.i("CheckNotificationsTask", "onPostExecute: Failure");
}
}
}
/** Messages checker task: it checks new messages only. */
private class CheckMessagesTask extends AsyncTask<Void, Void, String> {
boolean syncProblemOccurred = false;
private String getNumber(String connectUrl) {
try {
Elements message = Jsoup.connect(connectUrl)
.userAgent(userAgent)
.timeout(JSOUP_TIMEOUT)
.cookie("https://m.facebook.com", CookieManager.getInstance().getCookie("https://m.facebook.com"))
.get()
.select("div#viewport").select("div#page").select("div._129-")
.select("#messages_jewel").select("span._59tg");
return message.html();
} catch (IllegalArgumentException ex) {
Log.i("CheckMessagesTask", "Cookie sync problem occurred");
if (!syncProblemOccurred) {
syncProblemToast();
syncProblemOccurred = true;
}
} catch (IOException ex) {
ex.printStackTrace();
}
return "failure";
}
@Override
protected String doInBackground(Void... params) {
String result = null;
int tries = 0;
// sync cookies to get the right data
syncCookies();
while (tries++ < MAX_RETRY && result == null) {
Log.i("CheckMessagesTask", "doInBackground: Processing... Trial: " + tries);
// try to generate rss feed address
Log.i("CheckMsgTask:getNumber", "Trying: " + MESSAGES_URL);
String number = getNumber(MESSAGES_URL);
if (!number.matches("^[+-]?\\d+$")) {
Log.i("CheckMsgTask:getNumber", "Trying: " + MESSAGES_URL_BACKUP);
number = getNumber(MESSAGES_URL_BACKUP);
}
if (number.matches("^[+-]?\\d+$"))
result = number;
}
return result;
}
@Override
protected void onPostExecute(String result) {
try {
// parse a number of unread messages
int newMessages = Integer.parseInt(result);
if (!preferences.getBoolean("activity_visible", false) || preferences.getBoolean("notifications_everywhere", true)) {
if (newMessages == 1)
notifier(getString(R.string.you_have_one_message), NOTIFICATION_OLD_MESSAGE_URL, true, null);
else if (newMessages > 1)
notifier(String.format(getString(R.string.you_have_n_messages), newMessages), NOTIFICATION_OLD_MESSAGE_URL, true, null);
}
// save this check status
preferences.edit().putBoolean("msg_last_status", true).apply();
Log.i("CheckMessagesTask", "onPostExecute: Aight biatch ;)");
} catch (NumberFormatException ex) {
// save this check status
preferences.edit().putBoolean("msg_last_status", false).apply();
Log.i("CheckMessagesTask", "onPostExecute: Failure");
}
}
}
/** CookieSyncManager was deprecated in API level 21.
* We need it for API level lower than 21 though.
* In API level >= 21 it's done automatically.
*/
@SuppressWarnings("deprecation")
private void syncCookies() {
if (Build.VERSION.SDK_INT < 21) {
CookieSyncManager.createInstance(getApplicationContext());
CookieSyncManager.getInstance().sync();
}
}
// show a Sync Problem Toast while not being on UI Thread
private void syncProblemToast() {
Handler handler = new Handler(Looper.getMainLooper());
handler.post(new Runnable() {
@Override
public void run() {
Toast.makeText(getApplicationContext(), getString(R.string.sync_problem),
Toast.LENGTH_SHORT).show();
}
});
}
// restart the service from inside the service
private void restartItself() {
final Context context = MyApplication.getContextOfApplication();
final Intent intent = new Intent(context, NotificationsService.class);
context.stopService(intent);
context.startService(intent);
}
// create a notification and display it
private void notifier(String title, String url, boolean isMessage, Bitmap picture) {
// let's display a notification, dude!
final String contentTitle;
if (isMessage)
contentTitle = getString(R.string.app_name) + ": " + getString(R.string.messages);
else
contentTitle = getString(R.string.app_name) + ": " + getString(R.string.notifications);
// log line (show what type of notification is about to be displayed)
Log.i(TAG, "Start notification - isMessage: " + isMessage);
// start building a notification
NotificationCompat.Builder mBuilder =
new NotificationCompat.Builder(this)
.setStyle(new NotificationCompat.BigTextStyle().bigText(title))
.setSmallIcon(R.mipmap.ic_stat_fs)
.setContentTitle(contentTitle)
.setContentText(title)
.setTicker(title)
.setWhen(System.currentTimeMillis())
.setAutoCancel(true);
// picture is available
if (picture != null)
mBuilder.setLargeIcon(picture);
// ringtone
String ringtoneKey = "ringtone";
if (isMessage)
ringtoneKey = "ringtone_msg";
Uri ringtoneUri = Uri.parse(preferences.getString(ringtoneKey, "content://settings/system/notification_sound"));
mBuilder.setSound(ringtoneUri);
// vibration
if (preferences.getBoolean("vibrate", false))
mBuilder.setVibrate(new long[] {500, 500});
else
mBuilder.setVibrate(new long[] {0L});
// LED light
if (preferences.getBoolean("led_light", false)) {
Resources resources = getResources(), systemResources = Resources.getSystem();
mBuilder.setLights(Color.CYAN,
resources.getInteger(systemResources.getIdentifier("config_defaultNotificationLedOn", "integer", "android")),
resources.getInteger(systemResources.getIdentifier("config_defaultNotificationLedOff", "integer", "android")));
}
// priority for Heads-up
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN)
mBuilder.setPriority(Notification.PRIORITY_HIGH);
// intent with notification url in extra
Intent intent = new Intent(this, MainActivity.class);
intent.putExtra("start_url", url);
intent.setAction("NOTIFICATION_URL_ACTION");
// final notification building
TaskStackBuilder stackBuilder = TaskStackBuilder.create(this);
stackBuilder.addParentStack(MainActivity.class);
stackBuilder.addNextIntent(intent);
PendingIntent resultPendingIntent = PendingIntent.getActivity(getApplicationContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
mBuilder.setContentIntent(resultPendingIntent);
mBuilder.setOngoing(false);
Notification note = mBuilder.build();
// LED light flag
if (preferences.getBoolean("led_light", false))
note.flags |= Notification.FLAG_SHOW_LIGHTS;
// display a notification
NotificationManager mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
// because message notifications are displayed separately
if (isMessage)
mNotificationManager.notify(1, note);
else
mNotificationManager.notify(0, note);
}
// cancel all the notifications which are visible at the moment
public static void cancelAllNotifications() {
NotificationManager notificationManager = (NotificationManager)
MyApplication.getContextOfApplication().getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancelAll();
}
}