diff --git a/app/src/main/java/com/github/gotify/service/WebSocketConnection.java b/app/src/main/java/com/github/gotify/service/WebSocketConnection.java new file mode 100644 index 0000000..d5c802a --- /dev/null +++ b/app/src/main/java/com/github/gotify/service/WebSocketConnection.java @@ -0,0 +1,171 @@ +package com.github.gotify.service; + +import android.os.Handler; + +import com.github.gotify.Utils; +import com.github.gotify.api.Callback; +import com.github.gotify.client.JSON; +import com.github.gotify.client.model.Message; +import com.github.gotify.log.Log; + +import java.util.concurrent.TimeUnit; + +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; + +public class WebSocketConnection { + private static final JSON gson = Utils.json(); + + private final OkHttpClient client = + new OkHttpClient.Builder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .pingInterval(1, TimeUnit.MINUTES) + .connectTimeout(10, TimeUnit.SECONDS) + .build(); + + private final Handler handler = new Handler(); + private int errorCount = 0; + + private final String baseUrl; + private final String token; + private WebSocket webSocket; + private Callback.SuccessCallback onMessage; + private Runnable onClose; + private Runnable onOpen; + private BadRequestRunnable onBadRequest; + private OnFailureCallback onFailure; + private Runnable onReconnected; + + WebSocketConnection(String baseUrl, String token) { + this.baseUrl = baseUrl; + this.token = token; + } + + synchronized WebSocketConnection onMessage(Callback.SuccessCallback onMessage) { + this.onMessage = onMessage; + return this; + } + + synchronized WebSocketConnection onClose(Runnable onClose) { + this.onClose = onClose; + return this; + } + + synchronized WebSocketConnection onOpen(Runnable onOpen) { + this.onOpen = onOpen; + return this; + } + + synchronized WebSocketConnection onBadRequest(BadRequestRunnable onBadRequest) { + this.onBadRequest = onBadRequest; + return this; + } + + synchronized WebSocketConnection onFailure(OnFailureCallback onFailure) { + this.onFailure = onFailure; + return this; + } + + synchronized WebSocketConnection onReconnected(Runnable onReconnected) { + this.onReconnected = onReconnected; + return this; + } + + private Request request() { + HttpUrl url = + HttpUrl.parse(baseUrl) + .newBuilder() + .addPathSegment("stream") + .addQueryParameter("token", token) + .build(); + return new Request.Builder().url(url).get().build(); + } + + public synchronized WebSocketConnection start() { + close(); + Log.i("WebSocket: starting..."); + + webSocket = client.newWebSocket(request(), new Listener()); + return this; + } + + public synchronized void close() { + if (webSocket != null) { + webSocket.close(1000, ""); + webSocket = null; + } + } + + private class Listener extends WebSocketListener { + @Override + public void onOpen(WebSocket webSocket, Response response) { + Log.i("WebSocket: opened"); + synchronized (this) { + onOpen.run(); + + if (errorCount > 0) { + onReconnected.run(); + errorCount = 0; + } + } + super.onOpen(webSocket, response); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + Log.i("WebSocket: received message " + text); + synchronized (this) { + Message message = gson.deserialize(text, Message.class); + onMessage.onSuccess(message); + } + super.onMessage(webSocket, text); + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + Log.w("WebSocket: closed"); + + synchronized (this) { + onClose.run(); + } + super.onClosed(webSocket, code, reason); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + String code = response != null ? "StatusCode: " + response.code() : ""; + String message = response != null ? response.message() : ""; + Log.e("WebSocket: failure " + code + " Message: " + message, t); + synchronized (this) { + if (response != null && response.code() >= 400 && response.code() <= 499) { + onBadRequest.execute(message); + close(); + return; + } + + int minutes = errorCount * 5 + 1; + + Log.i("WebSocket: trying to restart in " + minutes + " minute(s)"); + + errorCount++; + handler.postDelayed( + WebSocketConnection.this::start, TimeUnit.MINUTES.toMillis(minutes)); + onFailure.execute(minutes); + } + + super.onFailure(webSocket, t, response); + } + } + + interface BadRequestRunnable { + void execute(String message); + } + + interface OnFailureCallback { + void execute(int minutesToTryAgain); + } +} diff --git a/app/src/main/java/com/github/gotify/service/WebSocketService.java b/app/src/main/java/com/github/gotify/service/WebSocketService.java new file mode 100644 index 0000000..ee26621 --- /dev/null +++ b/app/src/main/java/com/github/gotify/service/WebSocketService.java @@ -0,0 +1,221 @@ +package com.github.gotify.service; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; + +import com.github.gotify.MissedMessageUtil; +import com.github.gotify.NotificationSupport; +import com.github.gotify.R; +import com.github.gotify.Settings; +import com.github.gotify.Utils; +import com.github.gotify.api.ClientFactory; +import com.github.gotify.client.model.Message; +import com.github.gotify.log.Log; +import com.github.gotify.log.UncaughtExceptionHandler; +import com.github.gotify.messages.MessagesActivity; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; + +public class WebSocketService extends Service { + + public static final String NEW_MESSAGE_BROADCAST = + WebSocketService.class.getName() + ".NEW_MESSAGE"; + + private static final int NOT_LOADED = -2; + + private Settings settings; + private WebSocketConnection connection; + + private AtomicInteger lastReceivedMessage = new AtomicInteger(NOT_LOADED); + private MissedMessageUtil missingMessageUtil; + + @Override + public void onCreate() { + super.onCreate(); + settings = new Settings(this); + missingMessageUtil = + new MissedMessageUtil(ClientFactory.clientToken(settings.url(), settings.token())); + Log.i("Create " + getClass().getSimpleName()); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (connection != null) { + connection.close(); + } + Log.w("Destroy " + getClass().getSimpleName()); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.init(this); + Log.i("Starting " + getClass().getSimpleName()); + super.onStartCommand(intent, flags, startId); + new Thread(this::startPushService).run(); + + return START_STICKY; + } + + private void startPushService() { + UncaughtExceptionHandler.registerCurrentThread(); + foreground(getString(R.string.websocket_init)); + + if (lastReceivedMessage.get() == NOT_LOADED) { + missingMessageUtil.lastReceivedMessage(lastReceivedMessage::set); + } + + connection = + new WebSocketConnection(settings.url(), settings.token()) + .onOpen(this::onOpen) + .onClose(() -> foreground(getString(R.string.websocket_closed))) + .onBadRequest(this::onBadRequest) + .onFailure((min) -> foreground(getString(R.string.websocket_failed, min))) + .onMessage(this::onMessage) + .onReconnected(this::notifyMissedNotifications) + .start(); + } + + private void onBadRequest(String message) { + foreground(getString(R.string.websocket_could_not_connect, message)); + } + + private void onOpen() { + foreground(getString(R.string.websocket_listening, settings.url())); + } + + private void notifyMissedNotifications() { + int messageId = lastReceivedMessage.get(); + if (messageId == NOT_LOADED) { + return; + } + + List messages = missingMessageUtil.missingMessages(messageId); + + if (messages.size() > 5) { + onGroupedMessages(messages); + } else { + for (Message message : messages) { + onMessage(message); + } + } + } + + private void onGroupedMessages(List messages) { + for (Message message : messages) { + if (lastReceivedMessage.get() < message.getId()) { + lastReceivedMessage.set(message.getId()); + } + broadcast(message); + } + int size = messages.size(); + showNotification( + NotificationSupport.ID.GROUPED, + getString(R.string.missed_messages), + getString(R.string.grouped_message, size)); + } + + private void onMessage(Message message) { + if (lastReceivedMessage.get() < message.getId()) { + lastReceivedMessage.set(message.getId()); + } + broadcast(message); + showNotification(message.getId(), message.getTitle(), message.getMessage()); + } + + private void broadcast(Message message) { + Intent intent = new Intent(); + intent.setAction(NEW_MESSAGE_BROADCAST); + intent.putExtra("message", Utils.json().serialize(message)); + sendBroadcast(intent); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void foreground(String message) { + Intent notificationIntent = new Intent(this, MessagesActivity.class); + + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0); + + Notification notification = + new NotificationCompat.Builder(this, NotificationSupport.Channel.FOREGROUND) + .setSmallIcon(R.drawable.ic_gotify) + .setOngoing(true) + .setContentTitle(getString(R.string.app_name)) + .setContentText(message) + .setStyle(new NotificationCompat.BigTextStyle().bigText(message)) + .setContentIntent(pendingIntent) + .build(); + + startForeground(NotificationSupport.ID.FOREGROUND, notification); + } + + private void showNotification(int id, String title, String message) { + Intent intent = new Intent(this, MessagesActivity.class); + PendingIntent contentIntent = + PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + + NotificationCompat.Builder b = + new NotificationCompat.Builder(this, NotificationSupport.Channel.MESSAGES); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + showNotificationGroup(); + } + + b.setAutoCancel(true) + .setDefaults(Notification.DEFAULT_ALL) + .setWhen(System.currentTimeMillis()) + .setSmallIcon(R.drawable.ic_gotify) + .setTicker(getString(R.string.app_name) + " - " + title) + .setGroup(NotificationSupport.Group.MESSAGES) + .setContentTitle(title) + .setContentText(message) + .setStyle(new NotificationCompat.BigTextStyle().bigText(message)) + .setDefaults(Notification.DEFAULT_LIGHTS | Notification.DEFAULT_SOUND) + .setContentIntent(contentIntent); + + NotificationManager notificationManager = + (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(id, b.build()); + } + + @RequiresApi(Build.VERSION_CODES.N) + public void showNotificationGroup() { + Intent intent = new Intent(this, MessagesActivity.class); + PendingIntent contentIntent = + PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + + NotificationCompat.Builder b = + new NotificationCompat.Builder(this, NotificationSupport.Channel.MESSAGES); + + b.setAutoCancel(true) + .setDefaults(Notification.DEFAULT_ALL) + .setWhen(System.currentTimeMillis()) + .setSmallIcon(R.drawable.ic_gotify) + .setTicker(getString(R.string.app_name)) + .setGroup(NotificationSupport.Group.MESSAGES) + .setContentTitle(getString(R.string.grouped_notification_text)) + .setGroupSummary(true) + .setContentText(getString(R.string.grouped_notification_text)) + .setContentIntent(contentIntent); + + NotificationManager notificationManager = + (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(-5, b.build()); + } +}