-
Notifications
You must be signed in to change notification settings - Fork 123
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
base: development
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
---|---|---|
|
@@ -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?) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
} |
There was a problem hiding this comment.
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