diff --git a/README.md b/README.md index 2e01565..0ad93bb 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,20 @@ These broadcasts can also be sent via `adb`. For example: adb shell am broadcast -a com.chiller3.basicsync.AUTO_MODE com.chiller3.basicsync ``` +## Android TV + +BasicSync has basic support for Android TV. The UI is still the same phone/tablet UI, so navigation may be a bit awkward, but most functionality is accessible using the TV remote's arrow keys, including Syncthing's web UI. + +However, there are several things that cannot be supported due to Android TV's limitations: + +* Importing and exporting the configuration, as well as saving logs via debug mode, are not supported because Android TV does not include DocumentsUI (the system file manager and file picker). +* There is no way to see BasicSync's notification because Android TV does not support notifications from third party apps at all. BasicSync still needs to request the useless notifications permission to run Syncthing reliably in the background. +* Android TV has no UI for disabling "battery" optimizations, but it is also required for Syncthing to run reliably in the background. This must be done via adb instead: + + ```bash + adb shell dumpsys deviceidle whitelist +com.chiller3.basicsync + ``` + ## Persistent notification Android 14 and newer [no longer allow](https://developer.android.com/about/versions/14/behavior-changes-all#non-dismissable-notifications) regular apps to prevent persistent notifications from being dismissed. To work around this, BasicSync will automatically show the persistent notification again whenever it is dismissed. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7962f84..5eb435d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,15 @@ + + + - + + tools:ignore="MissingTvBanner"> + diff --git a/app/src/main/java/com/chiller3/basicsync/settings/ErrorDetailsDialog.kt b/app/src/main/java/com/chiller3/basicsync/settings/ErrorDetailsDialog.kt index e6114c0..c0bd71b 100644 --- a/app/src/main/java/com/chiller3/basicsync/settings/ErrorDetailsDialog.kt +++ b/app/src/main/java/com/chiller3/basicsync/settings/ErrorDetailsDialog.kt @@ -25,6 +25,7 @@ import com.chiller3.basicsync.R fun ErrorDetailsDialog( message: String?, onDismiss: () -> Unit, + showCopy: Boolean = true, ) { val context = LocalContext.current @@ -46,7 +47,7 @@ fun ErrorDetailsDialog( } }, dismissButton = { - message?.let { + if (message != null && showCopy) { TextButton( onClick = { val clipboardManager = context.getSystemService(ClipboardManager::class.java) diff --git a/app/src/main/java/com/chiller3/basicsync/settings/SettingsAlert.kt b/app/src/main/java/com/chiller3/basicsync/settings/SettingsAlert.kt index 49e4484..f08defd 100644 --- a/app/src/main/java/com/chiller3/basicsync/settings/SettingsAlert.kt +++ b/app/src/main/java/com/chiller3/basicsync/settings/SettingsAlert.kt @@ -6,6 +6,7 @@ package com.chiller3.basicsync.settings import android.net.Uri +import com.chiller3.basicsync.BuildConfig sealed interface SettingsAlert { data object ImportSucceeded : SettingsAlert @@ -25,4 +26,8 @@ sealed interface SettingsAlert { data class LogcatFailed(val uri: Uri, val error: String) : SettingsAlert data object BrowserNotFound : SettingsAlert + + data object TvInhibitBatteryOpt : SettingsAlert { + const val COMMAND = "adb shell dumpsys deviceidle whitelist +${BuildConfig.APPLICATION_ID}" + } } diff --git a/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt b/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt index d5da230..703b0bb 100644 --- a/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt +++ b/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt @@ -6,9 +6,11 @@ package com.chiller3.basicsync.settings import android.annotation.SuppressLint +import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.content.res.Configuration import android.os.BatteryManager import android.os.Build @@ -91,6 +93,8 @@ fun SettingsScreen( val context = LocalContext.current val resources = LocalResources.current + val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) } + val prefs = remember { Preferences(context) } var reloadPrefs by remember { mutableIntStateOf(0) } val isManualMode = remember(reloadPrefs) { prefs.isManualMode } @@ -121,6 +125,15 @@ fun SettingsScreen( val conflicts by viewModel.conflicts.collectAsStateWithLifecycle() val importExportState by viewModel.importExportState.collectAsStateWithLifecycle() + val requestInhibitBatteryOpt = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { + if (it.resultCode == Activity.RESULT_CANCELED && isTv) { + viewModel.addAlert(SettingsAlert.TvInhibitBatteryOpt) + } else { + reloadPerms++ + } + } val requestPermissionActivity = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), ) { @@ -215,6 +228,8 @@ fun SettingsScreen( resources.getString(R.string.alert_logcat_failure, alert.uri.formattedString) SettingsAlert.BrowserNotFound -> resources.getString(R.string.alert_browser_not_found) + SettingsAlert.TvInhibitBatteryOpt -> + resources.getString(R.string.alert_tv_inhibit_battery_opt) } val details = when (alert) { SettingsAlert.ImportSucceeded -> null @@ -226,12 +241,26 @@ fun SettingsScreen( is SettingsAlert.LogcatSucceeded -> null is SettingsAlert.LogcatFailed -> alert.error SettingsAlert.BrowserNotFound -> null + is SettingsAlert.TvInhibitBatteryOpt -> buildString { + append(resources.getString(R.string.alert_tv_inhibit_battery_opt_details)) + append("\n\n") + append(alert.COMMAND) + } + } + + // Snack bars are not focusable on TVs, so just show the dialog directly. + if (isTv) { + showErrorDialog = details } val result = params.snackbarHostState.showSnackbar( message = msg, - details?.let { resources.getString(R.string.action_details) }, - withDismissAction = true, + if (isTv) { + null + } else { + details?.let { resources.getString(R.string.action_details) } + }, + withDismissAction = !isTv, ) viewModel.acknowledgeFirstAlert() @@ -246,6 +275,7 @@ fun SettingsScreen( ErrorDetailsDialog( message = message, onDismiss = { showErrorDialog = null }, + showCopy = !isTv, ) } @@ -268,7 +298,7 @@ fun SettingsScreen( showExit = showExit, isDebugMode = isDebugMode, onInhibitBatteryOptGrant = { - requestPermissionActivity.launch(Permissions.getInhibitBatteryOptIntent(context)) + requestInhibitBatteryOpt.launch(Permissions.getInhibitBatteryOptIntent(context)) }, onNotificationsGrant = { requestPermissionsRequired.launch(Permissions.NOTIFICATION) diff --git a/app/src/main/java/com/chiller3/basicsync/settings/WebUiScreen.kt b/app/src/main/java/com/chiller3/basicsync/settings/WebUiScreen.kt index cc334a4..9961f6d 100644 --- a/app/src/main/java/com/chiller3/basicsync/settings/WebUiScreen.kt +++ b/app/src/main/java/com/chiller3/basicsync/settings/WebUiScreen.kt @@ -9,6 +9,7 @@ import android.annotation.SuppressLint import android.app.Activity.RESULT_OK import android.content.ActivityNotFoundException import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.net.http.SslCertificate import android.net.http.SslError @@ -149,6 +150,10 @@ fun WebUiScreen( webView.evaluateJavascript("onDeviceIdScanned(\"${jsEscape(deviceId)}\");") {} } + fun setTvMode(enable: Boolean) { + webView.evaluateJavascript("setTvMode($enable);") {} + } + val requestQrScanner = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult() ) { @@ -253,6 +258,10 @@ fun WebUiScreen( } view.evaluateJavascript(script) {} + + val isTv = context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + Log.d(TAG, "Setting TV mode: $isTv") + setTvMode(isTv) } override fun doUpdateVisitedHistory(view: WebView, url: String, isReload: Boolean) { diff --git a/app/src/main/java/com/chiller3/basicsync/ui/Preferences.kt b/app/src/main/java/com/chiller3/basicsync/ui/Preferences.kt index 7dab1f4..95ace70 100644 --- a/app/src/main/java/com/chiller3/basicsync/ui/Preferences.kt +++ b/app/src/main/java/com/chiller3/basicsync/ui/Preferences.kt @@ -42,8 +42,13 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role @@ -196,12 +201,14 @@ fun Preference( private fun PreferenceSwitch( checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, enabled: Boolean = true, switchColors: SwitchColors = PreferenceDefaults.switchColors(), ) { Switch( checked = checked, onCheckedChange = onCheckedChange, + modifier = modifier, enabled = enabled, thumbContent = { Icon( @@ -277,10 +284,17 @@ fun SplitSwitchPreference( switchColors: SwitchColors = PreferenceDefaults.switchColors(), title: @Composable () -> Unit, ) { + val preferenceFocus = remember { FocusRequester() } + val switchFocus = remember { FocusRequester() } + SegmentedListItem( onClick = onClick, shapes = shapes, - modifier = Modifier.widthIn(max = PreferenceDefaults.MaxWidth).then(modifier), + modifier = Modifier + .widthIn(max = PreferenceDefaults.MaxWidth) + .focusRequester(preferenceFocus) + .focusProperties { end = switchFocus } + .then(modifier), enabled = enabled, trailingContent = { Row(verticalAlignment = Alignment.CenterVertically) { @@ -306,12 +320,15 @@ fun SplitSwitchPreference( width = PreferenceDefaults.DividerWidth, height = PreferenceDefaults.DividerHeight, ) - .background(color = supportingContentColor) + .background(color = supportingContentColor), ) PreferenceSwitch( checked = checked, onCheckedChange = onCheckedChange, + modifier = Modifier + .focusRequester(switchFocus) + .focusProperties { start = preferenceFocus }, enabled = enabled, switchColors = switchColors, ) diff --git a/app/src/main/res/raw/webview_bridge.js b/app/src/main/res/raw/webview_bridge.js index 103f9c5..77b592e 100644 --- a/app/src/main/res/raw/webview_bridge.js +++ b/app/src/main/res/raw/webview_bridge.js @@ -200,3 +200,39 @@ if (!tryMutate()) { subtree: true, }); } + +// Prevent arrow keys from opening dropdown menus. There is no good way to leave the menu afterwards +// because the up/down arrow keys are hijacked to only move within the list and a TV remote has no +// escape button to close the menu. Spatial navigation already works very well for this use case. +$(document).off('keydown.bs.dropdown.data-api'); + +// This is atrocious. By default, the browser makes the up/down arrow keys adjust number inputs up +// and down. preventDefault() stops this, but also prevents using spatial navigation to move to the +// next element above or below the input. There is currently no way to trigger spatial navigation +// programmatically. Instead, we'll just make the field read-only for a bit so that the arrow keys +// don't change the value. +$(document).on('keydown', 'input', function (e) { + if ((e.which == 38 || e.which == 40) && e.target.type == 'number') { + const wasReadOnly = e.target.readOnly; + + e.target.readOnly = true; + if (!wasReadOnly) { + setTimeout(function() { e.target.readOnly = false; }, 100); + } + } +}); + +function setTvMode(enable) { + const currentStyle = document.getElementById('basicsync-tv-style'); + + if (enable == !!currentStyle) { + return; + } else if (enable) { + const style = document.createElement('style'); + style.id = 'basicsync-tv-style'; + style.innerHTML = ':focus { border: 3px dotted !important; }'; + document.body.appendChild(style); + } else { + document.body.removeChild(currentStyle); + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5251913..66de1e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -169,6 +169,10 @@ No web browser is available. Android\'s builtin file manager (DocumentsUI) is unavailable. + + Disabling \"battery\" optimizations requires manual steps on Android TV. + + Android TV has no UI for disabling \"battery\" optimizations, but it is still required to run Syncthing in the background reliably. This must be done by running the following adb command. New folder