Added mention/reply notifications provided by a background service.

This commit is contained in:
Vavassor 2017-01-24 23:35:54 -05:00
parent b00a3cf443
commit 83f8b4303c
8 changed files with 312 additions and 16 deletions

View file

@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.VIBRATE"/>
<application <application
android:allowBackup="true" android:allowBackup="true"
@ -33,6 +34,10 @@
android:windowSoftInputMode="stateVisible|adjustResize" /> android:windowSoftInputMode="stateVisible|adjustResize" />
<activity android:name=".ViewVideoActivity" /> <activity android:name=".ViewVideoActivity" />
<activity android:name=".ViewThreadActivity" /> <activity android:name=".ViewThreadActivity" />
<service
android:name=".NotificationService"
android:description="@string/notification_service_description"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View file

@ -15,9 +15,12 @@
package com.keylesspalace.tusky; package com.keylesspalace.tusky;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.SystemClock;
import android.support.design.widget.TabLayout; import android.support.design.widget.TabLayout;
import android.support.v4.view.ViewPager; import android.support.v4.view.ViewPager;
import android.support.v7.app.AppCompatActivity; import android.support.v7.app.AppCompatActivity;
@ -27,6 +30,10 @@ import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
private AlarmManager alarmManager;
private PendingIntent serviceAlarmIntent;
private boolean notificationServiceEnabled;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@ -35,6 +42,7 @@ public class MainActivity extends AppCompatActivity {
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar); setSupportActionBar(toolbar);
// Setup the tabs and timeline pager.
TimelinePagerAdapter adapter = new TimelinePagerAdapter(getSupportFragmentManager()); TimelinePagerAdapter adapter = new TimelinePagerAdapter(getSupportFragmentManager());
String[] pageTitles = { String[] pageTitles = {
getString(R.string.title_home), getString(R.string.title_home),
@ -46,6 +54,25 @@ public class MainActivity extends AppCompatActivity {
viewPager.setAdapter(adapter); viewPager.setAdapter(adapter);
TabLayout tabLayout = (TabLayout) findViewById(R.id.tab_layout); TabLayout tabLayout = (TabLayout) findViewById(R.id.tab_layout);
tabLayout.setupWithViewPager(viewPager); 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() { private void compose() {
@ -54,6 +81,9 @@ public class MainActivity extends AppCompatActivity {
} }
private void logOut() { private void logOut() {
if (notificationServiceEnabled) {
alarmManager.cancel(serviceAlarmIntent);
}
SharedPreferences preferences = getSharedPreferences( SharedPreferences preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE); getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit(); SharedPreferences.Editor editor = preferences.edit();

View file

@ -17,6 +17,13 @@ package com.keylesspalace.tusky;
import android.support.annotation.Nullable; 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 class Notification {
public enum Type { public enum Type {
MENTION, MENTION,
@ -61,4 +68,27 @@ public class Notification {
|| type == Type.FAVOURITE || type == Type.FAVOURITE
|| type == Type.REBLOG; || type == Type.REBLOG;
} }
public static List<Notification> parse(JSONArray array) throws JSONException {
List<Notification> 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;
}
} }

View file

@ -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
* <http://www.gnu.org/licenses/>. */
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<JSONArray>() {
@Override
public void onResponse(JSONArray response) {
List<Notification> 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<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
}
};
VolleySingleton.getInstance(this).addToRequestQueue(request);
}
private void onCheckNotificationsSuccess(List<Notification> notifications, Date lastUpdate) {
Date newest = null;
List<MentionResult> 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<MentionResult> mentions, String url) {
ImageRequest request = new ImageRequest(url, new Response.Listener<Bitmap>() {
@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<MentionResult> 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);
}
}

View file

@ -106,23 +106,8 @@ public class NotificationsFragment extends SFragment implements
new Response.Listener<JSONArray>() { new Response.Listener<JSONArray>() {
@Override @Override
public void onResponse(JSONArray response) { public void onResponse(JSONArray response) {
List<Notification> notifications = new ArrayList<>();
try { try {
for (int i = 0; i < response.length(); i++) { List<Notification> notifications = Notification.parse(response);
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);
}
onFetchNotificationsSuccess(notifications, fromId != null); onFetchNotificationsSuccess(notifications, fromId != null);
} catch (JSONException e) { } catch (JSONException e) {
onFetchNotificationsFailure(); onFetchNotificationsFailure();

View file

@ -214,6 +214,9 @@ public class Status {
JSONObject account = object.getJSONObject("account"); JSONObject account = object.getJSONObject("account");
String accountId = account.getString("id"); String accountId = account.getString("id");
String displayName = account.getString("display_name"); String displayName = account.getString("display_name");
if (displayName.isEmpty()) {
displayName = account.getString("username");
}
String username = account.getString("acct"); String username = account.getString("acct");
String avatar = account.getString("avatar"); String avatar = account.getString("avatar");

View file

@ -0,0 +1,11 @@
<vector android:height="24dp" android:viewportHeight="637.7953"
android:viewportWidth="637.7953" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="1" android:fillColor="#ffffff"
android:pathData="M638.4,462C518.5,688.5 338.4,648.4 266,598.8 159,525.6 124,422.3 242.9,288.3 455.8,48.3 302.5,13.2 302.5,13.2c0,0 182.3,30.4 27.3,256.1 -80.8,117.6 -110.4,189.8 -8,253.1 110,56.5 231,-61.9 257,-103z"
android:strokeAlpha="1" android:strokeColor="#00000000"
android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeWidth="1"/>
<path android:fillAlpha="1" android:fillColor="#ffffff"
android:pathData="m263.5,4.2c-41,12.5 -74,22 -103,59.8C60.4,194.3 29.6,305.7 128.9,407.1 251.6,489.6 425.3,370.6 425.3,370.6l38.5,66.6c0,0 -194.3,142.4 -338,52.1C79.6,460.2 -85.6,403.7 64.9,121.1 122.5,12.7 176.6,-10.9 263.5,4.2Z"
android:strokeAlpha="1" android:strokeColor="#00000000"
android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeWidth="1"/>
</vector>

View file

@ -86,4 +86,8 @@
<string name="visibility_private">Private</string> <string name="visibility_private">Private</string>
<string name="visibility_unlisted">Unlisted</string> <string name="visibility_unlisted">Unlisted</string>
<string name="notification_service_description">Allows Tusky to check for Mastodon notifications.</string>
<string name="notification_service_several_mentions">%d new mentions</string>
<string name="notification_service_one_mention">Mention from %s</string>
</resources> </resources>