diff --git a/app/src/main/java/net/pipe01/pinepartner/MainActivity.kt b/app/src/main/java/net/pipe01/pinepartner/MainActivity.kt index 53f3dc7..08a1f3e 100644 --- a/app/src/main/java/net/pipe01/pinepartner/MainActivity.kt +++ b/app/src/main/java/net/pipe01/pinepartner/MainActivity.kt @@ -1,17 +1,17 @@ package net.pipe01.pinepartner import android.annotation.SuppressLint -import android.content.ComponentName -import android.content.Intent -import android.content.ServiceConnection import android.os.Bundle -import android.os.IBinder -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -19,12 +19,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.navigation.compose.rememberNavController import net.pipe01.pinepartner.data.AppDatabase +import net.pipe01.pinepartner.pages.ConnectingServicePage import net.pipe01.pinepartner.scripting.BuiltInPlugins -import net.pipe01.pinepartner.service.BackgroundService +import net.pipe01.pinepartner.service.ServiceHandle import net.pipe01.pinepartner.ui.theme.PinePartnerTheme +import net.pipe01.pinepartner.utils.composables.PluginsDisabledDialog @SuppressLint("MissingPermission") class MainActivity : ComponentActivity() { + private val serviceHandle = ServiceHandle(this) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -32,48 +36,66 @@ class MainActivity : ComponentActivity() { BuiltInPlugins.init(assets) - val intent = Intent(this, BackgroundService::class.java) - setContent { val navController = rememberNavController() + val snackbarHostState = remember { SnackbarHostState() } - var service by remember { mutableStateOf(null) } var showBottomBar by remember { mutableStateOf(false) } DisposableEffect(Unit) { - val conn = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { - service = (binder as BackgroundService.ServiceBinder).service - } - - override fun onServiceDisconnected(name: ComponentName?) { - Log.d("MainActivity", "Service disconnected") - } - } - - bindService(intent, conn, 0) + serviceHandle.start() onDispose { - unbindService(conn) + serviceHandle.unbind() } } PinePartnerTheme { - Scaffold( - bottomBar = { - if (showBottomBar) { - BottomBar(navController = navController) + if (serviceHandle.service == null) { + ConnectingServicePage(crashed = serviceHandle.hasCrashed) + } else { + var showPluginsDisabledDialog by remember { mutableStateOf(false) } + + if (serviceHandle.pluginsDisabled) { + LaunchedEffect(Unit) { + val result = snackbarHostState.showSnackbar( + message = "Plugins have been disabled", + duration = SnackbarDuration.Long, + actionLabel = "Why?", + ) + when (result) { + SnackbarResult.ActionPerformed -> { + showPluginsDisabledDialog = true + } + SnackbarResult.Dismissed -> { + } + } } } - ) { padding -> - PermissionsFrame( - onGotAllPermissions = { showBottomBar = true }, - ) { - if (service != null) { + + if (showPluginsDisabledDialog) { + PluginsDisabledDialog( + onDismissRequest = { showPluginsDisabledDialog = false } + ) + } + + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + bottomBar = { + if (showBottomBar) { + BottomBar(navController = navController) + } + } + ) { padding -> + PermissionsFrame( + onGotAllPermissions = { showBottomBar = true }, + ) { NavFrame( modifier = Modifier.padding(padding), navController = navController, - backgroundService = service!!, + backgroundService = serviceHandle.service!!, onShowBottomBar = { showBottomBar = it }, db = db, ) @@ -87,7 +109,6 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() - val intent = Intent(this, BackgroundService::class.java) - startForegroundService(intent) + serviceHandle.start() } } diff --git a/app/src/main/java/net/pipe01/pinepartner/NavFrame.kt b/app/src/main/java/net/pipe01/pinepartner/NavFrame.kt index 9a87ea7..11d067d 100644 --- a/app/src/main/java/net/pipe01/pinepartner/NavFrame.kt +++ b/app/src/main/java/net/pipe01/pinepartner/NavFrame.kt @@ -51,6 +51,7 @@ import net.pipe01.pinepartner.pages.plugins.PluginsPage import net.pipe01.pinepartner.pages.settings.NotificationSettingsPage import net.pipe01.pinepartner.pages.settings.SettingsPage import net.pipe01.pinepartner.service.BackgroundService +import net.pipe01.pinepartner.utils.PineError import net.pipe01.pinepartner.utils.composables.ErrorDialog import java.net.URLDecoder import java.net.URLEncoder @@ -132,7 +133,7 @@ fun NavFrame( onShowBottomBar: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { - val errors = remember { mutableStateListOf() } + val errors = remember { mutableStateListOf() } if (errors.isNotEmpty()) { val error = errors.first() diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/ConnectingServicePage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/ConnectingServicePage.kt new file mode 100644 index 0000000..24e4970 --- /dev/null +++ b/app/src/main/java/net/pipe01/pinepartner/pages/ConnectingServicePage.kt @@ -0,0 +1,41 @@ +package net.pipe01.pinepartner.pages + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun ConnectingServicePage(crashed: Boolean) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(1f)) + + CircularProgressIndicator() + + Text( + modifier = Modifier.padding(top = 16.dp), + text = when (crashed) { + true -> "Background service crashed, restarting it" + false -> "Connecting to service" + } + ) + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +private fun ConnectingServicePagePreview() { + ConnectingServicePage(false) +} diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/devices/DFUPage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/devices/DFUPage.kt index 6e39498..0dd4cd7 100644 --- a/app/src/main/java/net/pipe01/pinepartner/pages/devices/DFUPage.kt +++ b/app/src/main/java/net/pipe01/pinepartner/pages/devices/DFUPage.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.runBlocking import net.pipe01.pinepartner.components.Header import net.pipe01.pinepartner.service.BackgroundService import net.pipe01.pinepartner.service.TransferProgress +import net.pipe01.pinepartner.utils.PineError import net.pipe01.pinepartner.utils.composables.ErrorDialog import net.pipe01.pinepartner.utils.toMinutesSeconds import java.time.Duration @@ -43,7 +44,7 @@ fun DFUPage( ) { var uri by remember { mutableStateOf(null) } - var showErrorDialog by remember { mutableStateOf(null) } + var showErrorDialog by remember { mutableStateOf(null) } if (showErrorDialog != null) { ErrorDialog( @@ -105,7 +106,7 @@ private fun Uploader( onStart: () -> Unit = { }, onFinish: () -> Unit = { }, onCancel: () -> Unit = { }, - onError: (Error) -> Unit = { }, + onError: (PineError) -> Unit = { }, ) { var progress by remember { mutableStateOf(null) } @@ -119,7 +120,7 @@ private fun Uploader( launch { backgroundService.startWatchDFU(jobId, address, uri).onFailure { - onError(Error("Failed to do DFU transfer", it)) + onError(PineError("Failed to do DFU transfer", it)) } } diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/devices/DevicePage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/devices/DevicePage.kt index 45891a6..f452a00 100644 --- a/app/src/main/java/net/pipe01/pinepartner/pages/devices/DevicePage.kt +++ b/app/src/main/java/net/pipe01/pinepartner/pages/devices/DevicePage.kt @@ -26,6 +26,7 @@ import net.pipe01.pinepartner.data.AppDatabase import net.pipe01.pinepartner.data.Watch import net.pipe01.pinepartner.devices.WatchState import net.pipe01.pinepartner.service.BackgroundService +import net.pipe01.pinepartner.utils.PineError @SuppressLint("MissingPermission") @Composable @@ -35,7 +36,7 @@ fun DevicePage( backgroundService: BackgroundService, onUploadFirmware: () -> Unit, onBrowseFiles: () -> Unit, - onError: (Error) -> Unit, + onError: (PineError) -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -50,12 +51,12 @@ fun DevicePage( } backgroundService.connectWatch(deviceAddress).onFailure { - onError(Error("Failed to connect to watch", it)) + onError(PineError("Failed to connect to watch", it)) return@launch } state = backgroundService.getWatchState(deviceAddress).onFailure { - onError(Error("Failed to get watch state", it)) + onError(PineError("Failed to get watch state", it)) }.getOrNull() } } @@ -80,7 +81,7 @@ fun DevicePage( Button(onClick = { coroutineScope.launch { backgroundService.sendTestNotification().onFailure { - onError(Error("Failed to send test notification", it)) + onError(PineError("Failed to send test notification", it)) } } }) { diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/devices/DevicesPage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/devices/DevicesPage.kt index 3a1de13..a0ba8b5 100644 --- a/app/src/main/java/net/pipe01/pinepartner/pages/devices/DevicesPage.kt +++ b/app/src/main/java/net/pipe01/pinepartner/pages/devices/DevicesPage.kt @@ -38,6 +38,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import net.pipe01.pinepartner.BuildConfig import net.pipe01.pinepartner.components.Header import net.pipe01.pinepartner.components.LoadingStandIn import net.pipe01.pinepartner.data.AppDatabase @@ -45,6 +46,7 @@ import net.pipe01.pinepartner.data.Watch import net.pipe01.pinepartner.devices.Device import net.pipe01.pinepartner.devices.WatchState import net.pipe01.pinepartner.service.BackgroundService +import net.pipe01.pinepartner.utils.PineError import net.pipe01.pinepartner.utils.composables.BoxWithFAB @@ -54,7 +56,7 @@ fun DevicesPage( backgroundService: BackgroundService, onAddDevice: () -> Unit, onDeviceClick: (address: String) -> Unit, - onError: (Error) -> Unit, + onError: (PineError) -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -114,6 +116,17 @@ fun DevicesPage( ) } } + + if (BuildConfig.DEBUG) { + Button(onClick = { + CoroutineScope(Dispatchers.IO).launch { + backgroundService.crash() + Log.d("DevicesPage", "Crashed the service") + } + }) { + Text(text = "Crash") + } + } } } } @@ -127,7 +140,7 @@ private fun DeviceItem( backgroundService: BackgroundService, onClick: () -> Unit, onRemoveDevice: () -> Unit, - onError: (Error) -> Unit, + onError: (PineError) -> Unit, ) { val haptics = LocalHapticFeedback.current @@ -139,7 +152,7 @@ private fun DeviceItem( withContext(Dispatchers.Main) { while (true) { state = backgroundService.getWatchState(watch.address).onFailure { - onError(Error("Failed to get watch state", it)) + onError(PineError("Failed to get watch state", it)) }.getOrNull() delay(1000) @@ -213,7 +226,7 @@ private fun DeviceItem( Button(onClick = { coroutineScope.launch { backgroundService.disconnectWatch(watch.address).onFailure { - onError(Error("Failed to disconnect watch", it)) + onError(PineError("Failed to disconnect watch", it)) } } diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/devices/FileBrowserPage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/devices/FileBrowserPage.kt index e677768..9e9548d 100644 --- a/app/src/main/java/net/pipe01/pinepartner/pages/devices/FileBrowserPage.kt +++ b/app/src/main/java/net/pipe01/pinepartner/pages/devices/FileBrowserPage.kt @@ -68,6 +68,7 @@ import net.pipe01.pinepartner.devices.blefs.File import net.pipe01.pinepartner.devices.blefs.joinPaths import net.pipe01.pinepartner.service.BackgroundService import net.pipe01.pinepartner.service.TransferProgress +import net.pipe01.pinepartner.utils.PineError import net.pipe01.pinepartner.utils.composables.BoxWithFAB import net.pipe01.pinepartner.utils.composables.ExpandableFAB import net.pipe01.pinepartner.utils.composables.PopupDialog @@ -81,7 +82,7 @@ fun FileBrowserPage( deviceAddress: String, path: String, onOpenFolder: (String) -> Unit, - onError: (Error) -> Unit, + onError: (PineError) -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -103,7 +104,7 @@ fun FileBrowserPage( backgroundService.listFiles(deviceAddress, path).fold( onSuccess = { files.addAll(it) }, - onFailure = { onError(Error("Failed to list files", it)) }, + onFailure = { onError(PineError("Failed to list files", it)) }, ) isLoading = false @@ -133,7 +134,7 @@ fun FileBrowserPage( } else { backgroundService.writeFile(deviceAddress, joinPaths(path, name), ByteArray(0)) }.onFailure { - onError(Error("Failed to create ${if (isFolder) "folder" else "file"}", it)) + onError(PineError("Failed to create ${if (isFolder) "folder" else "file"}", it)) } reload() } @@ -181,7 +182,7 @@ fun FileBrowserPage( }, onDeleteFile = { backgroundService.deleteFile(deviceAddress, it.fullPath).onFailure { - onError(Error("Failed to delete file", it)) + onError(PineError("Failed to delete file", it)) } }, ) @@ -468,7 +469,7 @@ private fun UploadDialog( isExternalResources: Boolean, onDone: () -> Unit, onCancel: () -> Unit, - onError: (Error) -> Unit, + onError: (PineError) -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -489,7 +490,7 @@ private fun UploadDialog( } else { backgroundService.sendFile(jobId, deviceAddress, path, fileUri) }.onFailure { - onError(Error("Failed to upload file", it)) + onError(PineError("Failed to upload file", it)) } onDone() @@ -530,7 +531,7 @@ private fun UploadDialog( modifier = Modifier.align(Alignment.End), onClick = { backgroundService.cancelTransfer(jobId).onFailure { - onError(Error("Failed to cancel transfer", it)) + onError(PineError("Failed to cancel transfer", it)) } }, ) { diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginPage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginPage.kt index 3233d0a..73d423f 100644 --- a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginPage.kt +++ b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginPage.kt @@ -53,6 +53,7 @@ import net.pipe01.pinepartner.scripting.Permission import net.pipe01.pinepartner.scripting.StringType import net.pipe01.pinepartner.scripting.downloadPlugin import net.pipe01.pinepartner.service.BackgroundService +import net.pipe01.pinepartner.utils.PineError import java.time.ZoneOffset @Composable @@ -62,7 +63,7 @@ fun PluginPage( id: String, onRemoved: () -> Unit, onViewCode: () -> Unit, - onError: (Error) -> Unit, + onError: (PineError) -> Unit, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -83,7 +84,7 @@ fun PluginPage( resp.fold( onSuccess = { events.addAll(it) }, - onFailure = { onError(Error("Failed to get plugin events", it)) } + onFailure = { onError(PineError("Failed to get plugin events", it)) } ) delay(1000) @@ -98,7 +99,7 @@ fun PluginPage( onRemove = { coroutineScope.launch { backgroundService.deletePlugin(id).onFailure { - onError(Error("Failed to delete plugin", it)) + onError(PineError("Failed to delete plugin", it)) return@launch } @@ -121,7 +122,7 @@ fun PluginPage( plugin = newPlugin backgroundService.reloadPlugins().onFailure { - onError(Error("Failed to reload plugins", it)) + onError(PineError("Failed to reload plugins", it)) } Toast.makeText(context, "Plugin updated", Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginsPage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginsPage.kt index e8079bf..8075768 100644 --- a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginsPage.kt +++ b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginsPage.kt @@ -41,6 +41,7 @@ import net.pipe01.pinepartner.data.AppDatabase import net.pipe01.pinepartner.data.Plugin import net.pipe01.pinepartner.scripting.BuiltInPlugins import net.pipe01.pinepartner.service.BackgroundService +import net.pipe01.pinepartner.utils.PineError import net.pipe01.pinepartner.utils.composables.BoxWithFAB import net.pipe01.pinepartner.utils.composables.HeaderFrame @@ -50,7 +51,7 @@ fun PluginsPage( backgroundService: BackgroundService, onPluginClicked: (Plugin) -> Unit, onImportPlugin: () -> Unit, - onError: (Error) -> Unit, + onError: (PineError) -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -102,7 +103,7 @@ private fun PluginList( backgroundService: BackgroundService, plugins: MutableList, onPluginClicked: (Plugin) -> Unit, - onError: (Error) -> Unit, + onError: (PineError) -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -120,7 +121,7 @@ private fun PluginList( } else { backgroundService.disablePlugin(plugin.id) }.onFailure { - onError(Error("Failed to change plugin state", it)) + onError(PineError("Failed to change plugin state", it)) } val index = plugins.indexOf(plugin) diff --git a/app/src/main/java/net/pipe01/pinepartner/scripting/PluginManager.kt b/app/src/main/java/net/pipe01/pinepartner/scripting/PluginManager.kt index 0fd930b..c7bb05d 100644 --- a/app/src/main/java/net/pipe01/pinepartner/scripting/PluginManager.kt +++ b/app/src/main/java/net/pipe01/pinepartner/scripting/PluginManager.kt @@ -12,6 +12,8 @@ class PluginManager( private val runningPlugins = mutableMapOf() private val runningMutex = Mutex() + var disableRunning: Boolean = false + suspend fun reload() { val plugins = pluginDao.getAll() @@ -25,6 +27,10 @@ class PluginManager( } private suspend fun start(plugin: Plugin) { + if (disableRunning) { + return + } + runningMutex.withLock { runningPlugins[plugin.id]?.let { if (it.plugin.checksum == plugin.checksum) { diff --git a/app/src/main/java/net/pipe01/pinepartner/service/BackgroundService.kt b/app/src/main/java/net/pipe01/pinepartner/service/BackgroundService.kt index 8767eef..a4e235c 100644 --- a/app/src/main/java/net/pipe01/pinepartner/service/BackgroundService.kt +++ b/app/src/main/java/net/pipe01/pinepartner/service/BackgroundService.kt @@ -22,11 +22,13 @@ import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import com.google.android.gms.location.LocationServices +import io.sentry.Sentry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import net.pipe01.pinepartner.BuildConfig import net.pipe01.pinepartner.MainActivity import net.pipe01.pinepartner.NotificationReceivedAction import net.pipe01.pinepartner.R @@ -71,7 +73,7 @@ class BackgroundService : Service() { private var isStarted = false - val notifReceiver = object : BroadcastReceiver() { + private val notifReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { Log.d(TAG, "Received intent ${intent?.action}") @@ -110,6 +112,8 @@ class BackgroundService : Service() { } override fun onCreate() { + attachUnhandledExceptionHandler() + super.onCreate() db = AppDatabase.create(applicationContext) @@ -150,6 +154,18 @@ class BackgroundService : Service() { } } + private fun attachUnhandledExceptionHandler() { + if (!BuildConfig.DEBUG || true) { + Thread.setDefaultUncaughtExceptionHandler { _, e -> handleUncaughtException(e) } + } + } + + private fun handleUncaughtException(e: Throwable) { + Sentry.captureException(e) + Log.e(TAG, "Uncaught exception, stopping service", e) + stopSelf() + } + @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (!isStarted) { @@ -158,7 +174,10 @@ class BackgroundService : Service() { return START_STICKY } - Log.i(TAG, "Service started") + val disablePlugins = intent?.getBooleanExtra("disablePlugins", false) ?: false + pluginManager.disableRunning = disablePlugins + + Log.i(TAG, "Service started, disable plugins: $disablePlugins") val filter = IntentFilter() filter.addAction(NotificationReceivedAction) @@ -167,7 +186,9 @@ class BackgroundService : Service() { toggleNotificationListenerService() CoroutineScope(Dispatchers.Main).launch { - pluginManager.reload() + if (!disablePlugins) { + pluginManager.reload() + } db.watchDao().getAll().forEach { if (it.autoConnect) { @@ -220,6 +241,10 @@ class BackgroundService : Service() { class ServiceBinder(val service: BackgroundService) : Binder() + fun crash() { + throw RuntimeException("Crash test") + } + suspend fun sendTestNotification() = runCatching { for (device in deviceManager.connectedDevices) { device.sendNotification(0, 1, "Test", "This is a test notification") diff --git a/app/src/main/java/net/pipe01/pinepartner/service/ServiceHandle.kt b/app/src/main/java/net/pipe01/pinepartner/service/ServiceHandle.kt new file mode 100644 index 0000000..cfb1299 --- /dev/null +++ b/app/src/main/java/net/pipe01/pinepartner/service/ServiceHandle.kt @@ -0,0 +1,68 @@ +package net.pipe01.pinepartner.service + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import java.util.Timer +import kotlin.concurrent.schedule + +class ServiceHandle(private val context: Context) { + private val TAG = "ServiceHandle" + + private var restartsRemaining = 5 + + var service by mutableStateOf(null) + private set + var hasCrashed by mutableStateOf(false) + private set + + var pluginsDisabled by mutableStateOf(false) + private set + + private val conn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + hasCrashed = false + service = (binder as BackgroundService.ServiceBinder).service + + Log.d(TAG, "Service connected") + } + + override fun onServiceDisconnected(name: ComponentName?) { + hasCrashed = true + service = null + + Log.d(TAG, "Service disconnected") + + if (restartsRemaining-- > 0) { + Timer().schedule(2000) { + start(disablePlugins = restartsRemaining < 3) + } + } + } + } + + fun start(disablePlugins: Boolean = false) { + if (service != null) { + Log.w(TAG, "Tried to start service when it was already running") + return + } + + pluginsDisabled = disablePlugins + + val intent = Intent(context, BackgroundService::class.java) + intent.putExtra("disablePlugins", disablePlugins) + context.startForegroundService(intent) + + context.bindService(intent, conn, Context.BIND_ABOVE_CLIENT or Context.BIND_IMPORTANT) + } + + fun unbind() { + context.unbindService(conn) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/pipe01/pinepartner/utils/PineError.kt b/app/src/main/java/net/pipe01/pinepartner/utils/PineError.kt new file mode 100644 index 0000000..f09ad7b --- /dev/null +++ b/app/src/main/java/net/pipe01/pinepartner/utils/PineError.kt @@ -0,0 +1,8 @@ +package net.pipe01.pinepartner.utils + +class PineError( + message: String, + cause: Throwable, + val onTryAgain: (() -> Unit)? = null, + val onDismiss: (() -> Unit)? = null, +) : Exception(message, cause) diff --git a/app/src/main/java/net/pipe01/pinepartner/utils/composables/ErrorDialog.kt b/app/src/main/java/net/pipe01/pinepartner/utils/composables/ErrorDialog.kt index 3275ec3..90c8596 100644 --- a/app/src/main/java/net/pipe01/pinepartner/utils/composables/ErrorDialog.kt +++ b/app/src/main/java/net/pipe01/pinepartner/utils/composables/ErrorDialog.kt @@ -11,18 +11,21 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview +import net.pipe01.pinepartner.utils.PineError @Composable fun ErrorDialog( - error: Error, + error: PineError, onDismissRequest: () -> Unit, - onTryAgain: (() -> Unit)? = null, ) { val context = LocalContext.current val clipboardManager = LocalClipboardManager.current AlertDialog( - onDismissRequest = onDismissRequest, + onDismissRequest = { + error.onDismiss?.invoke() + onDismissRequest() + }, dismissButton = { if (error.cause != null) { TextButton(onClick = { @@ -34,7 +37,7 @@ fun ErrorDialog( } }, confirmButton = { - onTryAgain?.let { + error.onTryAgain?.let { TextButton(onClick = it) { Text("Try again") } @@ -57,5 +60,5 @@ fun ErrorDialog( @Preview @Composable fun ErrorDialogPreview() { - ErrorDialog(Error("An error occurred", Exception("This is a test exception")), {}) + ErrorDialog(PineError("An error occurred", Exception("This is a test exception")), {}) } diff --git a/app/src/main/java/net/pipe01/pinepartner/utils/composables/PluginsDisabledDialog.kt b/app/src/main/java/net/pipe01/pinepartner/utils/composables/PluginsDisabledDialog.kt new file mode 100644 index 0000000..d32e2db --- /dev/null +++ b/app/src/main/java/net/pipe01/pinepartner/utils/composables/PluginsDisabledDialog.kt @@ -0,0 +1,24 @@ +package net.pipe01.pinepartner.utils.composables + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +@Composable +fun PluginsDisabledDialog(onDismissRequest: () -> Unit) { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(text = "OK") + } + }, + title = { + Text(text = "Plugins Disabled") + }, + text = { + Text(text = "The background service crashed repeatedly and has been restarted without plugins. To re-enable them, please restart the app.") + } + ) +} \ No newline at end of file