Initial client working for MQTT push notifications.

This commit is contained in:
Vavassor 2017-05-16 22:19:34 -04:00
parent 1815d574c8
commit 6752d45d4b
8 changed files with 534 additions and 38 deletions

View file

@ -63,6 +63,10 @@ dependencies {
googleCompile 'com.google.firebase:firebase-crash:10.2.4' googleCompile 'com.google.firebase:firebase-crash:10.2.4'
testCompile 'junit:junit:4.12' testCompile 'junit:junit:4.12'
annotationProcessor 'com.jakewharton:butterknife-compiler:8.5.1' 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' apply plugin: 'com.google.gms.google-services'

View file

@ -7,6 +7,9 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@ -83,6 +86,7 @@
<receiver android:name=".util.NotificationClearBroadcastReceiver" /> <receiver android:name=".util.NotificationClearBroadcastReceiver" />
<service android:name="org.eclipse.paho.android.service.MqttService" />
<service <service
tools:targetApi="24" tools:targetApi="24"
android:name="com.keylesspalace.tusky.service.TuskyTileService" android:name="com.keylesspalace.tusky.service.TuskyTileService"

View file

@ -38,9 +38,8 @@ import com.keylesspalace.tusky.json.SpannedTypeAdapter;
import com.keylesspalace.tusky.json.StringWithEmoji; import com.keylesspalace.tusky.json.StringWithEmoji;
import com.keylesspalace.tusky.json.StringWithEmojiTypeAdapter; import com.keylesspalace.tusky.json.StringWithEmojiTypeAdapter;
import com.keylesspalace.tusky.network.MastodonAPI; import com.keylesspalace.tusky.network.MastodonAPI;
import com.keylesspalace.tusky.network.TuskyAPI;
import com.keylesspalace.tusky.util.Log;
import com.keylesspalace.tusky.util.OkHttpUtils; import com.keylesspalace.tusky.util.OkHttpUtils;
import com.keylesspalace.tusky.util.PushNotificationClient;
import java.io.IOException; import java.io.IOException;
@ -49,17 +48,12 @@ import okhttp3.Interceptor;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
import okhttp3.Request; import okhttp3.Request;
import okhttp3.Response; import okhttp3.Response;
import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Retrofit; import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory; import retrofit2.converter.gson.GsonConverterFactory;
public class BaseActivity extends AppCompatActivity { public class BaseActivity extends AppCompatActivity {
private static final String TAG = "BaseActivity"; // logging tag
public MastodonAPI mastodonAPI; public MastodonAPI mastodonAPI;
protected TuskyAPI tuskyAPI; protected PushNotificationClient pushNotificationClient;
protected Dispatcher mastodonApiDispatcher; protected Dispatcher mastodonApiDispatcher;
protected PendingIntent serviceAlarmIntent; protected PendingIntent serviceAlarmIntent;
@ -163,12 +157,8 @@ public class BaseActivity extends AppCompatActivity {
protected void createTuskyAPI() { protected void createTuskyAPI() {
if (BuildConfig.USES_PUSH_NOTIFICATIONS) { if (BuildConfig.USES_PUSH_NOTIFICATIONS) {
Retrofit retrofit = new Retrofit.Builder() // TODO: Remove this test broker address.
.baseUrl(getString(R.string.tusky_api_url)) pushNotificationClient = new PushNotificationClient(this, "tcp://104.236.116.199:1883");
.client(OkHttpUtils.getCompatibleClient())
.build();
tuskyAPI = retrofit.create(TuskyAPI.class);
} }
} }
@ -204,18 +194,7 @@ public class BaseActivity extends AppCompatActivity {
protected void enablePushNotifications() { protected void enablePushNotifications() {
if (BuildConfig.USES_PUSH_NOTIFICATIONS) { if (BuildConfig.USES_PUSH_NOTIFICATIONS) {
String token = MessagingService.getInstanceToken(); pushNotificationClient.subscribeToTopic();
tuskyAPI.register(getBaseUrl(), getAccessToken(), token).enqueue(new Callback<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, retrofit2.Response<ResponseBody> response) {
Log.d(TAG, "Enable push notifications response: " + response.message());
}
@Override
public void onFailure(Call<ResponseBody> call, Throwable t) {
Log.d(TAG, "Enable push notifications failed: " + t.getMessage());
}
});
} else { } else {
// Start up the MessagingService on a repeating interval for "pull" notifications. // Start up the MessagingService on a repeating interval for "pull" notifications.
long checkInterval = 60 * 1000 * 5; long checkInterval = 60 * 1000 * 5;
@ -231,17 +210,7 @@ public class BaseActivity extends AppCompatActivity {
protected void disablePushNotifications() { protected void disablePushNotifications() {
if (BuildConfig.USES_PUSH_NOTIFICATIONS) { if (BuildConfig.USES_PUSH_NOTIFICATIONS) {
tuskyAPI.unregister(getBaseUrl(), getAccessToken()).enqueue(new Callback<ResponseBody>() { pushNotificationClient.unsubscribeToTopic();
@Override
public void onResponse(Call<ResponseBody> call, retrofit2.Response<ResponseBody> response) {
Log.d(TAG, "Disable push notifications response: " + response.message());
}
@Override
public void onFailure(Call<ResponseBody> call, Throwable t) {
Log.d(TAG, "Disable push notifications failed: " + t.getMessage());
}
});
} else if (serviceAlarmIntent != null) { } else if (serviceAlarmIntent != null) {
// Cancel the repeating call for "pull" notifications. // Cancel the repeating call for "pull" notifications.
AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);

View file

@ -307,7 +307,9 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag
if (replyVisibility != null && startingVisibility != null) { if (replyVisibility != null && startingVisibility != null) {
// Lowest possible visibility setting in response // 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"; startingVisibility = "private";
} else if (startingVisibility.equals("unlisted") || replyVisibility.equals("unlisted")) { } else if (startingVisibility.equals("unlisted") || replyVisibility.equals("unlisted")) {
startingVisibility = "unlisted"; startingVisibility = "unlisted";

View file

@ -233,6 +233,9 @@ public class NotificationsFragment extends SFragment implements
} else { } else {
adapter.update(notifications); adapter.update(notifications);
} }
for (Notification notification : notifications) {
Log.d(TAG, "id: " + notification.id);
}
if (notifications.size() == 0 && adapter.getItemCount() == 1) { if (notifications.size() == 0 && adapter.getItemCount() == 1) {
adapter.setFooterState(NotificationsAdapter.FooterState.EMPTY); adapter.setFooterState(NotificationsAdapter.FooterState.EMPTY);
} else if (fromId != null) { } else if (fromId != null) {

View file

@ -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<Notification>() {
@Override
public void onResponse(Call<Notification> call, Response<Notification> response) {
if (response.isSuccessful()) {
NotificationMaker.make(PushNotificationService.this, NOTIFY_ID,
response.body());
}
}
@Override
public void onFailure(Call<Notification> 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);
}
}

View file

@ -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<QueuedAction> 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<Notification>() {
@Override
public void onResponse(Call<Notification> call, Response<Notification> response) {
if (response.isSuccessful()) {
NotificationMaker.make(context, NOTIFY_ID, response.body());
}
}
@Override
public void onFailure(Call<Notification> 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);
}
}

View file

@ -3,6 +3,7 @@
<string name="app_name" translatable="false">Tusky</string> <string name="app_name" translatable="false">Tusky</string>
<string name="app_website" translatable="false">https://tusky.keylesspalace.com</string> <string name="app_website" translatable="false">https://tusky.keylesspalace.com</string>
<string name="tusky_api_url" translatable="false">https://tuskynotifier.keylesspalace.com</string> <string name="tusky_api_url" translatable="false">https://tuskynotifier.keylesspalace.com</string>
<string name="tusky_api_keystore_password" translatable="false">your_password_here</string>
<string name="oauth_scheme" translatable="false">oauth2redirect</string> <string name="oauth_scheme" translatable="false">oauth2redirect</string>
<string name="oauth_redirect_host" translatable="false">com.keylesspalace.tusky</string> <string name="oauth_redirect_host" translatable="false">com.keylesspalace.tusky</string>