Rewrite 'service' to Kotlin

This commit is contained in:
Niko Diamadis
2022-11-02 16:46:17 +01:00
parent 637e8802a4
commit 47bee618b4
5 changed files with 617 additions and 645 deletions

View File

@@ -330,7 +330,7 @@ public class MessagesActivity extends AppCompatActivity
nManager.cancelAll();
IntentFilter filter = new IntentFilter();
filter.addAction(WebSocketService.NEW_MESSAGE_BROADCAST);
filter.addAction(WebSocketService.Companion.getNEW_MESSAGE_BROADCAST());
registerReceiver(receiver, filter);
new UpdateMissedMessages().execute(viewModel.getMessages().getLastReceivedMessage());

View File

@@ -1,255 +0,0 @@
package com.github.gotify.service;
import android.app.AlarmManager;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.Build;
import android.os.Handler;
import com.github.gotify.SSLSettings;
import com.github.gotify.Utils;
import com.github.gotify.api.Callback;
import com.github.gotify.api.CertUtils;
import com.github.gotify.client.model.Message;
import com.github.gotify.log.Log;
import java.util.Calendar;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
class WebSocketConnection {
private static final AtomicLong ID = new AtomicLong(0);
private final ConnectivityManager connectivityManager;
private final AlarmManager alarmManager;
private OkHttpClient client;
private final Handler reconnectHandler = new Handler();
private Runnable reconnectCallback = this::start;
private int errorCount = 0;
private final String baseUrl;
private final String token;
private WebSocket webSocket;
private Callback.SuccessCallback<Message> onMessage;
private Runnable onClose;
private Runnable onOpen;
private BadRequestRunnable onBadRequest;
private OnNetworkFailureRunnable onNetworkFailure;
private Runnable onReconnected;
private State state;
WebSocketConnection(
String baseUrl,
SSLSettings settings,
String token,
ConnectivityManager connectivityManager,
AlarmManager alarmManager) {
this.connectivityManager = connectivityManager;
this.alarmManager = alarmManager;
OkHttpClient.Builder builder =
new OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(1, TimeUnit.MINUTES)
.connectTimeout(10, TimeUnit.SECONDS);
CertUtils.applySslSettings(builder, settings);
client = builder.build();
this.baseUrl = baseUrl;
this.token = token;
}
synchronized WebSocketConnection onMessage(Callback.SuccessCallback<Message> 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 onNetworkFailure(OnNetworkFailureRunnable onNetworkFailure) {
this.onNetworkFailure = onNetworkFailure;
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() {
if (state == State.Connecting || state == State.Connected) {
return this;
}
close();
state = State.Connecting;
long nextId = ID.incrementAndGet();
Log.i("WebSocket(" + nextId + "): starting...");
webSocket = client.newWebSocket(request(), new Listener(nextId));
return this;
}
public synchronized void close() {
if (webSocket != null) {
Log.i("WebSocket(" + ID.get() + "): closing existing connection.");
state = State.Disconnected;
webSocket.close(1000, "");
webSocket = null;
}
}
public synchronized void scheduleReconnect(long seconds) {
if (state == State.Connecting || state == State.Connected) {
return;
}
state = State.Scheduled;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Log.i(
"WebSocket: scheduling a restart in "
+ seconds
+ " second(s) (via alarm manager)");
final Calendar future = Calendar.getInstance();
future.add(Calendar.SECOND, (int) seconds);
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
future.getTimeInMillis(),
"reconnect-tag",
this::start,
null);
} else {
Log.i("WebSocket: scheduling a restart in " + seconds + " second(s)");
reconnectHandler.removeCallbacks(reconnectCallback);
reconnectHandler.postDelayed(reconnectCallback, TimeUnit.SECONDS.toMillis(seconds));
}
}
private class Listener extends WebSocketListener {
private final long id;
public Listener(long id) {
this.id = id;
}
@Override
public void onOpen(WebSocket webSocket, Response response) {
syncExec(
() -> {
state = State.Connected;
Log.i("WebSocket(" + id + "): opened");
onOpen.run();
if (errorCount > 0) {
onReconnected.run();
errorCount = 0;
}
});
super.onOpen(webSocket, response);
}
@Override
public void onMessage(WebSocket webSocket, String text) {
syncExec(
() -> {
Log.i("WebSocket(" + id + "): received message " + text);
Message message = Utils.JSON.fromJson(text, Message.class);
onMessage.onSuccess(message);
});
super.onMessage(webSocket, text);
}
@Override
public void onClosed(WebSocket webSocket, int code, String reason) {
syncExec(
() -> {
if (state == State.Connected) {
Log.w("WebSocket(" + id + "): closed");
onClose.run();
}
state = State.Disconnected;
});
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(" + id + "): failure " + code + " Message: " + message, t);
syncExec(
() -> {
state = State.Disconnected;
if (response != null && response.code() >= 400 && response.code() <= 499) {
onBadRequest.execute(message);
close();
return;
}
errorCount++;
NetworkInfo network = connectivityManager.getActiveNetworkInfo();
if (network == null || !network.isConnected()) {
Log.i("WebSocket(" + id + "): Network not connected");
}
int minutes = Math.min(errorCount * 2 - 1, 20);
onNetworkFailure.execute(minutes);
scheduleReconnect(TimeUnit.MINUTES.toSeconds(minutes));
});
super.onFailure(webSocket, t, response);
}
private void syncExec(Runnable runnable) {
synchronized (this) {
if (ID.get() == id) {
runnable.run();
}
}
}
}
interface BadRequestRunnable {
void execute(String message);
}
interface OnNetworkFailureRunnable {
void execute(int minutes);
}
enum State {
Scheduled,
Connecting,
Connected,
Disconnected
}
}

View File

@@ -0,0 +1,232 @@
package com.github.gotify.service
import android.app.AlarmManager
import android.net.ConnectivityManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.github.gotify.SSLSettings
import com.github.gotify.Utils
import com.github.gotify.api.Callback.SuccessCallback
import com.github.gotify.api.CertUtils
import com.github.gotify.client.model.Message
import com.github.gotify.log.Log
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import okhttp3.*
internal class WebSocketConnection(
private val baseUrl: String,
settings: SSLSettings?,
private val token: String,
private val connectivityManager: ConnectivityManager,
private val alarmManager: AlarmManager
) {
companion object {
private val ID = AtomicLong(0)
}
private val client: OkHttpClient
private val reconnectHandler = Handler(Looper.getMainLooper())
private val reconnectCallback = Runnable { start() }
private var errorCount = 0
private var webSocket: WebSocket? = null
private var onMessage: SuccessCallback<Message>? = null
private var onClose: Runnable? = null
private var onOpen: Runnable? = null
private var onBadRequest: BadRequestRunnable? = null
private var onNetworkFailure: OnNetworkFailureRunnable? = null
private var onReconnected: Runnable? = null
private var state: State? = null
init {
val builder = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(1, TimeUnit.MINUTES)
.connectTimeout(10, TimeUnit.SECONDS)
CertUtils.applySslSettings(builder, settings)
client = builder.build()
}
@Synchronized
fun onMessage(onMessage: SuccessCallback<Message>): WebSocketConnection {
this.onMessage = onMessage
return this
}
@Synchronized
fun onClose(onClose: Runnable): WebSocketConnection {
this.onClose = onClose
return this
}
@Synchronized
fun onOpen(onOpen: Runnable): WebSocketConnection {
this.onOpen = onOpen
return this
}
@Synchronized
fun onBadRequest(onBadRequest: BadRequestRunnable): WebSocketConnection {
this.onBadRequest = onBadRequest
return this
}
@Synchronized
fun onNetworkFailure(onNetworkFailure: OnNetworkFailureRunnable): WebSocketConnection {
this.onNetworkFailure = onNetworkFailure
return this
}
@Synchronized
fun onReconnected(onReconnected: Runnable): WebSocketConnection {
this.onReconnected = onReconnected
return this
}
private fun request(): Request {
val url = HttpUrl.parse(baseUrl)!!
.newBuilder()
.addPathSegment("stream")
.addQueryParameter("token", token)
.build()
return Request.Builder().url(url).get().build()
}
@Synchronized
fun start(): WebSocketConnection {
if (state == State.Connecting || state == State.Connected) {
return this
}
close()
state = State.Connecting
val nextId = ID.incrementAndGet()
Log.i("WebSocket($nextId): starting...")
webSocket = client.newWebSocket(request(), Listener(nextId))
return this
}
@Synchronized
fun close() {
if (webSocket != null) {
Log.i("WebSocket(${ID.get()}): closing existing connection.")
state = State.Disconnected
webSocket!!.close(1000, "")
webSocket = null
}
}
@Synchronized
fun scheduleReconnect(seconds: Long) {
if (state == State.Connecting || state == State.Connected) {
return
}
state = State.Scheduled
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Log.i("WebSocket: scheduling a restart in $seconds second(s) (via alarm manager)")
val future = Calendar.getInstance()
future.add(Calendar.SECOND, seconds.toInt())
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
future.timeInMillis,
"reconnect-tag",
{ start() },
null
)
} else {
Log.i("WebSocket: scheduling a restart in $seconds second(s)")
reconnectHandler.removeCallbacks(reconnectCallback)
reconnectHandler.postDelayed(reconnectCallback, TimeUnit.SECONDS.toMillis(seconds))
}
}
private inner class Listener(private val id: Long) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
syncExec {
state = State.Connected
Log.i("WebSocket($id): opened")
onOpen!!.run()
if (errorCount > 0) {
onReconnected!!.run()
errorCount = 0
}
}
super.onOpen(webSocket, response)
}
override fun onMessage(webSocket: WebSocket, text: String) {
syncExec {
Log.i("WebSocket($id): received message $text")
val message = Utils.JSON.fromJson(text, Message::class.java)
onMessage!!.onSuccess(message)
}
super.onMessage(webSocket, text)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
syncExec {
if (state == State.Connected) {
Log.w("WebSocket($id): closed")
onClose!!.run()
}
state = State.Disconnected
}
super.onClosed(webSocket, code, reason)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
val code = if (response != null) "StatusCode: ${response.code()}" else ""
val message = if (response != null) response.message() else ""
Log.e("WebSocket($id): failure $code Message: $message", t)
syncExec {
state = State.Disconnected
if (response != null && response.code() >= 400 && response.code() <= 499) {
onBadRequest!!.execute(message)
close()
return@syncExec
}
errorCount++
val network = connectivityManager.activeNetworkInfo
if (network == null || !network.isConnected) {
Log.i("WebSocket($id): Network not connected")
}
val minutes = (errorCount * 2 - 1).coerceAtMost(20)
onNetworkFailure!!.execute(minutes)
scheduleReconnect(TimeUnit.MINUTES.toSeconds(minutes.toLong()))
}
super.onFailure(webSocket, t, response)
}
private fun syncExec(runnable: Runnable) {
synchronized(this) {
if (ID.get() == id) {
runnable.run()
}
}
}
}
internal interface BadRequestRunnable {
fun execute(message: String)
}
internal interface OnNetworkFailureRunnable {
fun execute(minutes: Int)
}
internal enum class State {
Scheduled,
Connecting,
Connected,
Disconnected
}
}

View File

@@ -1,389 +0,0 @@
package com.github.gotify.service;
import android.app.AlarmManager;
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.content.IntentFilter;
import android.graphics.Color;
import android.net.ConnectivityManager;
import android.net.Uri;
import android.os.Build;
import android.os.IBinder;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
import com.github.gotify.MarkwonFactory;
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.ApiClient;
import com.github.gotify.client.api.MessageApi;
import com.github.gotify.client.model.Message;
import com.github.gotify.log.Log;
import com.github.gotify.log.UncaughtExceptionHandler;
import com.github.gotify.messages.Extras;
import com.github.gotify.messages.MessagesActivity;
import com.github.gotify.picasso.PicassoHandler;
import io.noties.markwon.Markwon;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import static com.github.gotify.api.Callback.call;
public class WebSocketService extends Service {
public static final String NEW_MESSAGE_BROADCAST =
WebSocketService.class.getName() + ".NEW_MESSAGE";
private static final long NOT_LOADED = -2;
private Settings settings;
private WebSocketConnection connection;
private AtomicLong lastReceivedMessage = new AtomicLong(NOT_LOADED);
private MissedMessageUtil missingMessageUtil;
private PicassoHandler picassoHandler;
private Markwon markwon;
@Override
public void onCreate() {
super.onCreate();
settings = new Settings(this);
ApiClient client =
ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token());
missingMessageUtil = new MissedMessageUtil(client.createService(MessageApi.class));
Log.i("Create " + getClass().getSimpleName());
picassoHandler = new PicassoHandler(this, settings);
markwon = MarkwonFactory.createForNotification(this, picassoHandler.get());
}
@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);
if (connection != null) {
connection.close();
}
Log.i("Starting " + getClass().getSimpleName());
super.onStartCommand(intent, flags, startId);
new Thread(this::startPushService).run();
return START_STICKY;
}
private void startPushService() {
UncaughtExceptionHandler.registerCurrentThread();
showForegroundNotification(getString(R.string.websocket_init));
if (lastReceivedMessage.get() == NOT_LOADED) {
missingMessageUtil.lastReceivedMessage(lastReceivedMessage::set);
}
ConnectivityManager cm =
(ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
connection =
new WebSocketConnection(
settings.url(),
settings.sslSettings(),
settings.token(),
cm,
alarmManager)
.onOpen(this::onOpen)
.onClose(this::onClose)
.onBadRequest(this::onBadRequest)
.onNetworkFailure(this::onNetworkFailure)
.onMessage(this::onMessage)
.onReconnected(this::notifyMissedNotifications)
.start();
IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
picassoHandler.updateAppIds();
}
private void onClose() {
showForegroundNotification(
getString(R.string.websocket_closed), getString(R.string.websocket_reconnect));
ClientFactory.userApiWithToken(settings)
.currentUser()
.enqueue(
call(
(ignored) -> this.doReconnect(),
(exception) -> {
if (exception.code() == 401) {
showForegroundNotification(
getString(R.string.user_action),
getString(R.string.websocket_closed_logout));
} else {
Log.i(
"WebSocket closed but the user still authenticated, trying to reconnect");
this.doReconnect();
}
}));
}
private void doReconnect() {
if (connection == null) {
return;
}
connection.scheduleReconnect(15);
}
private void onBadRequest(String message) {
showForegroundNotification(getString(R.string.websocket_could_not_connect), message);
}
private void onNetworkFailure(int minutes) {
String status = getString(R.string.websocket_not_connected);
String intervalUnit =
getResources()
.getQuantityString(R.plurals.websocket_retry_interval, minutes, minutes);
showForegroundNotification(
status, getString(R.string.websocket_reconnect) + ' ' + intervalUnit);
}
private void onOpen() {
showForegroundNotification(getString(R.string.websocket_listening));
}
private void notifyMissedNotifications() {
long messageId = lastReceivedMessage.get();
if (messageId == NOT_LOADED) {
return;
}
List<Message> messages = missingMessageUtil.missingMessages(messageId);
if (messages.size() > 5) {
onGroupedMessages(messages);
} else {
for (Message message : messages) {
onMessage(message);
}
}
}
private void onGroupedMessages(List<Message> messages) {
long highestPriority = 0;
for (Message message : messages) {
if (lastReceivedMessage.get() < message.getId()) {
lastReceivedMessage.set(message.getId());
highestPriority = Math.max(highestPriority, message.getPriority());
}
broadcast(message);
}
int size = messages.size();
showNotification(
NotificationSupport.ID.GROUPED,
getString(R.string.missed_messages),
getString(R.string.grouped_message, size),
highestPriority,
null);
}
private void onMessage(Message message) {
if (lastReceivedMessage.get() < message.getId()) {
lastReceivedMessage.set(message.getId());
}
broadcast(message);
showNotification(
message.getId(),
message.getTitle(),
message.getMessage(),
message.getPriority(),
message.getExtras(),
message.getAppid());
}
private void broadcast(Message message) {
Intent intent = new Intent();
intent.setAction(NEW_MESSAGE_BROADCAST);
intent.putExtra("message", Utils.JSON.toJson(message));
sendBroadcast(intent);
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
private void showForegroundNotification(String title) {
showForegroundNotification(title, null);
}
private void showForegroundNotification(String title, String message) {
Intent notificationIntent = new Intent(this, MessagesActivity.class);
PendingIntent pendingIntent =
PendingIntent.getActivity(
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder notificationBuilder =
new NotificationCompat.Builder(this, NotificationSupport.Channel.FOREGROUND);
notificationBuilder.setSmallIcon(R.drawable.ic_gotify);
notificationBuilder.setOngoing(true);
notificationBuilder.setPriority(NotificationCompat.PRIORITY_MIN);
notificationBuilder.setShowWhen(false);
notificationBuilder.setWhen(0);
notificationBuilder.setContentTitle(title);
if (message != null) {
notificationBuilder.setContentText(message);
notificationBuilder.setStyle(new NotificationCompat.BigTextStyle().bigText(message));
}
notificationBuilder.setContentIntent(pendingIntent);
notificationBuilder.setColor(
ContextCompat.getColor(getApplicationContext(), R.color.colorPrimary));
startForeground(NotificationSupport.ID.FOREGROUND, notificationBuilder.build());
}
private void showNotification(
int id, String title, String message, long priority, Map<String, Object> extras) {
showNotification(id, title, message, priority, extras, -1L);
}
private void showNotification(
long id,
String title,
String message,
long priority,
Map<String, Object> extras,
Long appid) {
Intent intent;
String intentUrl =
Extras.getNestedValue(
String.class, extras, "android::action", "onReceive", "intentUrl");
if (intentUrl != null) {
intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse(intentUrl));
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}
String url =
Extras.getNestedValue(String.class, extras, "client::notification", "click", "url");
if (url != null) {
intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse(url));
} else {
intent = new Intent(this, MessagesActivity.class);
}
PendingIntent contentIntent =
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder b =
new NotificationCompat.Builder(
this, NotificationSupport.convertPriorityToChannel(priority));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
showNotificationGroup(priority);
}
b.setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL)
.setWhen(System.currentTimeMillis())
.setSmallIcon(R.drawable.ic_gotify)
.setLargeIcon(picassoHandler.getIcon(appid))
.setTicker(getString(R.string.app_name) + " - " + title)
.setGroup(NotificationSupport.Group.MESSAGES)
.setContentTitle(title)
.setDefaults(Notification.DEFAULT_LIGHTS | Notification.DEFAULT_SOUND)
.setLights(Color.CYAN, 1000, 5000)
.setColor(ContextCompat.getColor(getApplicationContext(), R.color.colorPrimary))
.setContentIntent(contentIntent);
CharSequence formattedMessage = message;
if (Extras.useMarkdown(extras)) {
formattedMessage = markwon.toMarkdown(message);
message = formattedMessage.toString();
}
b.setContentText(message);
b.setStyle(new NotificationCompat.BigTextStyle().bigText(formattedMessage));
String notificationImageUrl =
Extras.getNestedValue(String.class, extras, "client::notification", "bigImageUrl");
if (notificationImageUrl != null) {
try {
b.setStyle(
new NotificationCompat.BigPictureStyle()
.bigPicture(picassoHandler.getImageFromUrl(notificationImageUrl)));
} catch (Exception e) {
Log.e("Error loading bigImageUrl", e);
}
}
NotificationManager notificationManager =
(NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.notify(Utils.longToInt(id), b.build());
}
@RequiresApi(Build.VERSION_CODES.N)
public void showNotificationGroup(long priority) {
Intent intent = new Intent(this, MessagesActivity.class);
PendingIntent contentIntent =
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder b =
new NotificationCompat.Builder(
this, NotificationSupport.convertPriorityToChannel(priority));
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)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setContentTitle(getString(R.string.grouped_notification_text))
.setGroupSummary(true)
.setContentText(getString(R.string.grouped_notification_text))
.setColor(ContextCompat.getColor(getApplicationContext(), R.color.colorPrimary))
.setContentIntent(contentIntent);
NotificationManager notificationManager =
(NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.notify(-5, b.build());
}
}

View File

@@ -0,0 +1,384 @@
package com.github.gotify.service
import android.app.*
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.os.IBinder
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.github.gotify.*
import com.github.gotify.api.Callback
import com.github.gotify.api.ClientFactory
import com.github.gotify.client.api.MessageApi
import com.github.gotify.client.model.Message
import com.github.gotify.log.Log
import com.github.gotify.log.UncaughtExceptionHandler
import com.github.gotify.messages.Extras
import com.github.gotify.messages.MessagesActivity
import com.github.gotify.picasso.PicassoHandler
import com.github.gotify.service.WebSocketConnection.BadRequestRunnable
import com.github.gotify.service.WebSocketConnection.OnNetworkFailureRunnable
import io.noties.markwon.Markwon
import java.util.concurrent.atomic.AtomicLong
class WebSocketService : Service() {
companion object {
val NEW_MESSAGE_BROADCAST = "${WebSocketService::class.java.name}.NEW_MESSAGE"
private const val NOT_LOADED = -2L
}
private lateinit var settings: Settings
private var connection: WebSocketConnection? = null
private val lastReceivedMessage = AtomicLong(NOT_LOADED)
private lateinit var missingMessageUtil: MissedMessageUtil
private lateinit var picassoHandler: PicassoHandler
private lateinit var markwon: Markwon
override fun onCreate() {
super.onCreate()
settings = Settings(this)
val client = ClientFactory.clientToken(
settings.url(),
settings.sslSettings(),
settings.token()
)
missingMessageUtil = MissedMessageUtil(client.createService(MessageApi::class.java))
Log.i("Create ${javaClass.simpleName}")
picassoHandler = PicassoHandler(this, settings)
markwon = MarkwonFactory.createForNotification(this, picassoHandler.get())
}
override fun onDestroy() {
super.onDestroy()
if (connection != null) {
connection!!.close()
}
Log.w("Destroy ${javaClass.simpleName}")
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
Log.init(this)
if (connection != null) {
connection!!.close()
}
Log.i("Starting ${javaClass.simpleName}")
super.onStartCommand(intent, flags, startId)
Thread { startPushService() }.start()
return START_STICKY
}
private fun startPushService() {
UncaughtExceptionHandler.registerCurrentThread()
showForegroundNotification(getString(R.string.websocket_init))
if (lastReceivedMessage.get() == NOT_LOADED) {
missingMessageUtil.lastReceivedMessage { lastReceivedMessage.set(it) }
}
val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
connection = WebSocketConnection(
settings.url(),
settings.sslSettings(),
settings.token(),
cm,
alarmManager
)
.onOpen { onOpen() }
.onClose { onClose() }
.onBadRequest(object : BadRequestRunnable {
override fun execute(message: String) {
onBadRequest(message)
}
})
.onNetworkFailure(object : OnNetworkFailureRunnable {
override fun execute(minutes: Int) {
onNetworkFailure(minutes)
}
})
.onMessage { onMessage(it) }
.onReconnected { notifyMissedNotifications() }
.start()
val intentFilter = IntentFilter()
intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION)
picassoHandler.updateAppIds()
}
private fun onClose() {
showForegroundNotification(
getString(R.string.websocket_closed), getString(R.string.websocket_reconnect)
)
ClientFactory.userApiWithToken(settings)
.currentUser()
.enqueue(Callback.call({ doReconnect() }) { exception ->
if (exception.code() == 401) {
showForegroundNotification(
getString(R.string.user_action),
getString(R.string.websocket_closed_logout)
)
} else {
Log.i("WebSocket closed but the user still authenticated, trying to reconnect")
doReconnect()
}
})
}
private fun doReconnect() {
if (connection == null) {
return
}
connection!!.scheduleReconnect(15)
}
private fun onBadRequest(message: String) {
showForegroundNotification(getString(R.string.websocket_could_not_connect), message)
}
private fun onNetworkFailure(minutes: Int) {
val status = getString(R.string.websocket_not_connected)
val intervalUnit = resources
.getQuantityString(R.plurals.websocket_retry_interval, minutes, minutes)
showForegroundNotification(
status, "${getString(R.string.websocket_reconnect)} $intervalUnit"
)
}
private fun onOpen() {
showForegroundNotification(getString(R.string.websocket_listening))
}
private fun notifyMissedNotifications() {
val messageId = lastReceivedMessage.get()
if (messageId == NOT_LOADED) {
return
}
val messages = missingMessageUtil.missingMessages(messageId)
if (messages.size > 5) {
onGroupedMessages(messages)
} else {
for (message in messages) {
onMessage(message)
}
}
}
private fun onGroupedMessages(messages: List<Message>) {
var highestPriority = 0L
for (message in messages) {
if (lastReceivedMessage.get() < message.id) {
lastReceivedMessage.set(message.id)
highestPriority = highestPriority.coerceAtLeast(message.priority)
}
broadcast(message)
}
val size = messages.size
showNotification(
NotificationSupport.ID.GROUPED,
getString(R.string.missed_messages),
getString(R.string.grouped_message, size),
highestPriority,
null
)
}
private fun onMessage(message: Message) {
if (lastReceivedMessage.get() < message.id) {
lastReceivedMessage.set(message.id)
}
broadcast(message)
showNotification(
message.id,
message.title,
message.message,
message.priority,
message.extras,
message.appid
)
}
private fun broadcast(message: Message) {
val intent = Intent()
intent.action = NEW_MESSAGE_BROADCAST
intent.putExtra("message", Utils.JSON.toJson(message))
sendBroadcast(intent)
}
override fun onBind(intent: Intent): IBinder? = null
private fun showForegroundNotification(title: String, message: String? = null) {
val notificationIntent = Intent(this, MessagesActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
)
val notificationBuilder =
NotificationCompat.Builder(this, NotificationSupport.Channel.FOREGROUND)
notificationBuilder.setSmallIcon(R.drawable.ic_gotify)
notificationBuilder.setOngoing(true)
notificationBuilder.priority = NotificationCompat.PRIORITY_MIN
notificationBuilder.setShowWhen(false)
notificationBuilder.setWhen(0)
notificationBuilder.setContentTitle(title)
if (message != null) {
notificationBuilder.setContentText(message)
notificationBuilder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
notificationBuilder.setContentIntent(pendingIntent)
notificationBuilder.color = ContextCompat.getColor(applicationContext, R.color.colorPrimary)
startForeground(NotificationSupport.ID.FOREGROUND, notificationBuilder.build())
}
private fun showNotification(
id: Int,
title: String,
message: String,
priority: Long,
extras: Map<String, Any>?
) {
showNotification(id.toLong(), title, message, priority, extras, -1L)
}
private fun showNotification(
id: Long,
title: String,
message: String,
priority: Long,
extras: Map<String, Any>?,
appId: Long
) {
var intent: Intent
val intentUrl = Extras.getNestedValue(
String::class.java,
extras,
"android::action",
"onReceive",
"intentUrl"
)
if (intentUrl != null) {
intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(intentUrl)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
}
val url = Extras.getNestedValue(
String::class.java,
extras,
"client::notification",
"click",
"url"
)
if (url != null) {
intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(url)
} else {
intent = Intent(this, MessagesActivity::class.java)
}
val contentIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val b = NotificationCompat.Builder(
this, NotificationSupport.convertPriorityToChannel(priority)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
showNotificationGroup(priority)
}
b.setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL)
.setWhen(System.currentTimeMillis())
.setSmallIcon(R.drawable.ic_gotify)
.setLargeIcon(picassoHandler.getIcon(appId))
.setTicker("${getString(R.string.app_name)} - $title")
.setGroup(NotificationSupport.Group.MESSAGES)
.setContentTitle(title)
.setDefaults(Notification.DEFAULT_LIGHTS or Notification.DEFAULT_SOUND)
.setLights(Color.CYAN, 1000, 5000)
.setColor(ContextCompat.getColor(applicationContext, R.color.colorPrimary))
.setContentIntent(contentIntent)
var formattedMessage = message as CharSequence
lateinit var newMessage: String
if (Extras.useMarkdown(extras)) {
formattedMessage = markwon.toMarkdown(message)
newMessage = formattedMessage.toString()
}
b.setContentText(newMessage)
b.setStyle(NotificationCompat.BigTextStyle().bigText(formattedMessage))
val notificationImageUrl = Extras.getNestedValue(
String::class.java,
extras,
"client::notification",
"bigImageUrl"
)
if (notificationImageUrl != null) {
try {
b.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(picassoHandler.getImageFromUrl(notificationImageUrl))
)
} catch (e: Exception) {
Log.e("Error loading bigImageUrl", e)
}
}
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(Utils.longToInt(id), b.build())
}
@RequiresApi(Build.VERSION_CODES.N)
fun showNotificationGroup(priority: Long) {
val intent = Intent(this, MessagesActivity::class.java)
val contentIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val builder = NotificationCompat.Builder(
this, NotificationSupport.convertPriorityToChannel(priority)
)
builder.setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL)
.setWhen(System.currentTimeMillis())
.setSmallIcon(R.drawable.ic_gotify)
.setTicker(getString(R.string.app_name))
.setGroup(NotificationSupport.Group.MESSAGES)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setContentTitle(getString(R.string.grouped_notification_text))
.setGroupSummary(true)
.setContentText(getString(R.string.grouped_notification_text))
.setColor(ContextCompat.getColor(applicationContext, R.color.colorPrimary))
.setContentIntent(contentIntent)
val notificationManager = this.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(-5, builder.build())
}
}