From d52d22816f816b7d084aab13848cd48d4158c1cd Mon Sep 17 00:00:00 2001 From: Sean Regan Date: Sun, 4 Feb 2024 00:54:41 -0500 Subject: [PATCH] Periodically check server health in the background --- android/app/build.gradle | 1 + .../backend_ui_interop/MethodCallHandler.kt | 4 + .../services/healthservice/HealthWorker.kt | 155 ++++++++++++++++++ .../services/healthservice/NetworkObserver.kt | 72 ++++++++ .../ConnectionErrorNotification.kt | 54 ++++++ .../services/system/HealthCheckHandler.kt | 30 ++++ .../com/bluebubbles/messaging/utils/Utils.kt | 11 ++ .../pages/server/server_management_panel.dart | 13 ++ lib/models/global/settings.dart | 4 + .../notifications/notifications_service.dart | 22 +-- lib/services/network/health_service.dart | 22 +++ 11 files changed, 368 insertions(+), 20 deletions(-) create mode 100644 android/app/src/main/kotlin/com/bluebubbles/messaging/services/healthservice/HealthWorker.kt create mode 100644 android/app/src/main/kotlin/com/bluebubbles/messaging/services/healthservice/NetworkObserver.kt create mode 100644 android/app/src/main/kotlin/com/bluebubbles/messaging/services/notifications/ConnectionErrorNotification.kt create mode 100644 android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/HealthCheckHandler.kt create mode 100644 lib/services/network/health_service.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 6fe6632bc..8eafe43ac 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -151,6 +151,7 @@ dependencies { implementation 'androidx.browser:browser:1.7.0' implementation 'androidx.activity:activity-ktx:1.8.2' implementation "androidx.work:work-runtime:2.9.0" + implementation "androidx.work:work-runtime-ktx:2.9.0" // Firebase items implementation 'com.google.firebase:firebase-messaging:23.4.0' diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt index d5ab81ff0..7718cc8ee 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt @@ -8,6 +8,7 @@ import com.bluebubbles.messaging.services.filesystem.GetContentUriPathHandler import com.bluebubbles.messaging.services.firebase.FirebaseAuthHandler import com.bluebubbles.messaging.services.firebase.ServerUrlRequestHandler import com.bluebubbles.messaging.services.firebase.UpdateNextRestartHandler +import com.bluebubbles.messaging.services.notifications.ConnectionErrorNotification import com.bluebubbles.messaging.services.notifications.CreateIncomingFaceTimeNotification import com.bluebubbles.messaging.services.notifications.CreateIncomingMessageNotification import com.bluebubbles.messaging.services.notifications.DeleteNotificationHandler @@ -16,6 +17,7 @@ import com.bluebubbles.messaging.services.notifications.NotificationListenerPerm import com.bluebubbles.messaging.services.notifications.StartNotificationListenerHandler import com.bluebubbles.messaging.services.system.BrowserLaunchRequestHandler import com.bluebubbles.messaging.services.system.CheckChromeOsHandler +import com.bluebubbles.messaging.services.system.HealthCheckHandler import com.bluebubbles.messaging.services.system.NewContactFormRequestHandler import com.bluebubbles.messaging.services.system.OpenCalendarRequestHandler import com.bluebubbles.messaging.services.system.OpenConversationNotificationSettingsHandler @@ -61,6 +63,8 @@ class MethodCallHandler { CreateIncomingMessageNotification.tag -> CreateIncomingMessageNotification().handleMethodCall(call, result, context) CreateIncomingFaceTimeNotification.tag -> CreateIncomingFaceTimeNotification().handleMethodCall(call, result, context) DeleteNotificationHandler.tag -> DeleteNotificationHandler().handleMethodCall(call, result, context) + HealthCheckHandler.tag -> HealthCheckHandler().handleMethodCall(call, result, context) + ConnectionErrorNotification.tag -> ConnectionErrorNotification().handleMethodCall(call, result, context) else -> { val error = "Could not find method call handler for ${call.method}!" Log.d(Constants.logTag, error) diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/healthservice/HealthWorker.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/healthservice/HealthWorker.kt new file mode 100644 index 000000000..fffc59b46 --- /dev/null +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/healthservice/HealthWorker.kt @@ -0,0 +1,155 @@ +package com.bluebubbles.messaging.services.healthservice + +import android.content.Context +import android.util.Log +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.bluebubbles.messaging.services.notifications.ConnectionErrorNotification +import com.bluebubbles.messaging.utils.BBServerInfo +import com.bluebubbles.messaging.utils.Utils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.io.IOException +import java.net.HttpURLConnection + +import java.net.URL +import java.net.URLEncoder +import java.util.concurrent.TimeUnit +import kotlin.time.DurationUnit +import kotlin.time.toDuration + + +class HealthWorker(val context: Context, params: WorkerParameters): CoroutineWorker(context, params) { + private val networkObserver = NetworkObserver(context) + + private enum class ResultType { + NO_INTERNET, + PING_FAILURE, + SUCCESS + } + + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + networkObserver.start() + + networkObserver.internetState.first { it != NetworkObserver.ConnectionState.UNKNOWN } + + val result = when(pingWithRetries()) { + ResultType.PING_FAILURE -> { + Log.w(TAG, "Server did not respond to pings!") + ConnectionErrorNotification().createErrorNotification(context) + Result.failure() + } + ResultType.NO_INTERNET -> { + Log.i(TAG, "Not checking health - no internet retry later") + Result.retry() + } + else -> { + ConnectionErrorNotification().clearErrorNotification(context) + + Result.success() + } + } + + networkObserver.stop() + + return@withContext result + } + } + + private suspend fun pingWithRetries(): ResultType { + return withContext(Dispatchers.IO) { + var retryCounter = MAX_RETRIES; + do { + if (networkObserver.internetState.value == NetworkObserver.ConnectionState.DISCONNECTED) { + return@withContext ResultType.NO_INTERNET + } + + val info = Utils.getBBServerUrl(context) + + try { + if (info.url == null || info.guid == null) { + Log.w(TAG, "Cannot ping - no server info") + + throw IOException("No URL or GUID available") + } + + Log.i(TAG, "Attempting to ping server") + pingServer(info) + return@withContext ResultType.SUCCESS + } catch (e: IOException) { + Log.i(TAG, "Ping failed: ${e.message}") + --retryCounter + if (retryCounter > 0) delay(RETRY_DELAY) + } + } while (retryCounter > 0) + + return@withContext ResultType.PING_FAILURE + } + } + + private suspend fun pingServer(info: BBServerInfo) { + withContext(Dispatchers.IO) { + val parameters = mapOf( + "guid" to info.guid + ) + + val paramsStr = parameters.keys.joinToString("&") { k -> + "${URLEncoder.encode(k, "UTF-8")}=${URLEncoder.encode(parameters[k], "UTF-8")}" + } + + val url = URL("${info.url}/api/v1/ping?$paramsStr") + val con = url.openConnection() as HttpURLConnection + con.requestMethod = "GET" + + con.disconnect() + + if (con.responseCode != 200) { + throw IOException("Server responded with unsuccessful status ${con.responseCode}") + } else { + Log.i(TAG, "Successfully pinged server") + } + } + } + + companion object { + private const val TAG = "HealthWorker" + private const val WORK_NAME = "HealthWorker" + + private const val MAX_RETRIES = 3 + private val RETRY_DELAY = 5.toDuration(DurationUnit.SECONDS) + + private const val REPEAT_MINUTES = 30L + + fun registerHealthChecking(context: Context) { + Log.i(TAG, "Health checking enabled") + + val work = PeriodicWorkRequestBuilder(REPEAT_MINUTES, TimeUnit.MINUTES) + .setConstraints(Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .addTag(TAG) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + work) + } + + fun cancelHealthChecking(context: Context) { + Log.i(TAG, "Health checking disabled") + + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/healthservice/NetworkObserver.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/healthservice/NetworkObserver.kt new file mode 100644 index 000000000..9a5c934d4 --- /dev/null +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/healthservice/NetworkObserver.kt @@ -0,0 +1,72 @@ +package com.bluebubbles.messaging.services.healthservice + +import android.content.Context +import android.net.ConnectivityManager +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class NetworkObserver(context: Context) { + enum class ConnectionState { + CONNECTED, + DISCONNECTED, + + UNKNOWN + } + + private val _internetState = MutableStateFlow(ConnectionState.UNKNOWN) + val internetState = _internetState.asStateFlow() + + private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + private val internetObserver = Observer(_internetState) + + fun start() { + connectivityManager.registerNetworkCallback(internetNetwork, internetObserver) + } + + fun stop() { + connectivityManager.unregisterNetworkCallback(internetObserver) + } + + private class Observer(val mutableState: MutableStateFlow): NetworkCallback(), + CoroutineScope by CoroutineScope(Dispatchers.IO) { + + override fun onAvailable(network: Network) { + super.onAvailable(network) + + launch { + mutableState.emit(ConnectionState.CONNECTED) + } + } + + override fun onUnavailable() { + super.onUnavailable() + + launch { + mutableState.emit(ConnectionState.DISCONNECTED) + } + } + + override fun onLost(network: Network) { + super.onLost(network) + + launch { + mutableState.emit(ConnectionState.DISCONNECTED) + } + } + } + + companion object { + private val internetNetwork = NetworkRequest + .Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/notifications/ConnectionErrorNotification.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/notifications/ConnectionErrorNotification.kt new file mode 100644 index 000000000..9e65baba2 --- /dev/null +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/notifications/ConnectionErrorNotification.kt @@ -0,0 +1,54 @@ +package com.bluebubbles.messaging.services.notifications + +import android.app.NotificationManager +import android.content.Context +import androidx.core.app.NotificationCompat +import com.bluebubbles.messaging.Constants +import com.bluebubbles.messaging.R +import com.bluebubbles.messaging.models.MethodCallHandlerImpl +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class ConnectionErrorNotification: MethodCallHandlerImpl() { + override fun handleMethodCall( + call: MethodCall, + result: MethodChannel.Result, + context: Context + ) { + if (call.argument("operation") as? String == "create") { + createErrorNotification(context) + } else if (call.argument("operation") as? String == "clear") { + clearErrorNotification(context) + } + + result.success(null) + } + + fun createErrorNotification(context: Context) { + val notificationBuilder = NotificationCompat.Builder(context, "com.bluebubbles.errors") + .setSmallIcon(R.mipmap.ic_stat_icon) + .setAutoCancel(false) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setContentTitle("Could not connect") + .setContentText("Your server may be offline!") + .setColor(0x4990de) + + val notificationManager = context.getSystemService(NotificationManager::class.java) + notificationManager.notify(Constants.newFaceTimeNotificationTag, NOTIFICATION_ID, notificationBuilder.build()) + } + + fun clearErrorNotification(context: Context) { + val notificationManager = context.getSystemService(NotificationManager::class.java) + notificationManager.cancel(NOTIFICATION_ID) + } + + companion object { + const val tag = "connection-error-notification" + + private const val NOTIFICATION_ID = -2 + } + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/HealthCheckHandler.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/HealthCheckHandler.kt new file mode 100644 index 000000000..550fe09ed --- /dev/null +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/HealthCheckHandler.kt @@ -0,0 +1,30 @@ +package com.bluebubbles.messaging.services.system + +import android.content.Context +import android.util.Log +import com.bluebubbles.messaging.models.MethodCallHandlerImpl +import com.bluebubbles.messaging.services.healthservice.HealthWorker +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class HealthCheckHandler: MethodCallHandlerImpl() { + companion object { + const val tag: String = "health-check-setup" + } + + override fun handleMethodCall( + call: MethodCall, + result: MethodChannel.Result, + context: Context + ) { + Log.i(tag, "Handling health check method") + + if (call.argument("enabled") as? Boolean == true) { + HealthWorker.registerHealthChecking(context) + } else if (call.argument("enabled") as? Boolean == false) { + HealthWorker.cancelHealthChecking(context) + } + + result.success(null) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/utils/Utils.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/utils/Utils.kt index 9d484c1c3..40a7db0a3 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/utils/Utils.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/utils/Utils.kt @@ -11,6 +11,8 @@ import com.bluebubbles.messaging.services.firebase.ServerUrlRequestHandler import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +data class BBServerInfo(val url: String?, val guid: String?) + object Utils { fun getAdaptiveIconFromByteArray(data: ByteArray): IconCompat { val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size) @@ -45,4 +47,13 @@ object Utils { override fun notImplemented() {} }, context) } + + fun getBBServerUrl(context: Context): BBServerInfo { + val prefs = context.getSharedPreferences("FlutterSharedPreferences", 0) + + return BBServerInfo( + prefs.getString("flutter.serverAddress", null), + prefs.getString("flutter.guidAuthKey", null) + ) + } } \ No newline at end of file diff --git a/lib/app/layouts/settings/pages/server/server_management_panel.dart b/lib/app/layouts/settings/pages/server/server_management_panel.dart index 927f44940..80e37b6b7 100644 --- a/lib/app/layouts/settings/pages/server/server_management_panel.dart +++ b/lib/app/layouts/settings/pages/server/server_management_panel.dart @@ -29,6 +29,7 @@ import 'package:qr_flutter/qr_flutter.dart'; import 'package:universal_html/html.dart' as html; import 'package:universal_io/io.dart'; import 'package:version/version.dart'; +import 'package:bluebubbles/services/network/health_service.dart'; class ServerManagementPanelController extends StatefulController { final RxnInt latency = RxnInt(); @@ -584,6 +585,18 @@ class _ServerManagementPanelState extends CustomState SettingsSwitch( + initialVal: ss.settings.backgroundServerPinging.value, + title: "Background Server Pinging", + subtitle: "Periodically pings your server in the background to check that it's still running (could impact battery life)", + backgroundColor: tileColor, + onChanged: (bool val) async { + ss.settings.backgroundServerPinging.value = val; + healthService.setBackgroundPingingState(val); + ss.saveSettings(); + }, + )), Container( color: tileColor, child: Padding( diff --git a/lib/models/global/settings.dart b/lib/models/global/settings.dart index 88cdab338..cb04a9665 100644 --- a/lib/models/global/settings.dart +++ b/lib/models/global/settings.dart @@ -75,6 +75,7 @@ class Settings { final RxnString receiveSoundPath = RxnString(); final RxInt soundVolume = 100.obs; final RxBool syncContactsAutomatically = false.obs; + final RxBool backgroundServerPinging = false.obs; final RxBool scrollToBottomOnSend = true.obs; final RxBool sendEventsToTasker = false.obs; final RxBool keepAppAlive = false.obs; @@ -248,6 +249,7 @@ class Settings { 'receiveSoundPath': receiveSoundPath.value, 'soundVolume': soundVolume.value, 'syncContactsAutomatically': syncContactsAutomatically.value, + 'backgroundServerPinging': backgroundServerPinging.value, 'scrollToBottomOnSend': scrollToBottomOnSend.value, 'sendEventsToTasker': sendEventsToTasker.value, 'keepAppAlive': keepAppAlive.value, @@ -364,6 +366,7 @@ class Settings { ss.settings.receiveSoundPath.value = map['receiveSoundPath']; ss.settings.soundVolume.value = map['soundVolume'] ?? 100; ss.settings.syncContactsAutomatically.value = map['syncContactsAutomatically'] ?? false; + ss.settings.backgroundServerPinging.value = map['backgroundServerPinging'] ?? false; ss.settings.scrollToBottomOnSend.value = map['scrollToBottomOnSend'] ?? true; ss.settings.sendEventsToTasker.value = map['sendEventsToTasker'] ?? true; ss.settings.keepAppAlive.value = map['keepAppAlive'] ?? false; @@ -487,6 +490,7 @@ class Settings { s.receiveSoundPath.value = map['receiveSoundPath']; s.soundVolume.value = map['soundVolume'] ?? 100; s.syncContactsAutomatically.value = map['syncContactsAutomatically'] ?? false; + s.backgroundServerPinging.value = map['backgroundServerPinging'] ?? false; s.scrollToBottomOnSend.value = map['scrollToBottomOnSend'] ?? true; s.sendEventsToTasker.value = map['sendEventsToTasker'] ?? false; s.keepAppAlive.value = map['keepAppAlive'] ?? false; diff --git a/lib/services/backend/notifications/notifications_service.dart b/lib/services/backend/notifications/notifications_service.dart index e143c01e2..b5da2c0c0 100644 --- a/lib/services/backend/notifications/notifications_service.dart +++ b/lib/services/backend/notifications/notifications_service.dart @@ -628,25 +628,7 @@ class NotificationsService extends GetxService { await socketToast!.show(); return; } else { - final notifs = await flnp.getActiveNotifications(); - if (notifs.firstWhereOrNull((element) => element.id == -2) != null) return; - await flnp.show( - -2, - title, - subtitle, - NotificationDetails( - android: AndroidNotificationDetails( - ERROR_CHANNEL, - 'Errors', - channelDescription: 'Displays message send failures, connection failures, and more', - priority: Priority.max, - importance: Importance.max, - color: HexColor("4990de"), - ongoing: true, - onlyAlertOnce: true, - ), - ), - ); + await mcs.invokeMethod("connection-error-notification", {"operation": "create"}); } } @@ -765,7 +747,7 @@ class NotificationsService extends GetxService { socketToast = null; return; } - await flnp.cancel(-2); + await mcs.invokeMethod("connection-error-notification", {"operation": "clear"}); } Future clearFailedToSend(int id) async { diff --git a/lib/services/network/health_service.dart b/lib/services/network/health_service.dart new file mode 100644 index 000000000..fe3d2bb87 --- /dev/null +++ b/lib/services/network/health_service.dart @@ -0,0 +1,22 @@ +import 'package:bluebubbles/services/services.dart'; +import 'package:get/get.dart'; + +HealthService healthService = Get.isRegistered() ? Get.find() : Get.put(HealthService()); + +class HealthService extends GetxService { + Future setBackgroundPingingState(bool enabled) async { + if (enabled) { + await enableBackgroundPinging(); + } else { + await disableBackgroundPinging(); + } + } + + Future enableBackgroundPinging() async { + await mcs.invokeMethod("health-check-setup", {"enabled": true}); + } + + Future disableBackgroundPinging() async { + await mcs.invokeMethod("health-check-setup", {"enabled": false}); + } +} \ No newline at end of file