Added mention/reply notifications provided by a background service.
This commit is contained in:
parent
b00a3cf443
commit
83f8b4303c
8 changed files with 312 additions and 16 deletions
|
@ -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>
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
|
@ -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");
|
||||||
|
|
||||||
|
|
11
app/src/main/res/drawable/ic_notify_mention.xml
Normal file
11
app/src/main/res/drawable/ic_notify_mention.xml
Normal 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>
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue