diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9dceedbe..5467b865 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
index fca7dc1e..6af79ddb 100644
--- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
+++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
@@ -15,9 +15,12 @@
package com.keylesspalace.tusky;
+import android.app.AlarmManager;
+import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
+import android.os.SystemClock;
import android.support.design.widget.TabLayout;
import android.support.v4.view.ViewPager;
import android.support.v7.app.AppCompatActivity;
@@ -27,6 +30,10 @@ import android.view.Menu;
import android.view.MenuItem;
public class MainActivity extends AppCompatActivity {
+ private AlarmManager alarmManager;
+ private PendingIntent serviceAlarmIntent;
+ private boolean notificationServiceEnabled;
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -35,6 +42,7 @@ public class MainActivity extends AppCompatActivity {
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
+ // Setup the tabs and timeline pager.
TimelinePagerAdapter adapter = new TimelinePagerAdapter(getSupportFragmentManager());
String[] pageTitles = {
getString(R.string.title_home),
@@ -46,6 +54,25 @@ public class MainActivity extends AppCompatActivity {
viewPager.setAdapter(adapter);
TabLayout tabLayout = (TabLayout) findViewById(R.id.tab_layout);
tabLayout.setupWithViewPager(viewPager);
+
+ // Retrieve notification update preference.
+ SharedPreferences preferences = getSharedPreferences(
+ getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
+ notificationServiceEnabled = preferences.getBoolean("notificationService", true);
+ long notificationCheckInterval =
+ preferences.getLong("notificationCheckInterval", 5 * 60 * 1000);
+ // Start up the NotificationsService.
+ alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
+ Intent intent = new Intent(this, NotificationService.class);
+ final int SERVICE_REQUEST_CODE = 8574603; // This number is arbitrary.
+ serviceAlarmIntent = PendingIntent.getService(this, SERVICE_REQUEST_CODE, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT);
+ if (notificationServiceEnabled) {
+ alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
+ SystemClock.elapsedRealtime(), notificationCheckInterval, serviceAlarmIntent);
+ } else {
+ alarmManager.cancel(serviceAlarmIntent);
+ }
}
private void compose() {
@@ -54,6 +81,9 @@ public class MainActivity extends AppCompatActivity {
}
private void logOut() {
+ if (notificationServiceEnabled) {
+ alarmManager.cancel(serviceAlarmIntent);
+ }
SharedPreferences preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit();
diff --git a/app/src/main/java/com/keylesspalace/tusky/Notification.java b/app/src/main/java/com/keylesspalace/tusky/Notification.java
index 86871cf2..d5fbacf3 100644
--- a/app/src/main/java/com/keylesspalace/tusky/Notification.java
+++ b/app/src/main/java/com/keylesspalace/tusky/Notification.java
@@ -17,6 +17,13 @@ package com.keylesspalace.tusky;
import android.support.annotation.Nullable;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
public class Notification {
public enum Type {
MENTION,
@@ -61,4 +68,27 @@ public class Notification {
|| type == Type.FAVOURITE
|| type == Type.REBLOG;
}
+
+ public static List parse(JSONArray array) throws JSONException {
+ List notifications = new ArrayList<>();
+ for (int i = 0; i < array.length(); i++) {
+ JSONObject object = array.getJSONObject(i);
+ String id = object.getString("id");
+ Notification.Type type = Notification.Type.valueOf(
+ object.getString("type").toUpperCase());
+ JSONObject account = object.getJSONObject("account");
+ String displayName = account.getString("display_name");
+ if (displayName.isEmpty()) {
+ displayName = account.getString("username");
+ }
+ Notification notification = new Notification(type, id, displayName);
+ if (notification.hasStatusType()) {
+ JSONObject statusObject = object.getJSONObject("status");
+ Status status = Status.parse(statusObject, false);
+ notification.setStatus(status);
+ }
+ notifications.add(notification);
+ }
+ return notifications;
+ }
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/NotificationService.java b/app/src/main/java/com/keylesspalace/tusky/NotificationService.java
new file mode 100644
index 00000000..5c6189ac
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/NotificationService.java
@@ -0,0 +1,228 @@
+/* Copyright 2017 Andrew Dawson
+ *
+ * This file is part of Tusky.
+ *
+ * Tusky 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
+ * . */
+
+package com.keylesspalace.tusky;
+
+import android.app.*;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.graphics.Bitmap;
+import android.os.Build;
+import android.provider.Settings;
+import android.support.annotation.Nullable;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.TaskStackBuilder;
+import android.util.Log;
+
+import com.android.volley.AuthFailureError;
+import com.android.volley.Response;
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.ImageRequest;
+import com.android.volley.toolbox.JsonArrayRequest;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class NotificationService extends IntentService {
+ private final int NOTIFY_ID = 6; // This is an arbitrary number.
+
+ public NotificationService() {
+ super("Tusky Notification Service");
+ }
+
+ @Override
+ protected void onHandleIntent(Intent intent) {
+ SharedPreferences preferences = getSharedPreferences(
+ getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
+ String domain = preferences.getString("domain", null);
+ String accessToken = preferences.getString("accessToken", null);
+ long date = preferences.getLong("lastUpdate", 0);
+ Date lastUpdate = null;
+ if (date != 0) {
+ lastUpdate = new Date(date);
+ }
+ assert(domain != null);
+ assert(accessToken != null);
+ checkNotifications(domain, accessToken, lastUpdate);
+ }
+
+ private void checkNotifications(final String domain, final String accessToken,
+ final Date lastUpdate) {
+ String endpoint = getString(R.string.endpoint_notifications);
+ String url = "https://" + domain + endpoint;
+ JsonArrayRequest request = new JsonArrayRequest(url,
+ new Response.Listener() {
+ @Override
+ public void onResponse(JSONArray response) {
+ List notifications;
+ try {
+ notifications = Notification.parse(response);
+ } catch (JSONException e) {
+ onCheckNotificationsFailure();
+ return;
+ }
+ onCheckNotificationsSuccess(notifications, lastUpdate);
+ }
+ }, new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ onCheckNotificationsFailure();
+ }
+ }) {
+ @Override
+ public Map getHeaders() throws AuthFailureError {
+ Map headers = new HashMap<>();
+ headers.put("Authorization", "Bearer " + accessToken);
+ return headers;
+ }
+ };
+ VolleySingleton.getInstance(this).addToRequestQueue(request);
+ }
+
+ private void onCheckNotificationsSuccess(List notifications, Date lastUpdate) {
+ Date newest = null;
+ List mentions = new ArrayList<>();
+ for (Notification notification : notifications) {
+ if (notification.getType() == Notification.Type.MENTION) {
+ Status status = notification.getStatus();
+ if (status != null) {
+ Date createdAt = status.getCreatedAt();
+ if (lastUpdate == null || createdAt.after(lastUpdate)) {
+ MentionResult mention = new MentionResult();
+ mention.content = status.getContent().toString();
+ mention.displayName = notification.getDisplayName();
+ mention.avatarUrl = status.getAvatar();
+ mentions.add(mention);
+ }
+ if (newest == null || createdAt.after(newest)) {
+ newest = createdAt;
+ }
+ }
+ }
+ }
+ long now = new Date().getTime();
+ if (mentions.size() > 0) {
+ SharedPreferences preferences = getSharedPreferences(
+ getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
+ SharedPreferences.Editor editor = preferences.edit();
+ editor.putLong("lastUpdate", now);
+ editor.apply();
+ loadAvatar(mentions, mentions.get(0).avatarUrl);
+ } else if (newest != null) {
+ long hoursAgo = (now - newest.getTime()) / (60 * 60 * 1000);
+ if (hoursAgo >= 1) {
+ dismissStaleNotifications();
+ }
+ }
+ }
+
+ private void onCheckNotificationsFailure() {
+ //TODO: not sure if just logging here is enough?
+ Log.e("Error", "Could not check notifications in the service.");
+ }
+
+ private static class MentionResult {
+ public String displayName;
+ public String content;
+ public String avatarUrl;
+ }
+
+ private String truncateWithEllipses(String string, int limit) {
+ if (string.length() < limit) {
+ return string;
+ } else {
+ return string.substring(0, limit - 3) + "...";
+ }
+ }
+
+ private void loadAvatar(final List mentions, String url) {
+ ImageRequest request = new ImageRequest(url, new Response.Listener() {
+ @Override
+ public void onResponse(Bitmap response) {
+ updateNotification(mentions, response);
+ }
+ }, 0, 0, null, null, new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ updateNotification(mentions, null);
+ }
+ });
+ VolleySingleton.getInstance(this).addToRequestQueue(request);
+ }
+
+ private void updateNotification(List mentions, @Nullable Bitmap icon) {
+ final int NOTIFICATION_CONTENT_LIMIT = 40;
+ SharedPreferences preferences = getSharedPreferences(
+ getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
+ String title;
+ if (mentions.size() > 1) {
+ title = String.format(
+ getString(R.string.notification_service_several_mentions),
+ mentions.size());
+ } else {
+ title = String.format(
+ getString(R.string.notification_service_one_mention),
+ mentions.get(0).displayName);
+ }
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(this)
+ .setSmallIcon(R.drawable.ic_notify_mention)
+ .setContentTitle(title);
+ if (icon != null) {
+ builder.setLargeIcon(icon);
+ }
+ if (preferences.getBoolean("notificationAlertSound", false)) {
+ builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI);
+ }
+ if (preferences.getBoolean("notificationStyleVibrate", false)) {
+ builder.setVibrate(new long[] { 500, 500 });
+ }
+ if (preferences.getBoolean("notificationStyleLight", false)) {
+ builder.setLights(0xFF00FF8F, 300, 1000);
+ }
+ for (int i = 0; i < mentions.size(); i++) {
+ MentionResult mention = mentions.get(i);
+ String text = truncateWithEllipses(mention.content, NOTIFICATION_CONTENT_LIMIT);
+ builder.setContentText(text)
+ .setNumber(i);
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ builder.setVisibility(android.app.Notification.VISIBILITY_PRIVATE);
+ builder.setCategory(android.app.Notification.CATEGORY_SOCIAL);
+ }
+ Intent resultIntent = new Intent(this, SplashActivity.class);
+ TaskStackBuilder stackBuilder = TaskStackBuilder.create(this);
+ stackBuilder.addParentStack(SplashActivity.class);
+ stackBuilder.addNextIntent(resultIntent);
+ PendingIntent resultPendingIntent =
+ stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT);
+ builder.setContentIntent(resultPendingIntent);
+ NotificationManager notificationManager =
+ (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.notify(NOTIFY_ID, builder.build());
+ }
+
+ private void dismissStaleNotifications() {
+ NotificationManager notificationManager =
+ (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.cancel(NOTIFY_ID);
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java
index cfe00b4f..eb07aeda 100644
--- a/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java
+++ b/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java
@@ -106,23 +106,8 @@ public class NotificationsFragment extends SFragment implements
new Response.Listener() {
@Override
public void onResponse(JSONArray response) {
- List notifications = new ArrayList<>();
try {
- for (int i = 0; i < response.length(); i++) {
- JSONObject object = response.getJSONObject(i);
- String id = object.getString("id");
- Notification.Type type = Notification.Type.valueOf(
- object.getString("type").toUpperCase());
- JSONObject account = object.getJSONObject("account");
- String displayName = account.getString("display_name");
- Notification notification = new Notification(type, id, displayName);
- if (notification.hasStatusType()) {
- JSONObject statusObject = object.getJSONObject("status");
- Status status = Status.parse(statusObject, false);
- notification.setStatus(status);
- }
- notifications.add(notification);
- }
+ List notifications = Notification.parse(response);
onFetchNotificationsSuccess(notifications, fromId != null);
} catch (JSONException e) {
onFetchNotificationsFailure();
diff --git a/app/src/main/java/com/keylesspalace/tusky/Status.java b/app/src/main/java/com/keylesspalace/tusky/Status.java
index d2d9e2c8..6c73c1ef 100644
--- a/app/src/main/java/com/keylesspalace/tusky/Status.java
+++ b/app/src/main/java/com/keylesspalace/tusky/Status.java
@@ -214,6 +214,9 @@ public class Status {
JSONObject account = object.getJSONObject("account");
String accountId = account.getString("id");
String displayName = account.getString("display_name");
+ if (displayName.isEmpty()) {
+ displayName = account.getString("username");
+ }
String username = account.getString("acct");
String avatar = account.getString("avatar");
diff --git a/app/src/main/res/drawable/ic_notify_mention.xml b/app/src/main/res/drawable/ic_notify_mention.xml
new file mode 100644
index 00000000..6ee8c1ce
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notify_mention.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 802dc566..62e1aa81 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -86,4 +86,8 @@
Private
Unlisted
+ Allows Tusky to check for Mastodon notifications.
+ %d new mentions
+ Mention from %s
+