diff --git a/README.md b/README.md
index ecb5981..5801a1f 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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.
@@ -73,11 +74,13 @@ 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.
@@ -85,9 +88,9 @@ Where possible, sync to internal storage instead. Using external storage is disc
* 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
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c641b6f..e5e1364 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -39,7 +39,7 @@
android:minSdkVersion="37"
tools:ignore="ProtectedPermissions" />
-
+
diff --git a/app/src/main/java/com/chiller3/basicsync/Notifications.kt b/app/src/main/java/com/chiller3/basicsync/Notifications.kt
index a8156c0..f889bbb 100644
--- a/app/src/main/java/com/chiller3/basicsync/Notifications.kt
+++ b/app/src/main/java/com/chiller3/basicsync/Notifications.kt
@@ -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 {
@@ -257,7 +254,8 @@ class Notifications(private val context: Context) {
notificationManager.notify(ID_FAILURE, notification)
}
- fun sendOrClearConflictsNotification(conflicts: List) {
+ fun sendOrClearConflictsNotification(conflictsInfo: SyncthingService.ConflictsInfo) {
+ val conflicts = conflictsInfo.items(context)
if (conflicts.isEmpty()) {
notificationManager.cancel(ID_CONFLICTS)
return
@@ -273,7 +271,7 @@ class Notifications(private val context: Context) {
append('…')
break
} else {
- append(File(conflict).expandTilde().shortenTilde())
+ append(conflict.displayName)
}
}
}
diff --git a/app/src/main/java/com/chiller3/basicsync/extension/FileExtensions.kt b/app/src/main/java/com/chiller3/basicsync/extension/FileExtensions.kt
index 05daad8..9270af4 100644
--- a/app/src/main/java/com/chiller3/basicsync/extension/FileExtensions.kt
+++ b/app/src/main/java/com/chiller3/basicsync/extension/FileExtensions.kt
@@ -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)) {
@@ -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
}
-}
diff --git a/app/src/main/java/com/chiller3/basicsync/extension/StorageVolumeExtensions.kt b/app/src/main/java/com/chiller3/basicsync/extension/StorageVolumeExtensions.kt
new file mode 100644
index 0000000..cdab570
--- /dev/null
+++ b/app/src/main/java/com/chiller3/basicsync/extension/StorageVolumeExtensions.kt
@@ -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
+ }
+ }
diff --git a/app/src/main/java/com/chiller3/basicsync/extension/UriExtensions.kt b/app/src/main/java/com/chiller3/basicsync/extension/UriExtensions.kt
index a606a6a..a8077bb 100644
--- a/app/src/main/java/com/chiller3/basicsync/extension/UriExtensions.kt
+++ b/app/src/main/java/com/chiller3/basicsync/extension/UriExtensions.kt
@@ -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) {
diff --git a/app/src/main/java/com/chiller3/basicsync/settings/ConflictsScreen.kt b/app/src/main/java/com/chiller3/basicsync/settings/ConflictsScreen.kt
index 4dfec56..a63131f 100644
--- a/app/src/main/java/com/chiller3/basicsync/settings/ConflictsScreen.kt
+++ b/app/src/main/java/com/chiller3/basicsync/settings/ConflictsScreen.kt
@@ -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
@@ -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
@@ -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(
@@ -47,12 +40,14 @@ fun ConflictsScreen(
) {
val context = LocalContext.current
- var syncConflicts by rememberSaveable { mutableStateOf(emptyList()) }
+ var syncConflicts by rememberSaveable {
+ mutableStateOf(emptyList())
+ }
var showDocumentsUIAlert by rememberSaveable { mutableStateOf(false) }
rememberServiceEventWatcher(
listener = object : SyncthingService.ServiceListener {
- override fun onMissingStoragePermissions(internal: Boolean, external: List) {}
+ override fun onMissingStoragePermissions(local: Boolean, saf: List) {}
override fun onExitRequested() = onExit()
@@ -66,7 +61,8 @@ fun ConflictsScreen(
exception: Exception?,
) {}
- override fun onConflictsUpdated(conflicts: List) {
+ override fun onConflictsUpdated(conflictsInfo: SyncthingService.ConflictsInfo) {
+ val conflicts = conflictsInfo.items(context)
if (conflicts.isEmpty()) {
onBack()
} else {
@@ -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")
}
@@ -117,8 +109,8 @@ fun ConflictsScreen(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ConflictsContent(
- conflicts: List,
- onConflictOpen: (String) -> Unit,
+ conflicts: List,
+ onConflictOpen: (Uri) -> Unit,
contentPadding: PaddingValues = PaddingValues(),
) {
PreferenceColumn(contentPadding = contentPadding) {
@@ -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(),
)
}
@@ -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 {
diff --git a/app/src/main/java/com/chiller3/basicsync/settings/FolderPickerDialog.kt b/app/src/main/java/com/chiller3/basicsync/settings/FolderPickerDialog.kt
index e431100..0d3ea2c 100644
--- a/app/src/main/java/com/chiller3/basicsync/settings/FolderPickerDialog.kt
+++ b/app/src/main/java/com/chiller3/basicsync/settings/FolderPickerDialog.kt
@@ -18,6 +18,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.res.stringResource
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -25,7 +27,7 @@ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.rememberViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import com.chiller3.basicsync.R
-import com.chiller3.basicsync.extension.EXTERNAL_DIR
+import com.chiller3.basicsync.extension.shortenTilde
import com.chiller3.basicsync.ui.Preference
import com.chiller3.basicsync.ui.PreferenceColumn
import com.chiller3.basicsync.ui.betterSegmentedShapes
@@ -48,49 +50,62 @@ fun FolderPickerDialog(
onSelect: (String) -> Unit,
onDismiss: () -> Unit,
) {
+ val context = LocalContext.current
val scopedOwner = rememberViewModelStoreOwner()
CompositionLocalProvider(LocalViewModelStoreOwner provides scopedOwner) {
- val viewModel: FolderPickerViewModel = viewModel()
- rememberSaveable {
+ val viewModel = viewModel {
val initialPath = when (initialLocation) {
- FolderPickerLocation.Default -> EXTERNAL_DIR
+ FolderPickerLocation.Default -> FolderPickerViewModel.VIRTUAL_ROOT
is FolderPickerLocation.Path -> File(initialLocation.path)
}
- viewModel.navigate(initialPath)
- true
+
+ FolderPickerViewModel(context, initialPath)
}
val state by viewModel.state.collectAsStateWithLifecycle()
- val hasParent = ".." in state.childDirs
+ val isRoot = state.cwd == FolderPickerViewModel.VIRTUAL_ROOT
+ val shortCwd = state.cwd.shortenTilde().toString()
var showNewFolderDialog by rememberSaveable { mutableStateOf(false) }
AlertDialog(
- title = { Text(text = state.shortCwd.toString()) },
+ title = {
+ if (!isRoot) {
+ Text(text = shortCwd)
+ }
+ },
text = {
PreferenceColumn(fillScreen = false) {
- itemsIndexed(state.childDirs, key = { _, c -> c }) { index, childDir ->
+ itemsIndexed(state.childDirs, key = { _, c -> c.cdPath }) { index, childDir ->
Preference(
- onClick = { viewModel.navigate(File(childDir)) },
+ onClick = { viewModel.navigate(state.cwd, childDir.cdPath) },
shapes = betterSegmentedShapes(
index = index,
count = state.childDirs.size,
),
- title = { Text(text = "$childDir/") },
+ enabled = childDir.enabled,
+ title = { Text(text = childDir.title) },
+ summary = childDir.summary?.let { { Text(text = it) } },
+ // This is uglier, but having the fade out animation delays the
+ // shrinking of the dialog window noticeably when navigating to a
+ // smaller directory.
+ modifier = Modifier.animateItem(fadeOutSpec = null),
)
}
}
BackHandler(
- enabled = hasParent,
- onBack = { viewModel.navigate(File("..")) },
+ enabled = !isRoot,
+ onBack = { viewModel.navigate(state.cwd, File("..")) },
)
},
onDismissRequest = onDismiss,
confirmButton = {
- TextButton(onClick = { onSelect(state.shortCwd.toString()) }) {
- Text(text = stringResource(android.R.string.ok))
+ if (!isRoot) {
+ TextButton(onClick = { onSelect(shortCwd) }) {
+ Text(text = stringResource(android.R.string.ok))
+ }
}
},
dismissButton = {
@@ -98,21 +113,23 @@ fun FolderPickerDialog(
Text(text = stringResource(android.R.string.cancel))
}
- TextButton(onClick = { showNewFolderDialog = true }) {
- Text(text = stringResource(R.string.dialog_new_folder_title))
+ if (!isRoot) {
+ TextButton(onClick = { showNewFolderDialog = true }) {
+ Text(text = stringResource(R.string.dialog_new_folder_title))
+ }
}
},
properties = DialogProperties(
- dismissOnBackPress = !hasParent,
+ dismissOnBackPress = isRoot,
dismissOnClickOutside = false,
),
)
if (showNewFolderDialog) {
NewFolderDialog(
- cwd = state.shortCwd.toString(),
- onSelect = { name ->
- viewModel.mkdir(File(name))
+ cwd = state.cwd,
+ onSelect = { cwd, name ->
+ viewModel.mkdir(cwd, name)
showNewFolderDialog = false
},
onDismiss = {
diff --git a/app/src/main/java/com/chiller3/basicsync/settings/FolderPickerViewModel.kt b/app/src/main/java/com/chiller3/basicsync/settings/FolderPickerViewModel.kt
index 0e423ab..ad2e3f2 100644
--- a/app/src/main/java/com/chiller3/basicsync/settings/FolderPickerViewModel.kt
+++ b/app/src/main/java/com/chiller3/basicsync/settings/FolderPickerViewModel.kt
@@ -5,83 +5,321 @@
package com.chiller3.basicsync.settings
+import android.content.BroadcastReceiver
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import android.os.Environment
+import android.os.FileObserver
+import android.os.storage.StorageManager
+import android.util.Log
+import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.chiller3.basicsync.extension.EXTERNAL_DIR
+import com.chiller3.basicsync.extension.directoryCompat
import com.chiller3.basicsync.extension.expandTilde
-import com.chiller3.basicsync.extension.shortenTilde
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.text.Collator
-class FolderPickerViewModel : ViewModel() {
+class FolderPickerViewModel(context: Context, initialPath: File) : ViewModel() {
companion object {
+ private val TAG = FolderPickerViewModel::class.java.simpleName
+
// Same sorting method as AOSP's DocumentsUI.
private val COLLATOR = Collator.getInstance().apply {
strength = Collator.SECONDARY
}
+
+ val VIRTUAL_ROOT = File("")
+
+ private val SUPPORTS_INOTIFY = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ private val WRITABLE_EXTERNAL = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+ }
+
+ sealed interface Child {
+ val enabled: Boolean
+ val pinToTop: Boolean
+ val title: String
+ val summary: String?
+ val cdPath: File
+ }
+
+ data class Root(
+ val writable: Boolean,
+ val isPrimary: Boolean,
+ val description: String,
+ val uuid: String?,
+ val mountPoint: File,
+ ) : Child {
+ override val enabled: Boolean
+ get() = writable
+
+ override val pinToTop: Boolean
+ get() = isPrimary
+
+ override val title: String
+ get() = description
+
+ override val summary: String?
+ get() = uuid
+
+ override val cdPath: File
+ get() = mountPoint
+ }
+
+ data class Directory(val name: String) : Child {
+ override val enabled: Boolean
+ get() = true
+
+ override val pinToTop: Boolean
+ get() = false
+
+ override val title: String
+ get() = "$name/"
+
+ override val summary: String?
+ get() = null
+
+ override val cdPath: File
+ get() = File(name)
}
data class State(
val cwd: File,
- // Can include "..".
- val childDirs: List,
- ) {
- val shortCwd: File
- get() = cwd.shortenTilde()
- }
+ val childDirs: List,
+ )
+
+ private val appContext = context.applicationContext
+ private val storageManager = appContext.getSystemService(StorageManager::class.java)
- private val _state = MutableStateFlow(State(cwd = EXTERNAL_DIR, childDirs = emptyList()))
+ private val _state = MutableStateFlow(State(cwd = VIRTUAL_ROOT, childDirs = emptyList()))
val state = _state.asStateFlow()
- fun navigate(path: File) {
- viewModelScope.launch {
- withContext(Dispatchers.IO) {
- var newCwd = _state.value.cwd.resolve(path.expandTilde()).normalize()
+ private val mainLock = this
+ private val operationLock = Mutex()
+ private var roots = emptyList()
+ @RequiresApi(Build.VERSION_CODES.Q)
+ private var observer: FileObserver? = null
- // Don't allow paths outside of the internal storage. They aren't readable anyway.
- var relPath = newCwd.toRelativeString(EXTERNAL_DIR)
- if (relPath == ".." || relPath.startsWith("../") || !newCwd.isDirectory) {
- newCwd = EXTERNAL_DIR
- relPath = ""
- }
+ private val rootsReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ Log.d(TAG, "StorageManager event: ${intent?.action}")
- val children = newCwd.listFiles() ?: return@withContext
- children.sortWith(Comparator { a, b -> COLLATOR.compare(a.name, b.name) })
+ launchOperation {
+ refreshRootsLocked()
+ refreshIfCwdLocked(VIRTUAL_ROOT)
+ }
+ }
+ }
- val childDirs = ArrayList().apply {
- if (relPath.isNotEmpty()) {
- add("..")
- }
+ init {
+ appContext.registerReceiver(
+ rootsReceiver,
+ // Keep in sync with android.os.storage.VolumeInfo.sEnvironmentToBroadcast.
+ IntentFilter().apply {
+ addAction(Intent.ACTION_MEDIA_UNMOUNTED)
+ addAction(Intent.ACTION_MEDIA_CHECKING)
+ addAction(Intent.ACTION_MEDIA_MOUNTED)
+ addAction(Intent.ACTION_MEDIA_MOUNTED)
+ addAction(Intent.ACTION_MEDIA_EJECT)
+ addAction(Intent.ACTION_MEDIA_UNMOUNTABLE)
+ addAction(Intent.ACTION_MEDIA_REMOVED)
+ addAction(Intent.ACTION_MEDIA_BAD_REMOVAL)
+ addDataScheme(ContentResolver.SCHEME_FILE)
+ },
+ )
- children
- .asSequence()
- .filter { it.isDirectory }
- .map { it.name }
- .toCollection(this)
- }
+ launchOperation {
+ refreshRootsLocked()
- _state.update { State(cwd = newCwd, childDirs = childDirs) }
+ if (initialPath == VIRTUAL_ROOT) {
+ val usableRoots = synchronized(mainLock) {
+ roots.asSequence().filter { it.enabled }.iterator()
+ }
+ if (usableRoots.hasNext()) {
+ val root = usableRoots.next()
+ if (!usableRoots.hasNext()) {
+ // Navigate to the only usable root initially to avoid an extra tap. The
+ // user can always navigate back if external storage is later attached.
+ navigateLocked(root.mountPoint, File("."))
+ return@launchOperation
+ }
+ }
}
+
+ navigateLocked(initialPath.expandTilde(), File("."))
}
}
- fun mkdir(path: File) {
- viewModelScope.launch {
- val cwd = _state.value.cwd
+ override fun onCleared() {
+ Log.d(TAG, "Cleared")
+
+ appContext.unregisterReceiver(rootsReceiver)
+ if (SUPPORTS_INOTIFY) {
+ synchronized(mainLock) {
+ observer?.stopWatching()
+ observer = null
+ }
+ }
+ }
+
+ private fun launchOperation(block: suspend CoroutineScope.() -> Unit): Job {
+ return viewModelScope.launch {
withContext(Dispatchers.IO) {
- val newPath = cwd.resolve(path).normalize()
+ operationLock.withLock {
+ block()
+ }
+ }
+ }
+ }
+
+ private fun refreshRootsLocked() {
+ Log.d(TAG, "Refreshing roots")
+
+ val newRoots = mutableListOf()
- newPath.mkdir()
+ for (volume in storageManager.storageVolumes) {
+ val mountPoint = volume.directoryCompat ?: continue
+
+ newRoots.add(Root(
+ writable = volume.state == Environment.MEDIA_MOUNTED
+ && (volume.isPrimary || WRITABLE_EXTERNAL),
+ isPrimary = volume.isPrimary,
+ description = volume.getDescription(appContext),
+ uuid = volume.uuid,
+ mountPoint = mountPoint,
+ ))
+ }
+
+ Log.d(TAG, "New roots: $newRoots")
+
+ synchronized(mainLock) {
+ roots = newRoots
+ }
+ }
+
+ private fun canBrowse(file: File) = synchronized(mainLock) {
+ roots.any { file.startsWith(it.mountPoint) }
+ }
+
+ private fun navigateLocked(cwd: File, path: File) {
+ var newCwd = VIRTUAL_ROOT
+ if (path != VIRTUAL_ROOT) {
+ cwd.resolve(path)
+ .normalize()
+ .takeIf { canBrowse(it) && it.isDirectory }
+ ?.let { newCwd = it }
+ }
+ Log.d(TAG, "Changing cwd: $cwd + $path = $newCwd")
+
+ val childDirs = if (newCwd == VIRTUAL_ROOT) {
+ synchronized(mainLock) { roots.toMutableList() }
+ } else {
+ mutableListOf().apply {
+ add(Directory(".."))
+
+ (newCwd.listFiles() ?: emptyArray())
+ .asSequence()
+ .filter { it.isDirectory }
+ .map { Directory(it.name) }
+ .toCollection(this)
+ }
+ }
+
+ childDirs.sortWith { a, b ->
+ compareValuesBy(
+ a,
+ b,
+ { !it.pinToTop },
+ { COLLATOR.getCollationKey(it.title) },
+ { COLLATOR.getCollationKey(it.summary) },
+ )
+ }
+
+ synchronized(mainLock) {
+ if (SUPPORTS_INOTIFY && newCwd != cwd) {
+ observer?.stopWatching()
+
+ if (newCwd == VIRTUAL_ROOT) {
+ observer = null
+ } else {
+ Log.d(TAG, "Watching: $newCwd")
+ observer = DirectoryObserver(newCwd, this).apply {
+ startWatching()
+ }
+ }
}
+ }
- navigate(cwd)
+ _state.update { State(cwd = newCwd, childDirs = childDirs) }
+ }
+
+ private fun refreshIfCwdLocked(expectedCwd: File) {
+ val cwd = _state.value.cwd
+ if (cwd == expectedCwd) {
+ navigateLocked(cwd, File("."))
+ } else {
+ Log.d(TAG, "Skipping refresh because of current cwd: $cwd != $expectedCwd")
+ }
+ }
+
+ fun navigate(cwd: File, path: File) {
+ Log.d(TAG, "Navigating to: $path")
+
+ launchOperation {
+ navigateLocked(cwd, path)
+ }
+ }
+
+ fun mkdir(cwd: File, name: String) {
+ Log.d(TAG, "Creating in: $cwd: $name")
+
+ if (!canBrowse(cwd)) {
+ // This is not an error because a volume may have been unmounted in the meantime.
+ Log.w(TAG, "Invalid cwd: $cwd")
+ return
+ } else if (!isSafeName(name)) {
+ throw IllegalArgumentException("Unsafe name: $name")
+ }
+
+ launchOperation {
+ cwd.resolve(name).mkdir()
+ refreshIfCwdLocked(cwd)
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ private class DirectoryObserver(
+ private val directory: File,
+ private val viewModel: FolderPickerViewModel,
+ ) : FileObserver(directory, EVENTS) {
+ companion object {
+ private const val EVENTS = CLOSE_WRITE or MOVED_FROM or MOVED_TO or DELETE or CREATE or
+ DELETE_SELF or MOVE_SELF
+ }
+
+ override fun onEvent(event: Int, path: String?) {
+ // The kernel can send unwanted events.
+ if (event and EVENTS != 0) {
+ Log.d(TAG, "inotify event $event: $path")
+
+ viewModel.launchOperation {
+ viewModel.refreshIfCwdLocked(directory)
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/chiller3/basicsync/settings/NewFolderDialog.kt b/app/src/main/java/com/chiller3/basicsync/settings/NewFolderDialog.kt
index a375c69..efb269a 100644
--- a/app/src/main/java/com/chiller3/basicsync/settings/NewFolderDialog.kt
+++ b/app/src/main/java/com/chiller3/basicsync/settings/NewFolderDialog.kt
@@ -24,11 +24,13 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.chiller3.basicsync.R
+import com.chiller3.basicsync.extension.shortenTilde
+import java.io.File
@Composable
fun NewFolderDialog(
- cwd: String,
- onSelect: (String) -> Unit,
+ cwd: File,
+ onSelect: (File, String) -> Unit,
onDismiss: () -> Unit,
) {
val input = rememberTextFieldState()
@@ -38,7 +40,7 @@ fun NewFolderDialog(
title = { Text(text = stringResource(R.string.dialog_new_folder_title)) },
text = {
Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) {
- Text(text = stringResource(R.string.dialog_new_folder_message, cwd))
+ Text(text = stringResource(R.string.dialog_new_folder_message, cwd.shortenTilde()))
OutlinedTextField(
state = input,
@@ -55,7 +57,7 @@ fun NewFolderDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
- onClick = { onSelect(name!!) },
+ onClick = { onSelect(cwd, name!!) },
enabled = name != null,
) {
Text(text = stringResource(android.R.string.ok))
@@ -73,9 +75,7 @@ fun NewFolderDialog(
)
}
-private fun tryParseInput(input: String): String? =
- if (input.isNotEmpty() && !input.contains('/') && input != "." && input != "..") {
- input
- } else {
- null
- }
+private fun tryParseInput(input: String): String? = input.takeIf(::isSafeName)
+
+fun isSafeName(name: String) =
+ name.isNotEmpty() && !name.contains('/') && name != "." && name != ".."
diff --git a/app/src/main/java/com/chiller3/basicsync/settings/ServiceEventWatcher.kt b/app/src/main/java/com/chiller3/basicsync/settings/ServiceEventWatcher.kt
index a329376..c2aad30 100644
--- a/app/src/main/java/com/chiller3/basicsync/settings/ServiceEventWatcher.kt
+++ b/app/src/main/java/com/chiller3/basicsync/settings/ServiceEventWatcher.kt
@@ -85,8 +85,8 @@ class ServiceEventWatcher internal constructor(
binder = null
}
- override fun onMissingStoragePermissions(internal: Boolean, external: List) {
- handler.post { listener.onMissingStoragePermissions(internal, external) }
+ override fun onMissingStoragePermissions(local: Boolean, saf: List) {
+ handler.post { listener.onMissingStoragePermissions(local, saf) }
}
override fun onExitRequested() {
@@ -107,8 +107,8 @@ class ServiceEventWatcher internal constructor(
handler.post { listener.onPreRunActionResult(preRunAction, exception) }
}
- override fun onConflictsUpdated(conflicts: List) {
- handler.post { listener.onConflictsUpdated(conflicts) }
+ override fun onConflictsUpdated(conflictsInfo: SyncthingService.ConflictsInfo) {
+ handler.post { listener.onConflictsUpdated(conflictsInfo) }
}
fun runWithBinder(block: (SyncthingService.ServiceBinder) -> Unit) {
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 06e63e0..048c5ba 100644
--- a/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt
@@ -15,6 +15,7 @@ import android.content.res.Configuration
import android.net.Uri
import android.os.BatteryManager
import android.os.Build
+import android.provider.DocumentsContract
import android.provider.Settings
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -56,6 +57,7 @@ import com.chiller3.basicsync.binding.stbridge.Stbridge
import com.chiller3.basicsync.extension.DOCUMENTSUI_AUTHORITY
import com.chiller3.basicsync.extension.formattedString
import com.chiller3.basicsync.syncthing.BlockedReason
+import com.chiller3.basicsync.syncthing.SyncthingSafClient
import com.chiller3.basicsync.syncthing.SyncthingService
import com.chiller3.basicsync.ui.AppScreen
import com.chiller3.basicsync.ui.BetterSegmentedShapes
@@ -129,7 +131,6 @@ fun SettingsScreen(
}
val serviceState by viewModel.serviceState.collectAsStateWithLifecycle()
- val conflicts by viewModel.conflicts.collectAsStateWithLifecycle()
val importExportState by viewModel.importExportState.collectAsStateWithLifecycle()
var storagePermissionRequest by rememberSaveable { mutableStateOf(false) }
@@ -171,11 +172,11 @@ fun SettingsScreen(
requestPermissionActivity.launch(Permissions.getAppInfoIntent(context))
}
}
- val requestSafExternalStorage = rememberLauncherForActivityResult(
+ val requestSafTree = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri?.let {
- SyncthingService.persistExternalStoragePermissions(context, it)
+ SyncthingService.persistSafPermissions(context, it)
renotifyOrRestartService()
}
}
@@ -201,14 +202,15 @@ fun SettingsScreen(
}
}
- var missingInternal by rememberSaveable { mutableStateOf(false) }
- var missingExternal by rememberSaveable { mutableStateOf(emptyList()) }
+ var missingLocal by rememberSaveable { mutableStateOf(false) }
+ var missingSaf by rememberSaveable { mutableStateOf(emptyList()) }
+ var conflicts by rememberSaveable { mutableIntStateOf(0) }
val watcher = rememberServiceEventWatcher(
listener = object : SyncthingService.ServiceListener {
- override fun onMissingStoragePermissions(internal: Boolean, external: List) {
- missingInternal = internal
- missingExternal = external
+ override fun onMissingStoragePermissions(local: Boolean, saf: List) {
+ missingLocal = local
+ missingSaf = saf
}
override fun onExitRequested() = onExit()
@@ -227,8 +229,8 @@ fun SettingsScreen(
viewModel.onPreRunActionResult(preRunAction, exception)
}
- override fun onConflictsUpdated(conflicts: List) {
- viewModel.conflicts.update { conflicts }
+ override fun onConflictsUpdated(conflictsInfo: SyncthingService.ConflictsInfo) {
+ conflicts = conflictsInfo.local.size + conflictsInfo.saf.size
}
},
)
@@ -318,9 +320,9 @@ fun SettingsScreen(
notificationsGranted = notificationsGranted,
localNetworkGranted = localNetworkGranted,
appHibernationDisabled = appHibernationDisabled,
- missingInternal = missingInternal,
- missingExternal = missingExternal,
- conflicts = conflicts ?: emptyList(),
+ missingLocalStorage = missingLocal,
+ missingSafStorage = missingSaf,
+ conflicts = conflicts,
isManualMode = isManualMode,
hasBattery = hasBattery,
runOnBattery = runOnBattery,
@@ -350,7 +352,7 @@ fun SettingsScreen(
)
)
},
- onInternalStorageGrant = {
+ onLocalStorageGrant = {
storagePermissionRequest = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -364,9 +366,20 @@ fun SettingsScreen(
requestPermissionsRequired.launch(Permissions.LEGACY_STORAGE)
}
},
- onExternalStorageGrant = { uri ->
+ onSafStorageGrant = { uri ->
+ // DocumentsUI does not support tree URIs for EXTRA_INITIAL_URI.
+ val documentUri = try {
+ DocumentsContract.buildDocumentUri(
+ uri.authority,
+ SyncthingSafClient.getNarrowestDocumentId(uri),
+ )
+ } catch (e: IllegalArgumentException) {
+ Log.w(TAG, "Invalid initial URI: $uri", e)
+ null
+ }
+
storagePermissionRequest = true
- requestSafExternalStorage.launch(uri)
+ requestSafTree.launch(documentUri)
},
onConflictsOpen = {
context.startActivity(Intent(context, ConflictsActivity::class.java))
@@ -543,9 +556,9 @@ private fun SettingsContent(
notificationsGranted: Boolean,
localNetworkGranted: Boolean,
appHibernationDisabled: Boolean,
- missingInternal: Boolean,
- missingExternal: List,
- conflicts: List,
+ missingLocalStorage: Boolean,
+ missingSafStorage: List,
+ conflicts: Int,
isManualMode: Boolean,
hasBattery: Boolean,
runOnBattery: Boolean,
@@ -562,8 +575,8 @@ private fun SettingsContent(
onNotificationsGrant: () -> Unit,
onLocalNetworkGrant: () -> Unit,
onAppHibernationDisable: () -> Unit,
- onInternalStorageGrant: () -> Unit,
- onExternalStorageGrant: (Uri) -> Unit,
+ onLocalStorageGrant: () -> Unit,
+ onSafStorageGrant: (Uri) -> Unit,
onConflictsOpen: () -> Unit,
onWebUiOpen: () -> Unit,
onConfigurationImport: () -> Unit,
@@ -626,20 +639,20 @@ private fun SettingsContent(
onGrant = onAppHibernationDisable,
))
}
- if (missingInternal) {
+ if (missingLocalStorage) {
add(MissingPermission(
- key = "missing_internal",
+ key = "missing_local",
title = stringResource(R.string.pref_local_storage_access_name),
summary = stringResource(R.string.pref_local_storage_access_desc),
- onGrant = onInternalStorageGrant,
+ onGrant = onLocalStorageGrant,
))
}
- for (uri in missingExternal) {
+ for (uri in missingSafStorage) {
add(MissingPermission(
- key = "missing_external:$uri",
- title = stringResource(R.string.pref_external_storage_access),
+ key = "missing_saf:$uri",
+ title = stringResource(R.string.pref_saf_folder_access_name),
summary = uri.formattedString,
- onGrant = { onExternalStorageGrant(uri) },
+ onGrant = { onSafStorageGrant(uri) },
))
}
}
@@ -675,12 +688,12 @@ private fun SettingsContent(
)
}
- if (conflicts.isNotEmpty()) {
+ if (conflicts > 0) {
item(key = "conflicts") {
val summary = pluralStringResource(
R.plurals.pref_conflicts_desc,
- conflicts.size,
- conflicts.size,
+ conflicts,
+ conflicts,
)
Preference(
@@ -697,7 +710,7 @@ private fun SettingsContent(
Preference(
onClick = onWebUiOpen,
enabled = runState?.webUiAvailable == true,
- shapes = if (conflicts.isNotEmpty()) {
+ shapes = if (conflicts > 0) {
BetterSegmentedShapes.middle()
} else {
BetterSegmentedShapes.top()
@@ -1019,9 +1032,9 @@ private fun PreviewSettingsScreen() {
notificationsGranted = false,
localNetworkGranted = false,
appHibernationDisabled = false,
- missingInternal = true,
- missingExternal = listOf("content://${DOCUMENTSUI_AUTHORITY}/tree/primary%3afile".toUri()),
- conflicts = listOf(""),
+ missingLocalStorage = true,
+ missingSafStorage = listOf("content://${DOCUMENTSUI_AUTHORITY}/tree/primary%3afile".toUri()),
+ conflicts = 1,
isManualMode = false,
hasBattery = true,
runOnBattery = true,
@@ -1038,8 +1051,8 @@ private fun PreviewSettingsScreen() {
onNotificationsGrant = {},
onLocalNetworkGrant = {},
onAppHibernationDisable = {},
- onInternalStorageGrant = {},
- onExternalStorageGrant = {},
+ onLocalStorageGrant = {},
+ onSafStorageGrant = {},
onConflictsOpen = {},
onWebUiOpen = {},
onConfigurationImport = {},
diff --git a/app/src/main/java/com/chiller3/basicsync/settings/SettingsViewModel.kt b/app/src/main/java/com/chiller3/basicsync/settings/SettingsViewModel.kt
index d78ed5e..e335896 100644
--- a/app/src/main/java/com/chiller3/basicsync/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/chiller3/basicsync/settings/SettingsViewModel.kt
@@ -52,8 +52,6 @@ class SettingsViewModel : ViewModel() {
val serviceState = MutableStateFlow(null)
- val conflicts = MutableStateFlow?>(null)
-
private val _importExportState = MutableStateFlow(null)
val importExportState = _importExportState.asStateFlow()
diff --git a/app/src/main/java/com/chiller3/basicsync/settings/StorageChoiceDialog.kt b/app/src/main/java/com/chiller3/basicsync/settings/StorageChoiceDialog.kt
index ee26f95..a192f0a 100644
--- a/app/src/main/java/com/chiller3/basicsync/settings/StorageChoiceDialog.kt
+++ b/app/src/main/java/com/chiller3/basicsync/settings/StorageChoiceDialog.kt
@@ -21,8 +21,8 @@ import kotlinx.parcelize.Parcelize
@Parcelize
enum class StorageChoice : Parcelable {
- INTERNAL,
- EXTERNAL,
+ LOCAL,
+ SAF,
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -39,16 +39,16 @@ fun StorageChoiceDialog(
PreferenceColumn(fillScreen = false) {
itemsIndexed(choices) { index, choice ->
val title = when (choice) {
- StorageChoice.INTERNAL ->
- stringResource(R.string.dialog_storage_type_internal_title)
- StorageChoice.EXTERNAL ->
- stringResource(R.string.dialog_storage_type_external_title)
+ StorageChoice.LOCAL ->
+ stringResource(R.string.dialog_storage_type_local_title)
+ StorageChoice.SAF ->
+ stringResource(R.string.dialog_storage_type_saf_title)
}
val summary = when (choice) {
- StorageChoice.INTERNAL ->
- stringResource(R.string.dialog_storage_type_internal_desc)
- StorageChoice.EXTERNAL ->
- stringResource(R.string.dialog_storage_type_external_desc)
+ StorageChoice.LOCAL ->
+ stringResource(R.string.dialog_storage_type_local_desc)
+ StorageChoice.SAF ->
+ stringResource(R.string.dialog_storage_type_saf_desc)
}
Preference(
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 0fb1503..b5a9b29 100644
--- a/app/src/main/java/com/chiller3/basicsync/settings/WebUiScreen.kt
+++ b/app/src/main/java/com/chiller3/basicsync/settings/WebUiScreen.kt
@@ -56,6 +56,8 @@ import com.chiller3.basicsync.BuildConfig
import com.chiller3.basicsync.Permissions
import com.chiller3.basicsync.R
import com.chiller3.basicsync.extension.DOCUMENTSUI_AUTHORITY
+import com.chiller3.basicsync.extension.DOCUMENTSUI_PRIMARY_ID
+import com.chiller3.basicsync.syncthing.SyncthingSafClient
import com.chiller3.basicsync.syncthing.SyncthingService
import com.chiller3.basicsync.ui.AppScreen
import com.chiller3.basicsync.ui.PreferenceDefaults
@@ -120,7 +122,7 @@ fun WebUiScreen(onExit: () -> Unit) {
var guiCert by remember { mutableStateOf(null) }
rememberServiceEventWatcher(
listener = object : SyncthingService.ServiceListener {
- override fun onMissingStoragePermissions(internal: Boolean, external: List) {}
+ override fun onMissingStoragePermissions(local: Boolean, saf: List) {}
override fun onExitRequested() = onExit()
@@ -155,7 +157,7 @@ fun WebUiScreen(onExit: () -> Unit) {
exception: Exception?,
) {}
- override fun onConflictsUpdated(conflicts: List) {}
+ override fun onConflictsUpdated(conflictsInfo: SyncthingService.ConflictsInfo) {}
},
)
@@ -241,11 +243,11 @@ fun WebUiScreen(onExit: () -> Unit) {
}
}
- val requestSafExternalStorage = rememberLauncherForActivityResult(
+ val requestSafTree = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri?.let {
- SyncthingService.persistExternalStoragePermissions(context, it)
+ SyncthingService.persistSafPermissions(context, it)
onFolderSelected("saf", SyncthingService.encodeSafUri(it))
}
@@ -257,7 +259,7 @@ fun WebUiScreen(onExit: () -> Unit) {
StorageChoiceDialog(
onSelect = { type ->
when (type) {
- StorageChoice.INTERNAL -> {
+ StorageChoice.LOCAL -> {
if (showFolderPicker(false)) {
// Already have permissions.
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -271,8 +273,8 @@ fun WebUiScreen(onExit: () -> Unit) {
requestPermissionsRequired.launch(Permissions.LEGACY_STORAGE)
}
}
- StorageChoice.EXTERNAL -> {
- requestSafExternalStorage.launch(existingFolderPath?.toUri())
+ StorageChoice.SAF -> {
+ requestSafTree.launch(existingFolderPath?.toUri())
existingFolderPath = null
}
}
@@ -319,10 +321,14 @@ fun WebUiScreen(onExit: () -> Unit) {
"saf" -> {
// DocumentsUI does not support tree URIs for EXTRA_INITIAL_URI.
val treeUri = SyncthingService.decodeSafUri(path).first
- existingFolderPath = DocumentsContract.buildDocumentUri(
- treeUri.authority,
- DocumentsContract.getTreeDocumentId(treeUri),
- ).toString()
+ try {
+ existingFolderPath = DocumentsContract.buildDocumentUri(
+ treeUri.authority,
+ SyncthingSafClient.getNarrowestDocumentId(treeUri),
+ ).toString()
+ } catch (e: IllegalArgumentException) {
+ Log.w(TAG, "Invalid initial URI: $treeUri", e)
+ }
}
else -> Log.w(TAG, "Ignoring unrecognized filesystem type: $filesystemType")
}
@@ -425,7 +431,9 @@ fun WebUiScreen(onExit: () -> Unit) {
val filesDir = context.getExternalFilesDir(null)!!
val relPath = filesDir.relativeTo(externalDir)
val uri = DocumentsContract.buildDocumentUri(
- DOCUMENTSUI_AUTHORITY, "primary:$relPath")
+ DOCUMENTSUI_AUTHORITY,
+ "$DOCUMENTSUI_PRIMARY_ID:$relPath",
+ )
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "vnd.android.document/directory")
}
diff --git a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingSafClient.kt b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingSafClient.kt
index 414d6ea..aa06340 100644
--- a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingSafClient.kt
+++ b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingSafClient.kt
@@ -20,6 +20,15 @@ import org.json.JSONObject
import java.io.IOException
class SyncthingSafClient(private val context: Context) : SafClient {
+ companion object {
+ fun getNarrowestDocumentId(uri: Uri): String =
+ try {
+ DocumentsContract.getDocumentId(uri)
+ } catch (_: IllegalArgumentException) {
+ DocumentsContract.getTreeDocumentId(uri)
+ }
+ }
+
private fun Cursor.asSequence() = generateSequence(seed = takeIf { it.moveToFirst() }) {
takeIf { it.moveToNext() }
}
@@ -68,13 +77,6 @@ class SyncthingSafClient(private val context: Context) : SafClient {
}
}
- private fun getNarrowestDocumentId(uri: Uri): String =
- try {
- DocumentsContract.getDocumentId(uri)
- } catch (_: IllegalArgumentException) {
- DocumentsContract.getTreeDocumentId(uri)
- }
-
override fun toTreeDocumentUri(treeUri: String): String {
val uri = treeUri.toUri()
val documentUri = DocumentsContract.buildDocumentUriUsingTree(
diff --git a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt
index 3e6a839..7909d2b 100644
--- a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt
+++ b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt
@@ -15,6 +15,8 @@ import android.net.Uri
import android.os.Binder
import android.os.Build
import android.os.IBinder
+import android.os.Parcelable
+import android.os.storage.StorageManager
import android.provider.DocumentsContract
import android.util.Log
import androidx.annotation.GuardedBy
@@ -28,6 +30,14 @@ import com.chiller3.basicsync.binding.stbridge.Stbridge
import com.chiller3.basicsync.binding.stbridge.SyncthingApp
import com.chiller3.basicsync.binding.stbridge.SyncthingStartupConfig
import com.chiller3.basicsync.binding.stbridge.SyncthingStatusReceiver
+import com.chiller3.basicsync.extension.DOCUMENTSUI_AUTHORITY
+import com.chiller3.basicsync.extension.DOCUMENTSUI_PRIMARY_ID
+import com.chiller3.basicsync.extension.directoryCompat
+import com.chiller3.basicsync.extension.expandTilde
+import com.chiller3.basicsync.extension.formattedString
+import com.chiller3.basicsync.extension.shortenTilde
+import kotlinx.parcelize.Parcelize
+import java.io.File
import java.io.IOException
import java.util.EnumSet
@@ -91,11 +101,11 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
context.startForegroundService(createIntent(context, action))
}
- private fun unpack0Sep(str0Sep: String): MutableList =
- mutableListOf().apply {
- if (str0Sep.isNotEmpty()) {
- str0Sep.splitToSequence('\u0000').toCollection(this)
- }
+ private fun unpack0Sep(str0Sep: String): Sequence =
+ if (str0Sep.isNotEmpty()) {
+ str0Sep.splitToSequence('\u0000')
+ } else {
+ emptySequence()
}
fun encodeSafUri(uri: Uri, path: String? = null) = buildString {
@@ -119,7 +129,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
return uri to path
}
- fun persistExternalStoragePermissions(context: Context, uri: Uri) {
+ fun persistSafPermissions(context: Context, uri: Uri) {
// Permissions are released in onCheckStoragePermissions() when unneeded.
context.contentResolver.takePersistableUriPermission(
uri,
@@ -267,6 +277,79 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
}
}
+ @Parcelize
+ data class ConflictItem(
+ val displayName: String,
+ val parentUri: Uri?,
+ ) : Parcelable
+
+ data class ConflictsInfo(
+ val local: List,
+ val saf: Map,
+ ) {
+ constructor() : this(
+ local = emptyList(),
+ saf = emptyMap(),
+ )
+
+ fun items(context: Context): List =
+ mutableListOf().apply {
+ val storageManager = context.getSystemService(StorageManager::class.java)
+
+ val mountPoints = mutableMapOf().apply {
+ for (volume in storageManager.storageVolumes) {
+ val mountPoint = volume.directoryCompat ?: continue
+ if (volume.isPrimary) {
+ put(DOCUMENTSUI_PRIMARY_ID, mountPoint)
+ } else {
+ val uuid = volume.uuid ?: continue
+ put(uuid, mountPoint)
+ }
+ }
+ }
+
+ for (path in local) {
+ // expandTilde() is only for expanding /sdcard. stbridge already expanded actual
+ // tilde characters.
+ val expandedPath = path.expandTilde()
+ val shortenedPath = expandedPath.shortenTilde()
+
+ val parentDir = expandedPath.parentFile
+ if (parentDir == null) {
+ Log.w(TAG, "Skipping due to unknown parent directory: $parentDir")
+ add(ConflictItem(shortenedPath.toString(), null))
+ continue
+ }
+
+ val volume = mountPoints.entries.find { parentDir.startsWith(it.value) }
+ if (volume == null) {
+ Log.w(TAG, "Skipping due to unknown mount point: $parentDir")
+ add(ConflictItem(shortenedPath.toString(), null))
+ continue
+ }
+
+ val relPath = parentDir.relativeTo(volume.value)
+ val uri = DocumentsContract.buildDocumentUri(
+ DOCUMENTSUI_AUTHORITY,
+ "${volume.key}:$relPath",
+ )
+
+ add(ConflictItem(shortenedPath.toString(), uri))
+ }
+
+ for ((child, parent) in saf) {
+ // This will never throw IllegalArgumentException. The URIs returned here by
+ // stbridge originate from SyncthingSafClient.
+ val parentDocumentUri = DocumentsContract.buildDocumentUri(
+ parent.authority,
+ SyncthingSafClient.getNarrowestDocumentId(parent),
+ )
+
+ add(ConflictItem(child.formattedString, parentDocumentUri))
+ }
+ }
+ }
+
data class FolderStates(
val idle: Int,
val scanning: Int,
@@ -323,9 +406,9 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
@GuardedBy("stateLock")
private var recheckPermissions = true
@GuardedBy("stateLock")
- private var missingInternal = false
+ private var missingLocal = false
@GuardedBy("stateLock")
- private var missingExternal = emptyList()
+ private var missingSaf = emptyList()
@GuardedBy("stateLock")
private var shouldThreadRun = true
@@ -348,7 +431,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
@GuardedBy("stateLock")
private var syncthingApp: SyncthingApp? = null
@GuardedBy("stateLock")
- private var syncthingConflicts = emptyList()
+ private var syncthingConflicts = ConflictsInfo()
set(conflicts) {
if (field != conflicts) {
field = conflicts
@@ -555,9 +638,9 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
}
}
- // We intentionally only check internal storage permissions. See
+ // We intentionally only check local storage permissions. See
// onCheckStoragePermissions().
- if (missingInternal && !recheckPermissions) {
+ if (missingLocal && !recheckPermissions) {
add(BlockedReason.NO_STORAGE_PERMISSIONS)
}
}
@@ -729,12 +812,12 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
}
}
- override fun onCheckStoragePermissions(internal0Sep: String, external0Sep: String) {
- val external = mutableSetOf()
+ override fun onCheckStoragePermissions(local0Sep: String, saf0Sep: String) {
+ val saf = mutableSetOf()
- for (encoded in unpack0Sep(external0Sep)) {
+ for (encoded in unpack0Sep(saf0Sep)) {
try {
- external.add(decodeSafUri(encoded).first)
+ saf.add(decodeSafUri(encoded).first)
} catch (e: Exception) {
Log.w(TAG, "Ignoring invalid encoded URI: $encoded", e)
}
@@ -745,7 +828,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
continue
}
- if (!external.remove(persisted.uri)) {
+ if (!saf.remove(persisted.uri)) {
Log.d(TAG, "Releasing persisted permission: $persisted")
try {
var flags = 0
@@ -765,21 +848,21 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
synchronized(stateLock) {
recheckPermissions = false
- missingInternal = internal0Sep.isNotEmpty() && !Permissions.haveLocalStorage(this)
- missingExternal = external.sorted()
+ missingLocal = local0Sep.isNotEmpty() && !Permissions.haveLocalStorage(this)
+ missingSaf = saf.sorted()
allListeners {
- it.onMissingStoragePermissions(missingInternal, missingExternal)
+ it.onMissingStoragePermissions(missingLocal, missingSaf)
}
stateChanged(recomputeBlockedReasons = true)
- // We intentionally block on missing internal permissions only. If a SAF URI is
+ // We intentionally block on missing local storage permissions only. If a SAF URI is
// inaccessible, Syncthing will completely lose access to it. However, if the local
// storage permission is denied, folders are still visible, so Syncthing thinks
// everything was deleted.
- if (missingInternal) {
- throw SecurityException("Missing internal storage permissions")
+ if (missingLocal) {
+ throw SecurityException("Missing local storage permissions")
}
}
}
@@ -800,7 +883,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
Log.i(TAG, "Syncthing successfully stopped")
synchronized(stateLock) {
- syncthingConflicts = emptyList()
+ syncthingConflicts = ConflictsInfo()
syncthingAlerts = 0
syncthingFolderStates = FolderStates()
syncthingDeviceStates = DeviceStates()
@@ -811,11 +894,19 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
}
@WorkerThread
- override fun onConflictsUpdated(paths0Sep: String) {
- val paths = unpack0Sep(paths0Sep).apply { sort() }
+ override fun onConflictsUpdated(local0Sep: String, safChildParent0Sep: String) {
+ val local = unpack0Sep(local0Sep)
+ .map(::File)
+ .sorted()
+ .toList()
+
+ val safChildParent = unpack0Sep(safChildParent0Sep)
+ val saf = safChildParent
+ .chunked(2)
+ .associate { it[0].toUri() to it[1].toUri() }
synchronized(stateLock) {
- syncthingConflicts = paths
+ syncthingConflicts = ConflictsInfo(local = local, saf = saf)
}
}
@@ -890,7 +981,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
}
interface ServiceListener {
- fun onMissingStoragePermissions(internal: Boolean, external: List)
+ fun onMissingStoragePermissions(local: Boolean, saf: List)
fun onExitRequested()
@@ -898,7 +989,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
fun onPreRunActionResult(preRunAction: PreRunAction, exception: Exception?)
- fun onConflictsUpdated(conflicts: List)
+ fun onConflictsUpdated(conflictsInfo: ConflictsInfo)
}
inner class ServiceBinder : Binder() {
@@ -910,7 +1001,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
Log.w(TAG, "Listener was already registered: $listener")
}
- listener.onMissingStoragePermissions(missingInternal, missingExternal)
+ listener.onMissingStoragePermissions(missingLocal, missingSaf)
listener.onRunStateChanged(lastServiceState!!, guiInfo)
listener.onConflictsUpdated(syncthingConflicts)
}
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index a58fe5f..9b8a9f6 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -31,7 +31,6 @@
لا يعمل إلا عند تفعيل إعداد المزامنة التلقائية للبيانات في أندرويد.
حالات التشغيل
متقدم
- السماح بالوصول لوحدة التخزين الخارجية
تداخلات المزامنة
- حدث تعارض لـ%d ملف أثناء المزامنة.
@@ -91,10 +90,6 @@
تعطيل تحسين \"البطارية\" يتطلب بعض الخطوات اليدوية على Android TV.
لا توجد واجهة مستخدم في Android TV لتعطيل تحسين \"البطارية\"، لكن تعطيله مطلوب ليعمل Syncthing في الخلفية بكفاءة. يمكن تعطيل التحسين عبر أمر adb التالي.
نوع وحدة التخزين
- وحدة التخزين الداخلية
- اسرع ويملك توافقية أفضل مع Syncthing.
- وحدة التخزين الخارجية
- أقل كفاءة وقد يتسبب بأخطاء غير متوقعة أثناء المزامنة.
مجلد جديد
ادخل اسم المجلد الذي ترغب بإنشائه في %1$s.
الاسم
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 14f903a..f5f641b 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -200,11 +200,6 @@
- %d Ordner werden gescannt
Speichertyp
- Interner Speicher
- Schneller und bessere Kompatibilität mit Syncthing.
- Externer Speicher
- Weniger effizient und kann zu unerwarteten Synchronisationsfehlern führen.
- Zugriff auf externen Speicher zulassen
Die App nach einem Neustart automatisch starten.
Beim Systemstart starten
Zugriff auf lokales Netzwerk erlauben
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 5471211..0866c7d 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -214,12 +214,7 @@
- %d dispositivos con sincronización pendiente
- %d dispositivos con sincronización pendiente
- Permitir acceso al almacenamiento externo
Tipo de almacenamiento
- Almacenamiento interno
- Más rápido y con mejor compatibilidad con Syncthing.
- Almacenamiento externo
- Menos eficiente y puede provocar errores de sincronización inesperados.
Permitir acceso a la red local
Necesario para conectarse directamente a los dispositivos de la red local en lugar de pasar por los servidores de retransmisión de Syncthing.
Iniciar al arrancar
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index b20970c..f36a610 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -229,14 +229,9 @@
Autoriser l\'accès au réseau local
Requis pour la connexion aux appareils en passant directement par le réseau local au lieu des serveurs de relai de Syncthing.
- Autoriser l\'accès au stockage externe
Démarrer au démarrage du système
Démarre l\'application automatiquement après un redémarrage du système.
Type de stockage
- Stockage interne
- Plus rapide et meilleure compatibilité avec Syncthing.
- Stockage externe
- Moins efficace et peut générer des erreurs de synchronisation inattendues.
Notification persistante quand démarré
Notification persistante quand arrêté
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 50677fb..6e788a5 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -229,12 +229,7 @@
- • %d urządzeń oczekujących na synchronizację
- • %d urządzeń oczekujących na synchronizację
- Zezwól na dostęp do pamięci zewnętrznej
Typ pamięci
- Pamięć wewnętrzna
- Szybsza i bardziej kompatybilna z Syncthing.
- Pamięć zewnętrzna
- Mniej wydajna i może powodować nieoczekiwane błędy synchronizacji.
Zezwól na dostęp do sieci lokalnej
Potrzebne do łączenia się z urządzeniami w sieci lokalnej bezpośrednio, zamiast korzystać z serwerów przekaźnikowych Syncthing.
Uruchom przy rozruchu
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index edeb0b6..3fb666c 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -229,14 +229,9 @@
Permite accesul la rețeaua locală
Este necesar pentru conectarea directă la dispozitivele din rețeaua locală, în loc să treacă prin serverele de releu Syncthing.
- Permite accesul la stocarea externă
Pornire împreună cu sistemul de operare
Pornește automat aplicația după o repornire.
Tip stocare
- Stocare internă
- Mai rapidă și are o compatibilitate mai bună cu Syncthing.
- Stocare externă
- Mai puțin eficientă și poate duce la erori de sincronizare neașteptate.
Notificare persistentă când este pornit
Notificare persistentă când este oprit
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index d7e5856..a0c7f5d 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -199,12 +199,7 @@
- • %d cihaz eşitlenmeyi bekliyor
- • %d cihaz eşitlenmeyi bekliyor
- Harici depolama erişimine izin ver
Depolama türü
- Dahili depolama
- Syncthing ile daha hızlı ve daha iyi uyumluluğa sahiptir.
- Harici depolama
- Daha az verimlidir ve beklenmeyen eşitleme hatalarına neden olabilir.
Yerel ağ erişimine izin ver
Syncthing\'in geçiş sunucularından geçmek yerine, yerel ağdaki cihazlara doğrudan bağlanmak gerekir.
Önyüklemede başlat
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index ce9d2aa..d6d9291 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -184,12 +184,7 @@
- • %d 台设备待同步
- 允许外部存储访问
存储类型
- 内部存储
- 速度更快,与 Syncthing 的兼容性更好。
- 外部存储
- 效率较低,可能会导致意外的同步错误。
允许本地网络访问
需要直接连接到本地网络上的设备,而不是通过 Syncthing 的中继服务器。
开机时启动
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 7cb54f9..754b3ce 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -166,7 +166,6 @@
允許存取區域網路
直接連線至區域網路上的裝置,不依賴 Syncthing 的中繼伺服器時,需要此權限。
- 允許存取外部儲存空間
允許自動模式
停用時,將會從應用程式、通知和快速設定面板中完全移除自動模式功能。
開機時啟動
@@ -176,10 +175,6 @@
在 Android TV 上,停用「電池」最佳化需要手動步驟。
Android TV 沒有停用「電池」最佳化的使用者介面,但要讓 Syncthing 在背景穩定執行需要此設定。您必須執行以下 adb 指令來完成此操作。
儲存空間類型
- 內部儲存空間
- 速度較快,且與 Syncthing 有更佳的相容性。
- 外部儲存空間
- 速度較慢,且可能導致預期外的同步錯誤。
啟動時的常駐通知
停止時的常駐通知
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0491f89..128b6eb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -29,12 +29,12 @@
Allow local network access
Needed to connect to devices on the local network directly instead of passing through Syncthing\'s relay servers.
-
+
Allow local storage access
-
- Needed for Syncthing to access the local storage under /sdcard.
-
- Allow external storage access
+
+ Needed for Syncthing to have direct file access to internal and external storage.
+
+ Allow folder access
Disable app hibernation
@@ -188,16 +188,16 @@
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.
-
+
Storage type
-
- Internal storage
-
- Faster and has better compatibility with Syncthing.
-
- External storage
-
- Less efficient and may result in unexpected sync errors.
+
+ Direct file access
+
+ Requires the \"all files access\" permission, but is more efficient and has better compatibility with Syncthing.
+
+ Storage access framework
+
+ Only grants permissions to the selected folder, but is slower and may result in unexpected sync errors.
New folder
diff --git a/stbridge/stbridge.go b/stbridge/stbridge.go
index 135b1e9..ad14623 100644
--- a/stbridge/stbridge.go
+++ b/stbridge/stbridge.go
@@ -292,17 +292,14 @@ func (app *SyncthingApp) GuiTlsCert() []byte {
}
type SyncthingStatusReceiver interface {
- OnCheckStoragePermissions(internal0Sep string, external0Sep string) error
+ OnCheckStoragePermissions(local0Sep string, saf0Sep string) error
OnSyncthingStarted(app *SyncthingApp)
OnSyncthingStopped(app *SyncthingApp)
// Can be sent before OnSyncthingStarted, but not after OnSyncthingStopped.
- // The list of paths is separated by a '\0' byte because gomobile does not
- // support passing a list of strings to the JVM. This works for arbitrary
- // paths on Linux.
- OnConflictsUpdated(paths0Sep string)
+ OnConflictsUpdated(local0Sep string, safChildParent0Sep string)
// Can be sent before OnSyncthingStarted, but not after OnSyncthingStopped.
OnAlertsUpdated(count int32)
@@ -336,34 +333,79 @@ func isConflict(name string) bool {
}
type conflictsInfo struct {
- byFolder map[string]map[string]struct{}
- folderPaths map[string]string
+ byFolder map[string]map[string]struct{}
+ folderPaths map[string]string
+ filesystemTypes map[string]config.FilesystemType
}
func dispatchConflicts(
conflictsInfo *conflictsInfo,
receiver SyncthingStatusReceiver,
) {
- unique := map[string]struct{}{}
+ uniqueLocal := map[string]struct{}{}
+ uniqueSaf := map[string]string{}
for folder, names := range conflictsInfo.byFolder {
folderPath := conflictsInfo.folderPaths[folder]
+ filesystemType := conflictsInfo.filesystemTypes[folder]
- for name := range names {
- unique[filepath.Join(folderPath, name)] = struct{}{}
+ if filesystemType == config.FilesystemTypeBasic {
+ // Never fails on Android.
+ expanded, _ := fs.ExpandTilde(folderPath)
+
+ for name := range names {
+ uniqueLocal[filepath.Join(expanded, name)] = struct{}{}
+ }
+ } else if filesystemType == config.FilesystemType(filesystemTypeSaf) {
+ // We need to get the URI of the conflicting files (for display) and
+ // the URI of the parent directories (for opening). We do this on
+ // the stbridge side to take advantage of the cached safNodes.
+ filesystem := fs.NewFilesystem(fs.FilesystemType(filesystemType), folderPath)
+
+ for name := range names {
+ parent := filepath.Dir(name)
+ parentFileInfo, err := filesystem.Stat(parent)
+ if err != nil {
+ log.Printf("Failed to stat SAF conflict dir: %q", rawPathJoin(folderPath, parent))
+ continue
+ }
+ parentSafInfo := parentFileInfo.(*safFileInfo)
+
+ childFileInfo, err := filesystem.Stat(name)
+ if err != nil {
+ log.Printf("Failed to stat SAF conflict file: %q", rawPathJoin(folderPath, name))
+ continue
+ }
+ childSafInfo := childFileInfo.(*safFileInfo)
+
+ uniqueSaf[childSafInfo.Uri] = parentSafInfo.Uri
+ }
+ } else {
+ log.Printf("Skipping unsupported filesystem type: %v", filesystemType)
+ continue
}
}
- var paths0Sep strings.Builder
+ var local0Sep strings.Builder
+ var safChildParent0Sep strings.Builder
+
+ for path := range uniqueLocal {
+ if local0Sep.Len() > 0 {
+ local0Sep.WriteRune('\u0000')
+ }
+ local0Sep.WriteString(path)
+ }
- for path := range unique {
- if paths0Sep.Len() > 0 {
- paths0Sep.WriteRune('\u0000')
+ for child, parent := range uniqueSaf {
+ if safChildParent0Sep.Len() > 0 {
+ safChildParent0Sep.WriteRune('\u0000')
}
- paths0Sep.WriteString(path)
+ safChildParent0Sep.WriteString(child)
+ safChildParent0Sep.WriteRune('\u0000')
+ safChildParent0Sep.WriteString(parent)
}
- receiver.OnConflictsUpdated(paths0Sep.String())
+ receiver.OnConflictsUpdated(local0Sep.String(), safChildParent0Sep.String())
}
// The only type of alerts we currently don't track are those associated
@@ -669,10 +711,11 @@ func eventLoop(
cfg := evt.Data.(config.Configuration)
clear(conflictsInfo.folderPaths)
+ clear(conflictsInfo.filesystemTypes)
for _, folder := range cfg.Folders {
- // Never fails on Android.
- conflictsInfo.folderPaths[folder.ID], _ = fs.ExpandTilde(folder.Path)
+ conflictsInfo.folderPaths[folder.ID] = folder.Path
+ conflictsInfo.filesystemTypes[folder.ID] = folder.FilesystemType
}
for folderID := range conflictsInfo.byFolder {
@@ -722,7 +765,7 @@ func eventLoop(
dispatchAlerts(alertsInfo, receiver)
// Let Syncthing service know so that outdated (ignored by user)
- // permission warnings for external storage can be removed.
+ // permission warnings for SAF URIs can be removed.
_ = checkStoragePermissions(cfg, receiver)
// Invalidate SAF virtual root so that the next call to the
@@ -750,8 +793,9 @@ func startEventLoop(
receiver SyncthingStatusReceiver,
) error {
conflictsInfo := conflictsInfo{
- byFolder: map[string]map[string]struct{}{},
- folderPaths: map[string]string{},
+ byFolder: map[string]map[string]struct{}{},
+ folderPaths: map[string]string{},
+ filesystemTypes: map[string]config.FilesystemType{},
}
// Find the initial set of conflicts from the database before starting the
@@ -774,8 +818,8 @@ func startEventLoop(
return fmt.Errorf("failed to query database for: %q: %w", folder.ID, err)
}
- // Never fails on Android.
- conflictsInfo.folderPaths[folder.ID], _ = fs.ExpandTilde(folder.Path)
+ conflictsInfo.folderPaths[folder.ID] = folder.Path
+ conflictsInfo.filesystemTypes[folder.ID] = folder.FilesystemType
}
dispatchConflicts(&conflictsInfo, receiver)
@@ -806,16 +850,16 @@ func checkStoragePermissions(
cfg config.Configuration,
receiver SyncthingStatusReceiver,
) error {
- var internal0Sep strings.Builder
- var external0Sep strings.Builder
+ var local0Sep strings.Builder
+ var saf0Sep strings.Builder
for _, folder := range cfg.Folders {
var builder *strings.Builder
if folder.FilesystemType == config.FilesystemTypeBasic {
- builder = &internal0Sep
+ builder = &local0Sep
} else if folder.FilesystemType == config.FilesystemType(filesystemTypeSaf) {
- builder = &external0Sep
+ builder = &saf0Sep
} else {
continue
}
@@ -826,7 +870,7 @@ func checkStoragePermissions(
builder.WriteString(folder.Path)
}
- return receiver.OnCheckStoragePermissions(internal0Sep.String(), external0Sep.String())
+ return receiver.OnCheckStoragePermissions(local0Sep.String(), saf0Sep.String())
}
type SyncthingStartupConfig struct {