From 83f8b4303cac5623913f7a4452a99a3e0ad0b048 Mon Sep 17 00:00:00 2001 From: Vavassor Date: Tue, 24 Jan 2017 23:35:54 -0500 Subject: [PATCH] Added mention/reply notifications provided by a background service. --- app/src/main/AndroidManifest.xml | 5 + .../com/keylesspalace/tusky/MainActivity.java | 30 +++ .../com/keylesspalace/tusky/Notification.java | 30 +++ .../tusky/NotificationService.java | 228 ++++++++++++++++++ .../tusky/NotificationsFragment.java | 17 +- .../java/com/keylesspalace/tusky/Status.java | 3 + .../main/res/drawable/ic_notify_mention.xml | 11 + app/src/main/res/values/strings.xml | 4 + 8 files changed, 312 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/NotificationService.java create mode 100644 app/src/main/res/drawable/ic_notify_mention.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9dceedbe5..5467b865e 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 fca7dc1eb..6af79ddb2 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 86871cf25..d5fbacf33 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 000000000..5c6189ace --- /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 cfe00b4fa..eb07aedaa 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 d2d9e2c85..6c73c1efb 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 000000000..6ee8c1ceb --- /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 802dc5661..62e1aa812 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 +