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