diff --git a/app/build.gradle b/app/build.gradle
index cf42c9791..5ef9205d3 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -63,6 +63,10 @@ dependencies {
googleCompile 'com.google.firebase:firebase-crash:10.2.4'
testCompile 'junit:junit:4.12'
annotationProcessor 'com.jakewharton:butterknife-compiler:8.5.1'
+ compile 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.1.0'
+ compile('org.eclipse.paho:org.eclipse.paho.android.service:1.1.1') {
+ exclude module: 'support-v4'
+ }
}
apply plugin: 'com.google.gms.google-services'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 058d165b3..1fd924b55 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,6 +7,9 @@
+
+
+
+
() {
- @Override
- public void onResponse(Call call, retrofit2.Response response) {
- Log.d(TAG, "Enable push notifications response: " + response.message());
- }
-
- @Override
- public void onFailure(Call call, Throwable t) {
- Log.d(TAG, "Enable push notifications failed: " + t.getMessage());
- }
- });
+ pushNotificationClient.subscribeToTopic();
} else {
// Start up the MessagingService on a repeating interval for "pull" notifications.
long checkInterval = 60 * 1000 * 5;
@@ -231,17 +210,7 @@ public class BaseActivity extends AppCompatActivity {
protected void disablePushNotifications() {
if (BuildConfig.USES_PUSH_NOTIFICATIONS) {
- tuskyAPI.unregister(getBaseUrl(), getAccessToken()).enqueue(new Callback() {
- @Override
- public void onResponse(Call call, retrofit2.Response response) {
- Log.d(TAG, "Disable push notifications response: " + response.message());
- }
-
- @Override
- public void onFailure(Call call, Throwable t) {
- Log.d(TAG, "Disable push notifications failed: " + t.getMessage());
- }
- });
+ pushNotificationClient.unsubscribeToTopic();
} else if (serviceAlarmIntent != null) {
// Cancel the repeating call for "pull" notifications.
AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java
index f0089fd2f..319257b44 100644
--- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java
+++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java
@@ -307,7 +307,9 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag
if (replyVisibility != null && startingVisibility != null) {
// Lowest possible visibility setting in response
- if (startingVisibility.equals("private") || replyVisibility.equals("private")) {
+ if (startingVisibility.equals("direct") || replyVisibility.equals("direct")) {
+ startingVisibility = "direct";
+ } else if (startingVisibility.equals("private") || replyVisibility.equals("private")) {
startingVisibility = "private";
} else if (startingVisibility.equals("unlisted") || replyVisibility.equals("unlisted")) {
startingVisibility = "unlisted";
diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java
index bef04290d..234ece7fc 100644
--- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java
+++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java
@@ -233,6 +233,9 @@ public class NotificationsFragment extends SFragment implements
} else {
adapter.update(notifications);
}
+ for (Notification notification : notifications) {
+ Log.d(TAG, "id: " + notification.id);
+ }
if (notifications.size() == 0 && adapter.getItemCount() == 1) {
adapter.setFooterState(NotificationsAdapter.FooterState.EMPTY);
} else if (fromId != null) {
diff --git a/app/src/main/java/com/keylesspalace/tusky/service/PushNotificationService.java b/app/src/main/java/com/keylesspalace/tusky/service/PushNotificationService.java
new file mode 100644
index 000000000..7db3e1241
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/service/PushNotificationService.java
@@ -0,0 +1,257 @@
+package com.keylesspalace.tusky.service;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Binder;
+import android.os.IBinder;
+import android.preference.PreferenceManager;
+import android.support.annotation.Nullable;
+import android.text.Spanned;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.keylesspalace.tusky.R;
+import com.keylesspalace.tusky.entity.Notification;
+import com.keylesspalace.tusky.json.SpannedTypeAdapter;
+import com.keylesspalace.tusky.json.StringWithEmoji;
+import com.keylesspalace.tusky.json.StringWithEmojiTypeAdapter;
+import com.keylesspalace.tusky.network.MastodonAPI;
+import com.keylesspalace.tusky.util.Log;
+import com.keylesspalace.tusky.util.NotificationMaker;
+import com.keylesspalace.tusky.util.OkHttpUtils;
+
+import org.eclipse.paho.android.service.MqttAndroidClient;
+import org.eclipse.paho.client.mqttv3.DisconnectedBufferOptions;
+import org.eclipse.paho.client.mqttv3.IMqttActionListener;
+import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
+import org.eclipse.paho.client.mqttv3.IMqttToken;
+import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.MqttMessage;
+
+import java.io.IOException;
+import java.util.Locale;
+import java.util.UUID;
+
+import okhttp3.Interceptor;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+import retrofit2.Retrofit;
+import retrofit2.converter.gson.GsonConverterFactory;
+
+public class PushNotificationService extends Service {
+ private class LocalBinder extends Binder {
+ PushNotificationService getService() {
+ return PushNotificationService.this;
+ }
+ }
+
+ private static final String TAG = "PushNotificationService";
+ private static final String CLIENT_NAME = "TuskyMastodonClient";
+ private static final String TOPIC = "tusky/notification";
+ private static final int NOTIFY_ID = 666;
+
+ private final IBinder binder = new LocalBinder();
+ private MqttAndroidClient mqttAndroidClient;
+ private MastodonAPI mastodonApi;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+
+ // Create the MQTT client.
+ String clientId = String.format(Locale.getDefault(), "%s/%s/%s", CLIENT_NAME,
+ System.currentTimeMillis(), UUID.randomUUID().toString());
+ String serverUri = getString(R.string.tusky_api_url);
+ mqttAndroidClient = new MqttAndroidClient(this, serverUri, clientId);
+ mqttAndroidClient.setCallback(new MqttCallbackExtended() {
+ @Override
+ public void connectComplete(boolean reconnect, String serverURI) {
+ if (reconnect) {
+ subscribeToTopic();
+ }
+ }
+
+ @Override
+ public void connectionLost(Throwable cause) {
+ onConnectionLost();
+ }
+
+ @Override
+ public void messageArrived(String topic, MqttMessage message) throws Exception {
+ onMessageReceived(new String(message.getPayload()));
+ }
+
+ @Override
+ public void deliveryComplete(IMqttDeliveryToken token) {
+ // This client is read-only, so this is unused.
+ }
+ });
+
+ // Open the MQTT connection.
+ MqttConnectOptions options = new MqttConnectOptions();
+ options.setAutomaticReconnect(true);
+ options.setCleanSession(false);
+ try {
+ mqttAndroidClient.connect(options, null, new IMqttActionListener() {
+ @Override
+ public void onSuccess(IMqttToken asyncActionToken) {
+ DisconnectedBufferOptions options = new DisconnectedBufferOptions();
+ options.setBufferEnabled(true);
+ options.setBufferSize(100);
+ options.setPersistBuffer(false);
+ options.setDeleteOldestMessages(false);
+ mqttAndroidClient.setBufferOpts(options);
+ onConnectionSuccess();
+ subscribeToTopic();
+ }
+
+ @Override
+ public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
+ onConnectionFailure();
+ }
+ });
+ } catch (MqttException e) {
+ Log.e(TAG, "An exception occurred while connecting. " + e.getMessage());
+ onConnectionFailure();
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ disconnect();
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ return binder;
+ }
+
+ /** Subscribe to the push notification topic. */
+ public void subscribeToTopic() {
+ try {
+ mqttAndroidClient.subscribe(TOPIC, 0, null, new IMqttActionListener() {
+ @Override
+ public void onSuccess(IMqttToken asyncActionToken) {
+ onConnectionSuccess();
+ }
+
+ @Override
+ public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
+ onConnectionFailure();
+ }
+ });
+ } catch (MqttException e) {
+ Log.e(TAG, "An exception occurred while subscribing." + e.getMessage());
+ onConnectionFailure();
+ }
+ }
+
+ /** Unsubscribe from the push notification topic. */
+ public void unsubscribeToTopic() {
+ try {
+ mqttAndroidClient.unsubscribe(TOPIC);
+ } catch (MqttException e) {
+ Log.e(TAG, "An exception occurred while unsubscribing." + e.getMessage());
+ onConnectionFailure();
+ }
+ }
+
+ private void onConnectionSuccess() {
+
+ }
+
+ private void onConnectionFailure() {
+
+ }
+
+ private void onConnectionLost() {
+
+ }
+
+ private void onMessageReceived(String message) {
+ String notificationId = message; // TODO: finalize the form the messages will be received
+
+ Log.d(TAG, "Notification received: " + notificationId);
+
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(
+ getApplicationContext());
+ boolean enabled = preferences.getBoolean("notificationsEnabled", true);
+ if (!enabled) {
+ return;
+ }
+
+ createMastodonAPI();
+
+ mastodonApi.notification(notificationId).enqueue(new Callback() {
+ @Override
+ public void onResponse(Call call, Response response) {
+ if (response.isSuccessful()) {
+ NotificationMaker.make(PushNotificationService.this, NOTIFY_ID,
+ response.body());
+ }
+ }
+
+ @Override
+ public void onFailure(Call call, Throwable t) {}
+ });
+ }
+
+ /** Disconnect from the MQTT broker. */
+ public void disconnect() {
+ try {
+ mqttAndroidClient.disconnect();
+ } catch (MqttException ex) {
+ Log.e(TAG, "An exception occurred while disconnecting.");
+ onDisconnectFailed();
+ }
+ }
+
+ private void onDisconnectFailed() {
+
+ }
+
+ private void createMastodonAPI() {
+ SharedPreferences preferences = getSharedPreferences(
+ getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
+ final String domain = preferences.getString("domain", null);
+ final String accessToken = preferences.getString("accessToken", null);
+
+ OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder()
+ .addInterceptor(new Interceptor() {
+ @Override
+ public okhttp3.Response intercept(Chain chain) throws IOException {
+ Request originalRequest = chain.request();
+
+ Request.Builder builder = originalRequest.newBuilder()
+ .header("Authorization", String.format("Bearer %s", accessToken));
+
+ Request newRequest = builder.build();
+
+ return chain.proceed(newRequest);
+ }
+ })
+ .build();
+
+ Gson gson = new GsonBuilder()
+ .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter())
+ .registerTypeAdapter(StringWithEmoji.class, new StringWithEmojiTypeAdapter())
+ .create();
+
+ Retrofit retrofit = new Retrofit.Builder()
+ .baseUrl("https://" + domain)
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create(gson))
+ .build();
+
+ mastodonApi = retrofit.create(MastodonAPI.class);
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PushNotificationClient.java b/app/src/main/java/com/keylesspalace/tusky/util/PushNotificationClient.java
new file mode 100644
index 000000000..ba8f34500
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/util/PushNotificationClient.java
@@ -0,0 +1,256 @@
+package com.keylesspalace.tusky.util;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.support.annotation.NonNull;
+import android.text.Spanned;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.keylesspalace.tusky.R;
+import com.keylesspalace.tusky.entity.Notification;
+import com.keylesspalace.tusky.json.SpannedTypeAdapter;
+import com.keylesspalace.tusky.json.StringWithEmoji;
+import com.keylesspalace.tusky.json.StringWithEmojiTypeAdapter;
+import com.keylesspalace.tusky.network.MastodonAPI;
+
+import org.eclipse.paho.android.service.MqttAndroidClient;
+import org.eclipse.paho.client.mqttv3.DisconnectedBufferOptions;
+import org.eclipse.paho.client.mqttv3.IMqttActionListener;
+import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
+import org.eclipse.paho.client.mqttv3.IMqttToken;
+import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
+import org.eclipse.paho.client.mqttv3.MqttClient;
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.MqttMessage;
+
+import java.io.IOException;
+import java.util.ArrayDeque;
+
+import okhttp3.Interceptor;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+import retrofit2.Retrofit;
+import retrofit2.converter.gson.GsonConverterFactory;
+
+public class PushNotificationClient {
+ private static final String TAG = "PushNotificationClient";
+ private static final String TOPIC = "tusky/notification";
+ private static final int NOTIFY_ID = 666;
+
+ private enum QueuedAction {
+ SUBSCRIBE,
+ UNSUBSCRIBE,
+ DISCONNECT,
+ }
+
+ private MqttAndroidClient mqttAndroidClient;
+ private MastodonAPI mastodonApi;
+ private boolean connected;
+ private ArrayDeque queuedActions;
+
+ public PushNotificationClient(final @NonNull Context context, @NonNull String serverUri) {
+ queuedActions = new ArrayDeque<>();
+
+ // Create the MQTT client.
+ String clientId = MqttClient.generateClientId();
+ mqttAndroidClient = new MqttAndroidClient(context, serverUri, clientId);
+ mqttAndroidClient.setCallback(new MqttCallbackExtended() {
+ @Override
+ public void connectComplete(boolean reconnect, String serverURI) {
+ if (reconnect) {
+ flushQueuedActions();
+ }
+ }
+
+ @Override
+ public void connectionLost(Throwable cause) {
+ onConnectionLost();
+ }
+
+ @Override
+ public void messageArrived(String topic, MqttMessage message) throws Exception {
+ onMessageReceived(context, new String(message.getPayload()));
+ }
+
+ @Override
+ public void deliveryComplete(IMqttDeliveryToken token) {
+ // This client is read-only, so this is unused.
+ }
+ });
+
+ // Open the MQTT connection.
+ MqttConnectOptions options = new MqttConnectOptions();
+ options.setAutomaticReconnect(true);
+ options.setCleanSession(false);
+ try {
+ /* TLS connection stuffs
+ InputStream input = context.getResources().openRawResource(R.raw.keystore_tusky_api);
+ String password = context.getString(R.string.tusky_api_keystore_password);
+ options.setSocketFactory(mqttAndroidClient.getSSLSocketFactory(input, password));
+ */
+ mqttAndroidClient.connect(options).setActionCallback(new IMqttActionListener() {
+ @Override
+ public void onSuccess(IMqttToken asyncActionToken) {
+ DisconnectedBufferOptions options = new DisconnectedBufferOptions();
+ options.setBufferEnabled(true);
+ options.setBufferSize(100);
+ options.setPersistBuffer(false);
+ options.setDeleteOldestMessages(false);
+ mqttAndroidClient.setBufferOpts(options);
+ onConnectionSuccess();
+ connected = true;
+ flushQueuedActions();
+ }
+
+ @Override
+ public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
+ Log.e(TAG, "An exception occurred while connecting. " + exception.getMessage());
+ onConnectionFailure();
+ }
+ });
+ } catch (MqttException e) {
+ Log.e(TAG, "An exception occurred while connecting. " + e.getMessage());
+ onConnectionFailure();
+ }
+ }
+
+ private void flushQueuedActions() {
+ for (QueuedAction action : queuedActions) {
+ switch (action) {
+ case SUBSCRIBE: subscribeToTopic(); break;
+ case UNSUBSCRIBE: unsubscribeToTopic(); break;
+ case DISCONNECT: disconnect(); break;
+ }
+ }
+ }
+
+ /** Disconnect from the MQTT broker. */
+ public void disconnect() {
+ if (!connected) {
+ queuedActions.add(QueuedAction.DISCONNECT);
+ return;
+ }
+ try {
+ mqttAndroidClient.disconnect();
+ } catch (MqttException ex) {
+ Log.e(TAG, "An exception occurred while disconnecting.");
+ onDisconnectFailed();
+ }
+ }
+
+ private void onDisconnectFailed() {
+ Log.v(TAG, "Failed while disconnecting from the broker.");
+ }
+
+ /** Subscribe to the push notification topic. */
+ public void subscribeToTopic() {
+ if (!connected) {
+ queuedActions.add(QueuedAction.SUBSCRIBE);
+ return;
+ }
+ try {
+ mqttAndroidClient.subscribe(TOPIC, 0, null, new IMqttActionListener() {
+ @Override
+ public void onSuccess(IMqttToken asyncActionToken) {
+ onConnectionSuccess();
+ }
+
+ @Override
+ public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
+ Log.e(TAG, "An exception occurred while subscribing." + exception.getMessage());
+ onConnectionFailure();
+ }
+ });
+ } catch (MqttException e) {
+ Log.e(TAG, "An exception occurred while subscribing." + e.getMessage());
+ onConnectionFailure();
+ }
+ }
+
+ /** Unsubscribe from the push notification topic. */
+ public void unsubscribeToTopic() {
+ if (!connected) {
+ queuedActions.add(QueuedAction.UNSUBSCRIBE);
+ return;
+ }
+ try {
+ mqttAndroidClient.unsubscribe(TOPIC);
+ } catch (MqttException e) {
+ Log.e(TAG, "An exception occurred while unsubscribing." + e.getMessage());
+ onConnectionFailure();
+ }
+ }
+
+ private void onConnectionSuccess() {
+ Log.v(TAG, "The connection succeeded.");
+ }
+
+ private void onConnectionFailure() {
+ Log.v(TAG, "The connection failed.");
+ }
+
+ private void onConnectionLost() {
+ Log.v(TAG, "The connection was lost.");
+ }
+
+ private void onMessageReceived(final Context context, String message) {
+ String notificationId = message; // TODO: finalize the form the messages will be received
+
+ Log.v(TAG, "Notification received: " + notificationId);
+
+ createMastodonAPI(context);
+
+ mastodonApi.notification(notificationId).enqueue(new Callback() {
+ @Override
+ public void onResponse(Call call, Response response) {
+ if (response.isSuccessful()) {
+ NotificationMaker.make(context, NOTIFY_ID, response.body());
+ }
+ }
+
+ @Override
+ public void onFailure(Call call, Throwable t) {}
+ });
+ }
+
+ private void createMastodonAPI(Context context) {
+ SharedPreferences preferences = context.getSharedPreferences(
+ context.getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
+ final String domain = preferences.getString("domain", null);
+ final String accessToken = preferences.getString("accessToken", null);
+
+ OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder()
+ .addInterceptor(new Interceptor() {
+ @Override
+ public okhttp3.Response intercept(Chain chain) throws IOException {
+ Request originalRequest = chain.request();
+
+ Request.Builder builder = originalRequest.newBuilder()
+ .header("Authorization", String.format("Bearer %s", accessToken));
+
+ Request newRequest = builder.build();
+
+ return chain.proceed(newRequest);
+ }
+ })
+ .build();
+
+ Gson gson = new GsonBuilder()
+ .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter())
+ .registerTypeAdapter(StringWithEmoji.class, new StringWithEmojiTypeAdapter())
+ .create();
+
+ Retrofit retrofit = new Retrofit.Builder()
+ .baseUrl("https://" + domain)
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create(gson))
+ .build();
+
+ mastodonApi = retrofit.create(MastodonAPI.class);
+ }
+}
diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index b2a8e3b6b..73a4b542e 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -3,6 +3,7 @@
Tusky
https://tusky.keylesspalace.com
https://tuskynotifier.keylesspalace.com
+ your_password_here
oauth2redirect
com.keylesspalace.tusky