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
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ The app is intentionally kept very basic so that the project is easy to maintain
## Features

* Supports Android 8 and newer
* Supports folders on [external storage](#external-storage)
* Supports external storage, like SD cards and USB drives
* Supports Android's [storage access framework](#storage-access-framework)
* Supports importing and exporting the configuration
* Supports pausing syncing based on network and battery conditions or on a periodic schedule
* Supports Android's HTTP proxy settings
Expand Down Expand Up @@ -51,7 +52,7 @@ The app is intentionally kept very basic so that the project is easy to maintain
* `ACCESS_NETWORK_STATE`
* Used for detecting when the device is connected to the network and if the network is unmetered.
* `MANAGE_EXTERNAL_STORAGE` (Android >=11), `READ_EXTERNAL_STORAGE`/`WRITE_EXTERNAL_STORAGE` (Android <11)
* Needed for Syncthing to access files on the internal storage (`/sdcard`).
* Optionally used for accessing internal and external storage when a folder is configured for direct file access instead of using Android's [storage access framework](#storage-access-framework).
* `RECEIVE_BOOT_COMPLETED`
* Needed for automatically starting Syncthing after a reboot.
* Autostart can be disabled from BasicSync's settings if desired.
Expand All @@ -73,21 +74,23 @@ Syncthing listens on the loopback interface and is available via `127.0.0.1:8384

For basic authentication, the password is the API token. Generating new API tokens is supported, but setting an arbitrary password is not. BasicSync will internally set the password to the API token on every start.

## External storage
## Storage access framework

BasicSync supports syncing files on external storage via Android's Storage Access Framework (SAF). This is primarily intended for use with SD cards and USB drives, but can work with well-behaved third party SAF provider apps as well.
**For GrapheneOS users**: Use the OS-level storage scopes feature instead. It allows direct file access without the limitations of Android's storage access framework described below.

Where possible, sync to internal storage instead. Using external storage is discouraged because SAF is not well suited for how Syncthing accesses files.
BasicSync supports file access via Android's storage access framework (SAF) as an alternative to direct file access. This limits permissions so that Syncthing only has access to specific folders instead of all internal and external storage.

However, despite not having granular permissions, using direct file access is still recommended. SAF is discouraged because it is not well suited for how Syncthing accesses files.

* Accessing files by path is extremely inefficient. For example, it is not possible to directly access a nested file like `a/b/c`. Instead, Android will list every file in `a` and `b` and query their metadata before `c` is accessible. To reduce the impact of this, BasicSync caches directory listings and file metadata, but it will still be significantly slower than internal storage.

* SAF cannot report any watcher events more specific than "some file changed in this folder". When a file is changed, Syncthing needs to rescan the folder it's stored in instead of just the file. That said, leaving file watchers enabled is generally still a good idea if files aren't being frequently changed.

* SAF cannot guarantee that a creating a file will have the expected filename. When multiple processes try to create the same file at the same time, `file.txt` may be created as, for example, `file (1).txt`. BasicSync tries to reduce the chance of this happening within Syncthing, but it cannot protect from other apps writing to the same files.

When using external storage, there is higher chance of running into unexpected sync errors. If they occur, wait 5 minutes for BasicSync's caches to expire and then trigger a rescan of the folder from Syncthing's web UI. Due to SAF's limitations, this is the best that can be done.
When using SAF, there is higher chance of running into unexpected sync errors. If they occur, wait 5 minutes for BasicSync's caches to expire and then trigger a rescan of the folder from Syncthing's web UI. Due to SAF's limitations, this is the best that can be done.

**NOTE**: External storage support is a BasicSync feature, not a Syncthing feature. BasicSync has a large amount of code for bridging Syncthing's custom filesystem support to SAF. This means if the Syncthing config is exported from BasicSync and imported into another app, folders on external storage will not work properly.
**NOTE**: SAF support is a BasicSync feature, not a Syncthing feature. BasicSync has a large amount of code for bridging Syncthing's custom filesystem support to SAF. This means if the Syncthing config is exported from BasicSync and imported into another app, folders using SAF will not work properly.

### SAF custom filesystem scheme

Expand Down
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
android:minSdkVersion="37"
tools:ignore="ProtectedPermissions" />

<!-- For Syncthing to access internal storage. -->
<!-- For Syncthing to access local storage. -->
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="AllFilesAccessPolicy,ScopedStorage" />
Expand Down
8 changes: 3 additions & 5 deletions app/src/main/java/com/chiller3/basicsync/Notifications.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,11 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import com.chiller3.basicsync.extension.expandTilde
import com.chiller3.basicsync.extension.shortenTilde
import com.chiller3.basicsync.extension.toSingleLineString
import com.chiller3.basicsync.settings.ConflictsActivity
import com.chiller3.basicsync.settings.SettingsActivity
import com.chiller3.basicsync.settings.WebUiActivity
import com.chiller3.basicsync.syncthing.SyncthingService
import java.io.File

class Notifications(private val context: Context) {
companion object {
Expand Down Expand Up @@ -257,7 +254,8 @@ class Notifications(private val context: Context) {
notificationManager.notify(ID_FAILURE, notification)
}

fun sendOrClearConflictsNotification(conflicts: List<String>) {
fun sendOrClearConflictsNotification(conflictsInfo: SyncthingService.ConflictsInfo) {
val conflicts = conflictsInfo.items(context)
if (conflicts.isEmpty()) {
notificationManager.cancel(ID_CONFLICTS)
return
Expand All @@ -273,7 +271,7 @@ class Notifications(private val context: Context) {
append('…')
break
} else {
append(File(conflict).expandTilde().shortenTilde())
append(conflict.displayName)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import java.io.File
private val HOME = File("~")
@SuppressLint("SdCardPath")
private val SDCARD = File("/sdcard")
val EXTERNAL_DIR: File = Environment.getExternalStorageDirectory()
private val EXTERNAL_DIR: File = Environment.getExternalStorageDirectory()

fun File.expandTilde(): File =
if (startsWith(HOME)) {
Expand All @@ -23,15 +23,9 @@ fun File.expandTilde(): File =
this
}

fun File.shortenTilde(): File {
val relPath = relativeToOrSelf(EXTERNAL_DIR)
val relPathString = relPath.toString()

return if (relPathString.isEmpty()) {
HOME
} else if (!relPath.isAbsolute) {
File(HOME, relPathString)
fun File.shortenTilde(): File =
if (startsWith(EXTERNAL_DIR)) {
File(HOME, toRelativeString(EXTERNAL_DIR))
} else {
relPath
this
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2026 Andrew Gunnerson
* SPDX-License-Identifier: GPL-3.0-only
*/

package com.chiller3.basicsync.extension

import android.annotation.SuppressLint
import android.os.Build
import android.os.Environment
import android.os.storage.StorageVolume
import java.io.File

val StorageVolume.directoryCompat: File?
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
directory
} else {
when (state) {
Environment.MEDIA_MOUNTED, Environment.MEDIA_MOUNTED_READ_ONLY ->
@SuppressLint("DiscouragedPrivateApi")
javaClass.getDeclaredMethod("getPathFile").invoke(this) as File?
else -> null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import android.content.ContentResolver
import android.net.Uri

const val DOCUMENTSUI_AUTHORITY = "com.android.externalstorage.documents"
const val DOCUMENTSUI_PRIMARY_ID = "primary"

val Uri.formattedString: String
get() = when (scheme) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.provider.DocumentsContract
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
Expand All @@ -22,14 +21,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.chiller3.basicsync.R
import com.chiller3.basicsync.extension.DOCUMENTSUI_AUTHORITY
import com.chiller3.basicsync.extension.EXTERNAL_DIR
import com.chiller3.basicsync.extension.expandTilde
import com.chiller3.basicsync.extension.shortenTilde
import com.chiller3.basicsync.syncthing.SyncthingService
import com.chiller3.basicsync.ui.AppScreen
import com.chiller3.basicsync.ui.BetterSegmentedShapes
Expand All @@ -38,7 +32,6 @@ import com.chiller3.basicsync.ui.PreferenceColumn
import com.chiller3.basicsync.ui.PreferenceDefaults
import com.chiller3.basicsync.ui.betterSegmentedShapes
import com.chiller3.basicsync.ui.theme.AppTheme
import java.io.File

@Composable
fun ConflictsScreen(
Expand All @@ -47,12 +40,14 @@ fun ConflictsScreen(
) {
val context = LocalContext.current

var syncConflicts by rememberSaveable { mutableStateOf(emptyList<String>()) }
var syncConflicts by rememberSaveable {
mutableStateOf(emptyList<SyncthingService.ConflictItem>())
}
var showDocumentsUIAlert by rememberSaveable { mutableStateOf(false) }

rememberServiceEventWatcher(
listener = object : SyncthingService.ServiceListener {
override fun onMissingStoragePermissions(internal: Boolean, external: List<Uri>) {}
override fun onMissingStoragePermissions(local: Boolean, saf: List<Uri>) {}

override fun onExitRequested() = onExit()

Expand All @@ -66,7 +61,8 @@ fun ConflictsScreen(
exception: Exception?,
) {}

override fun onConflictsUpdated(conflicts: List<String>) {
override fun onConflictsUpdated(conflictsInfo: SyncthingService.ConflictsInfo) {
val conflicts = conflictsInfo.items(context)
if (conflicts.isEmpty()) {
onBack()
} else {
Expand All @@ -82,13 +78,9 @@ fun ConflictsScreen(
) { params ->
ConflictsContent(
conflicts = syncConflicts,
onConflictOpen = { relPath ->
onConflictOpen = { uri ->
try {
val intent = Intent(Intent.ACTION_VIEW).apply {
val uri = DocumentsContract.buildDocumentUri(
DOCUMENTSUI_AUTHORITY,
"primary:$relPath",
)
setDataAndType(uri, "vnd.android.document/directory")
}

Expand Down Expand Up @@ -117,8 +109,8 @@ fun ConflictsScreen(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ConflictsContent(
conflicts: List<String>,
onConflictOpen: (String) -> Unit,
conflicts: List<SyncthingService.ConflictItem>,
onConflictOpen: (Uri) -> Unit,
contentPadding: PaddingValues = PaddingValues(),
) {
PreferenceColumn(contentPadding = contentPadding) {
Expand All @@ -133,27 +125,11 @@ fun ConflictsContent(
)
}

itemsIndexed(conflicts, key = { _, n -> "conflict_$n" }) { index, path ->
val expanded: File
val shortened: File
val relPath: File

if (LocalInspectionMode.current) {
// EXTERNAL_DIR fails with NPE in compose previewer.
val externalDir = "/storage/emulated/0"
expanded = File(path.replaceFirst("~", externalDir))
shortened = File(path.replaceFirst(externalDir, "~"))
relPath = expanded.relativeTo(File(externalDir))
} else {
expanded = File(path).expandTilde()
shortened = expanded.shortenTilde()
relPath = expanded.relativeTo(EXTERNAL_DIR)
}

itemsIndexed(conflicts, key = { _, c -> "conflict_${c.displayName}" }) { index, conflict ->
Preference(
onClick = { onConflictOpen(relPath.toString()) },
onClick = { conflict.parentUri?.let(onConflictOpen) },
shapes = betterSegmentedShapes(index, conflicts.size),
title = { Text(text = shortened.toString()) },
title = { Text(text = conflict.displayName) },
modifier = Modifier.animateItem(),
)
}
Expand All @@ -172,8 +148,9 @@ fun ConflictsContent(
@Composable
private fun PreviewConflictsScreen() {
val conflicts = listOf(
"/storage/emulated/0/DCIM/Camera/picture.sync-conflict-20260101-000000-ABCDEFG.jpg",
"/storage/emulated/0/Sync/test.sync-conflict-20260101-000000-ABCDEFG.txt",
SyncthingService.ConflictItem("~/Sync/test.sync-conflict-20260101-000000-ABCDEFG.txt", null),
SyncthingService.ConflictItem("/storage/0000-0000/Sync/test.sync-conflict-20260101-000000-ABCDEFG.txt", null),
SyncthingService.ConflictItem("0000-0000:test.sync-conflict-20260101-000000-ABCDEFG.txt", null),
)

AppTheme {
Expand Down
Loading