Skip to content
Merged
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 13 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.location.gps"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />

<!-- For Syncthing to run at all. -->
<uses-permission
Expand Down Expand Up @@ -43,7 +52,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<!-- For optionally running only when connected to specific Wi-Fi networks. -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"
tools:ignore="LeanbackUsesWifi" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"
Expand All @@ -70,7 +80,7 @@
android:supportsRtl="true"
android:theme="@style/Theme.BasicSync"
tools:targetApi="tiramisu"
tools:ignore="DataExtractionRules">
tools:ignore="MissingTvBanner">
<meta-data android:name="android.webkit.WebView.MetricsOptOut"
android:value="true" />
<meta-data android:name="android.webkit.WebView.EnableSafeBrowsing"
Expand All @@ -84,6 +94,7 @@
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.chiller3.basicsync.R
fun ErrorDetailsDialog(
message: String?,
onDismiss: () -> Unit,
showCopy: Boolean = true,
) {
val context = LocalContext.current

Expand All @@ -46,7 +47,7 @@ fun ErrorDetailsDialog(
}
},
dismissButton = {
message?.let {
if (message != null && showCopy) {
TextButton(
onClick = {
val clipboardManager = context.getSystemService(ClipboardManager::class.java)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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(),
) {
Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -246,6 +275,7 @@ fun SettingsScreen(
ErrorDetailsDialog(
message = message,
onDismiss = { showErrorDialog = null },
showCopy = !isTv,
)
}

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -149,6 +150,10 @@ fun WebUiScreen(
webView.evaluateJavascript("onDeviceIdScanned(\"${jsEscape(deviceId)}\");") {}
}

fun setTvMode(enable: Boolean) {
webView.evaluateJavascript("setTvMode($enable);") {}
}

val requestQrScanner = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 19 additions & 2 deletions app/src/main/java/com/chiller3/basicsync/ui/Preferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
)
Expand Down
36 changes: 36 additions & 0 deletions app/src/main/res/raw/webview_bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@
<string name="alert_browser_not_found">No web browser is available.</string>
<!-- Alert shown when opening the system file manager (DocumentsUI) and it is disabled or removed (eg. in custom ROM). -->
<string name="alert_documentsui_not_found">Android\'s builtin file manager (DocumentsUI) is unavailable.</string>
<!-- Alert shown when trying to disable "battery" optimizations on Android TV. -->
<string name="alert_tv_inhibit_battery_opt">Disabling \"battery\" optimizations requires manual steps on Android TV.</string>
<!-- Detailed message for alert shown when trying to disable "battery" optimizations on Android TV. -->
<string name="alert_tv_inhibit_battery_opt_details">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.</string>

<!-- Button shown in the local filesystem folder picker to create a new local folder. -->
<string name="dialog_new_folder_title">New folder</string>
Expand Down