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