Skip to content

Feature: Check server health in the background #2666

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove the runtime impl above this, because -ktx just adds kotlin extensions, don't need the duplicate one


// Firebase items
implementation 'com.google.firebase:firebase-messaging:23.4.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a network observer at all if you are already checking in-line what the network state is when the worker runs?


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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be useful to only just ping the raw URL (landing page URL) so we don't have to deal with user password, just food for thought.

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<HealthWorker>(REPEAT_MINUTES, TimeUnit.MINUTES)
.setConstraints(Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to above, should this not mean that the worker only runs if the phone is connected to network? So no need to even observe network state.

.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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ConnectionState>): 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()
}
}
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +18 to +24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep all the notification cancelling logic in DeleteNotificationHandler, and just pass ID -2 from Dart itself

}

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets add this to constants.kt

}

}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll also need to handle custom headers, if the user has set any, so this will get a bit more complex


object Utils {
fun getAdaptiveIconFromByteArray(data: ByteArray): IconCompat {
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size)
Expand Down Expand Up @@ -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)
)
}
Comment on lines +51 to +58
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer that we force the worker to go through firebase to get the URL, because there is no guarantee the one in the preferences is most up to date. I do need to update the getServerUrl logic to handle setups that don't have firebase though, because it will just error currently. Overall just need to support firebase-less setups better

}
Loading