diff --git a/README.md b/README.md index ca4921b..ccbf5c1 100644 --- a/README.md +++ b/README.md @@ -68,26 +68,6 @@ The app is intentionally kept very basic so that the project is easy to maintain * `INTERACT_ACROSS_USERS` (Android >=17) * Optionally used to allow two separate BasicSync instances installed in different Android profiles or users to talk to each other over localhost. Can only be granted via `adb`. See the [cross-user communication](#cross-user-communication) section for more details. -## Other apps losing permissions - -With Android's September 2026 security patches, other apps will lose permissions to shared files when they are synced by default. - -Syncthing normally overwrites files safely during syncing by: - -1. Writing the new data to a temporary file (`.syncthing..tmp`) -2. Deleting the original file -3. Renaming the temporary file to the original name - -However, with Android's September 2026 security patches, renaming or deleting files (steps 2 and 3) will cause other apps to lose permissions to them. - -Turning off the "Overwrite files safely" option on BasicSync's main screen will work around this problem. This forces Syncthing to always use its fallback mechanism for overwriting files by: - -1. Writing the new data to a temporary file (`.syncthing..tmp`) -2. Overwriting the original file in place, copying data from the temporary file -3. Deleting the temporary file - -Android will not revoke other apps' permissions in this case because the original file is never renamed or deleted. Turning off safe overwrites can potentially cause issues in the event that the app crashes during step 2 where the original file is overwritten in place. The partially overwritten file will create a sync conflict the next time the app starts up that must be manually resolved. - ## Remote web UI access Syncthing listens on the loopback interface and is available via `127.0.0.1:8384` by default. BasicSync will try to use the same port on every start, but will automatically pick a new random port if there is a conflict. The current port number can be found in Web UI -> Actions -> Settings -> GUI. HTTPS and basic authentication are both forcibly enabled every time Syncthing starts. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b160484..e92c028 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -81,6 +81,7 @@ android:enableOnBackInvokedCallback="true" android:memtagMode="sync" android:requestLegacyExternalStorage="true" + android:requestRawExternalStorageAccess="true" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.BasicSync" diff --git a/app/src/main/java/com/chiller3/basicsync/MainApplication.kt b/app/src/main/java/com/chiller3/basicsync/MainApplication.kt index ec71382..df33a57 100644 --- a/app/src/main/java/com/chiller3/basicsync/MainApplication.kt +++ b/app/src/main/java/com/chiller3/basicsync/MainApplication.kt @@ -35,8 +35,7 @@ class MainApplication : Application() { Notifications(this).updateChannels() - val prefs = Preferences(this) - prefs.migrate() + Preferences(this).migrate() Stbridge.initDirs( filesDir.toString(), @@ -44,6 +43,5 @@ class MainApplication : Application() { getExternalFilesDir(null)!!.toString(), ) Stbridge.setSafClient(SyncthingSafClient(this)) - Stbridge.setAllowSafeOverwrites(prefs.allowSafeOverwrites) } } diff --git a/app/src/main/java/com/chiller3/basicsync/Preferences.kt b/app/src/main/java/com/chiller3/basicsync/Preferences.kt index b118f08..f50044e 100644 --- a/app/src/main/java/com/chiller3/basicsync/Preferences.kt +++ b/app/src/main/java/com/chiller3/basicsync/Preferences.kt @@ -25,7 +25,6 @@ class Preferences(context: Context) { const val PREF_REMOTE_CONTROL = "remote_control" const val PREF_ALLOW_AUTO_MODE = "allow_auto_mode" const val PREF_START_ON_BOOT = "start_on_boot" - const val PREF_ALLOW_SAFE_OVERWRITES = "allow_safe_overwrites" const val PREF_REQUIRE_UNMETERED_NETWORK = "require_unmetered_network" const val PREF_NETWORK_ALLOW_WIFI = "network_allow_wifi" const val PREF_NETWORK_ALLOW_CELLULAR = "network_allow_cellular" @@ -45,6 +44,7 @@ class Preferences(context: Context) { // Legacy preferences. private const val PREF_REQUIRE_SUFFICIENT_BATTERY = "require_sufficient_battery" + private const val PREF_ALLOW_SAFE_OVERWRITES = "allow_safe_overwrites" } private val prefs = PreferenceManager.getDefaultSharedPreferences(context) @@ -97,10 +97,6 @@ class Preferences(context: Context) { get() = prefs.getBoolean(PREF_START_ON_BOOT, true) set(enabled) = prefs.edit { putBoolean(PREF_START_ON_BOOT, enabled) } - var allowSafeOverwrites: Boolean - get() = prefs.getBoolean(PREF_ALLOW_SAFE_OVERWRITES, true) - set(enabled) = prefs.edit { putBoolean(PREF_ALLOW_SAFE_OVERWRITES, enabled) } - var requireUnmeteredNetwork: Boolean get() = prefs.getBoolean(PREF_REQUIRE_UNMETERED_NETWORK, true) set(enabled) = prefs.edit { putBoolean(PREF_REQUIRE_UNMETERED_NETWORK, enabled) } @@ -174,6 +170,10 @@ class Preferences(context: Context) { prefs.edit { remove(PREF_REQUIRE_SUFFICIENT_BATTERY) } } + if (prefs.contains(PREF_ALLOW_SAFE_OVERWRITES)) { + prefs.edit { remove(PREF_ALLOW_SAFE_OVERWRITES) } + } + clampSyncScheduleDurations(true) } diff --git a/app/src/main/java/com/chiller3/basicsync/settings/AllowSafeOverwritesDialog.kt b/app/src/main/java/com/chiller3/basicsync/settings/AllowSafeOverwritesDialog.kt deleted file mode 100644 index 757f94f..0000000 --- a/app/src/main/java/com/chiller3/basicsync/settings/AllowSafeOverwritesDialog.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Andrew Gunnerson - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.chiller3.basicsync.settings - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.window.DialogProperties -import com.chiller3.basicsync.R - -@Composable -fun AllowSafeOverwritesDialog(onDismiss: () -> Unit) { - AlertDialog( - title = { Text(text = stringResource(R.string.pref_allow_safe_overwrites_name)) }, - text = { - Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) { - Text(text = stringResource(R.string.pref_allow_safe_overwrites_info)) - } - }, - onDismissRequest = onDismiss, - confirmButton = {}, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(text = stringResource(android.R.string.ok)) - } - }, - ) -} 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 e5b17e6..d3f7695 100644 --- a/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt +++ b/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt @@ -113,7 +113,6 @@ fun SettingsScreen( val remoteControl = remember(reloadPrefs) { prefs.remoteControl } val allowAutoMode = remember(reloadPrefs) { prefs.allowAutoMode } val startOnBoot = remember(reloadPrefs) { prefs.startOnBoot } - val allowSafeOverwrites = remember(reloadPrefs) { prefs.allowSafeOverwrites } val isDebugMode = remember(reloadPrefs) { prefs.isDebugMode } var reloadPerms by remember { mutableIntStateOf(0) } @@ -337,7 +336,6 @@ fun SettingsScreen( remoteControl = remoteControl, allowAutoMode = allowAutoMode, startOnBoot = startOnBoot, - allowSafeOverwrites = allowSafeOverwrites, isDebugMode = isDebugMode, onInhibitBatteryOptGrant = { requestInhibitBatteryOpt.launch(Permissions.getInhibitBatteryOptIntent(context)) @@ -472,11 +470,6 @@ fun SettingsScreen( prefs.startOnBoot = enabled reloadPrefs++ }, - onAllowSafeOverwritesChange = { enabled -> - // SyncthingService watches this and applies the change to stbridge. - prefs.allowSafeOverwrites = enabled - reloadPrefs++ - }, onDebugModeChange = { enabled -> prefs.isDebugMode = enabled reloadPrefs++ @@ -584,7 +577,6 @@ private fun SettingsContent( remoteControl: Boolean, allowAutoMode: Boolean, startOnBoot: Boolean, - allowSafeOverwrites: Boolean, isDebugMode: Boolean, onInhibitBatteryOptGrant: () -> Unit, onNotificationsGrant: () -> Unit, @@ -610,7 +602,6 @@ private fun SettingsContent( onRemoteControlChange: (Boolean) -> Unit, onAllowAutoModeChange: (Boolean) -> Unit, onStartOnBootChange: (Boolean) -> Unit, - onAllowSafeOverwritesChange: (Boolean) -> Unit, onDebugModeChange: (Boolean) -> Unit, onSourceRepoOpen: () -> Unit, onSaveLogs: () -> Unit, @@ -677,7 +668,6 @@ private fun SettingsContent( val runState = serviceState?.runState var showMinBatteryLevelDialog by rememberSaveable { mutableStateOf(false) } - var showAllowSafeOverwritesDialog by rememberSaveable { mutableStateOf(false) } PreferenceColumn(contentPadding = contentPadding) { if (missingPermissions.isNotEmpty()) { @@ -924,25 +914,13 @@ private fun SettingsContent( SwitchPreference( checked = startOnBoot, onCheckedChange = onStartOnBootChange, - shapes = BetterSegmentedShapes.middle(), + shapes = BetterSegmentedShapes.bottom(), title = { Text(text = stringResource(R.string.pref_start_on_boot_name)) }, summary = { Text(text = stringResource(R.string.pref_start_on_boot_desc)) }, modifier = Modifier.animateItem(), ) } - item(key = "allow_safe_overwrites") { - SplitSwitchPreference( - onClick = { showAllowSafeOverwritesDialog = true }, - checked = allowSafeOverwrites, - onCheckedChange = onAllowSafeOverwritesChange, - shapes = BetterSegmentedShapes.bottom(), - title = { Text(text = stringResource(R.string.pref_allow_safe_overwrites_name)) }, - summary = { Text(text = stringResource(R.string.pref_allow_safe_overwrites_desc)) }, - modifier = Modifier.animateItem(), - ) - } - item(key = "about") { PreferenceCategory( title = { Text(text = stringResource(R.string.pref_header_about)) }, @@ -993,14 +971,6 @@ private fun SettingsContent( }, ) } - - if (showAllowSafeOverwritesDialog) { - AllowSafeOverwritesDialog( - onDismiss = { - showAllowSafeOverwritesDialog = false - }, - ) - } } @Composable @@ -1104,7 +1074,6 @@ private fun PreviewSettingsScreen() { remoteControl = false, allowAutoMode = true, startOnBoot = true, - allowSafeOverwrites = true, isDebugMode = true, onInhibitBatteryOptGrant = {}, onNotificationsGrant = {}, @@ -1130,7 +1099,6 @@ private fun PreviewSettingsScreen() { onRemoteControlChange = {}, onAllowAutoModeChange = {}, onStartOnBootChange = {}, - onAllowSafeOverwritesChange = {}, onDebugModeChange = {}, onSourceRepoOpen = {}, onSaveLogs = {}, 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 d44255a..52ca009 100644 --- a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt +++ b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt @@ -11,6 +11,7 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.pm.ServiceInfo +import android.media.MediaScannerConnection import android.net.Uri import android.os.Binder import android.os.Build @@ -427,6 +428,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener private lateinit var prefs: Preferences private lateinit var notifications: Notifications + private val mediaScanner = MediaScannerConnection(this, null) private val runnerThread = Thread(::runner) @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") @@ -541,6 +543,8 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener deviceStateTracker = DeviceStateTracker(this) deviceStateTracker.registerListener(this) + mediaScanner.connect() + runnerThread.start() } @@ -565,6 +569,8 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener deviceStateTracker.unregisterListener(this) + mediaScanner.disconnect() + Log.d(TAG, "Exiting") } @@ -634,10 +640,6 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener when (key) { in BLOCKED_REASONS_PREFS, in DeviceState.PREFS -> recomputeBlockedReasons = true in STATE_CHANGE_PREFS -> {} - Preferences.PREF_ALLOW_SAFE_OVERWRITES -> { - Stbridge.setAllowSafeOverwrites(prefs.allowSafeOverwrites) - return - } else -> return } @@ -971,6 +973,16 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener } } + override fun onRemoteFileUpdated(path: String, isDelete: Boolean) { + try { + mediaScanner.scanFile(path, null) + } catch (e: IllegalStateException) { + // On exit, we don't wait for the runner thread to stop. There's a small chance we could + // still receive an event after disconnection. + Log.w(TAG, "Received remote file update event after shutdown", e) + } + } + @WorkerThread override fun onAlertsUpdated(count: Int) { synchronized(stateLock) { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 84f9fe6..c48b274 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -214,7 +214,4 @@ Detaillierte Benachrichtigungen Den Status von freigegebenen Ordnern und verbundenen Geräten in der dauerhaften Benachrichtigung anzeigen. Mobile Daten erlauben (Roaming) - Dateien sicher überschreiben - Mit den Android-Sicherheitspatches vom September 2026 verhindert das Deaktivieren dieser Funktion, dass andere Apps bei der Synchronisierung die Berechtigungen für freigegebene Dateien verlieren. - Standardmäßig überschreibt Syncthing Dateien auf sichere Weise, indem es die neuen Daten zunächst in eine temporäre Datei schreibt, die alte Datei löscht und anschließend die temporäre Datei umbenennt. Mit den Sicherheitspatches von Android vom September 2026 führen diese Dateioperationen jedoch dazu, dass andere Apps bei jeder Synchronisierung ihre Zugriffsrechte auf freigegebene Dateien verlieren.\n\nWenn das sichere Überschreiben deaktiviert ist, schreibt Syncthing die neuen Daten in eine temporäre Datei, überschreibt die Originaldatei direkt an Ort und Stelle und löscht anschließend die temporäre Datei. Dies führt vor allem dann zu Problemen, wenn die App während der Synchronisierung abstürzt. Die teilweise überschriebene Datei verursacht einen Synchronisierungskonflikt, der manuell behoben werden muss. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8cbb179..1e2f6f0 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -242,7 +242,4 @@ Afficher l\'état des partages et appareils dans la notification persistante. Notifications détaillées Autoriser (en itinérance) sur les données mobiles - Écraser les fichiers de façon sécurisée - Avec les correctifs de sécurité Android de septembre 2026, la désactivation de cette option empêche les autres applications de perdre leurs autorisations d\'accès aux fichiers partagés lors de leur synchronisation. - Par défaut, Syncthing écrase les fichiers de façon sécurisée en écrivant d’abord les nouvelles données dans un fichier temporaire, supprimant l’ancien fichier, puis en renommant le fichier temporaire. Cependant, avec les correctifs de sécurité d’Android de septembre 2026, ces opérations sur les fichiers entraîneront la perte des autorisations d’accès aux fichiers partagés pour les autres applications à chaque synchronisation.\n\nSi le remplacement sécurisé est désactivé, Syncthing écrira les nouvelles données dans un fichier temporaire, remplacera le fichier d\'origine sur place, puis supprimera le fichier temporaire. Cela pose principalement des problèmes si l\'application plante pendant la synchronisation. Le fichier partiellement remplacé créera un conflit de synchronisation qui devra être résolu manuellement. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6cb5043..9b55432 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -244,7 +244,4 @@ Powiadomienia Powiadomienia szczegółowe Zezwól na dane mobilne (roaming) - Bezpiecznie nadpisz pliki - W przypadku wrześniowych poprawek bezpieczeństwa systemu Android z 2026 roku wyłączenie tej opcji zapobiega utracie przez inne aplikacje uprawnień do plików współdzielonych podczas ich synchronizacji. - Domyślnie Syncthing nadpisuje pliki w bezpieczny sposób: najpierw zapisuje nowe dane w pliku tymczasowym, usuwa stary plik, a następnie zmienia nazwę pliku tymczasowego. Jednak w związku z wrześniowymi poprawkami bezpieczeństwa systemu Android z 2026 roku, operacje te będą powodować utratę uprawnień innych aplikacji do współdzielonych plików przy każdej synchronizacji.\n\nJeśli bezpieczne nadpisywanie zostanie wyłączone, Syncthing zapisze nowe dane w pliku tymczasowym, nadpisze oryginalny plik w jego bieżącej lokalizacji, a następnie usunie plik tymczasowy. Problemy mogą wystąpić głównie w przypadku awarii aplikacji podczas synchronizacji. Częściowo nadpisany plik spowoduje konflikt synchronizacji, który trzeba będzie rozwiązać ręcznie. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index cec8d75..1238add 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -229,7 +229,4 @@ %d horas %d horas - Substituir arquivos com segurança - Com os patches de segurança de setembro de 2026 do Android, desligar isso evita que outros aplicativos percam permissões para arquivos compartilhados quando eles são sincronizados. - Por padrão, o Syncthing substitui os arquivos com segurança, gravando primeiro os novos dados em um arquivo temporário, excluindo o arquivo antigo e, em seguida, renomeando o arquivo temporário. No entanto, com os patches de segurança de setembro de 2026 do Android, essas operações de arquivo farão com que outros aplicativos percam suas permissões para arquivos compartilhados sempre que forem sincronizados.\n\nSe as substituições seguras estiverem desativadas, o Syncthing gravará os novos dados em um arquivo temporário, substituirá o arquivo original e, em seguida, excluirá o arquivo temporário. Isso causa problemas principalmente se o aplicativo travar durante a sincronização. O arquivo parcialmente substituído criará um conflito de sincronização que deverá ser resolvido manualmente. diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index ef20958..ff6ad5c 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -242,7 +242,4 @@ Notificări detaliate Afișează starea dosarelor partajate și a dispozitivelor conectate în notificarea persistentă. Permite date mobile (roaming) - Suprascrie fișierele în siguranță - Cu patch-urile de securitate Android din septembrie 2026, dezactivarea acestei opțiuni împiedică alte aplicații să piardă permisiunile asupra fișierelor partajate atunci când acestea sunt sincronizate. - În mod implicit, Syncthing suprascrie fișierele în siguranță prin scrierea mai întâi a noilor date într-un fișier temporar, ștergând fișierul vechi și apoi redenumind fișierul temporar. Cu toate acestea, odată cu patch-urile de securitate Android din septembrie 2026, aceste operațiuni asupra fișierelor vor face ca alte aplicații să își piardă permisiunile asupra fișierelor partajate de fiecare dată când sunt sincronizate.\n\nDacă suprascrierile în siguranță sunt dezactivate, Syncthing va scrie noile date într-un fișier temporar, va suprascrie fișierul original și apoi va șterge fișierul temporar. Acest lucru cauzează probleme în principal dacă aplicația se blochează în timpul sincronizării. Fișierul parțial suprascris va crea un conflict de sincronizare care trebuie rezolvat manual. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 410bc61..c043653 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -244,7 +244,4 @@ Подробные уведомления Показывать состояние общих папок и подключенных устройств в закрепленном уведомлении. Разрешить мобильные данные (роуминг) - Безопасная перезапись файлов - Предотвращает потерю разрешений к общим файлам для других приложений при синхронизации после патча Android (сентябрь 2026 г.). - По умолчанию Syncthing безопасно перезаписывает файлы: данные записываются во временный файл, старый файл удаляется, а временный переименовывается. Однако после обновлений системы безопасности Android за сентябрь 2026 г. эти операции приводят к потере разрешений других приложений на доступ к общим файлам при каждой синхронизации.\n\nЕсли отключить безопасную перезапись, Syncthing будет перезаписывать исходный файл «на месте». Это может привести к проблемам только при сбое приложения во время синхронизации: частично перезаписанный файл создаст конфликт синхронизации, который потребуется устранить вручную. diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index b107373..1cd25df 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -214,7 +214,4 @@ Ayrıntılı bildirimler Kalıcı bildirimde paylaşılan klasörlerin ve bağlı cihazların durumunu göster. Mobil veriye izin ver (dolaşım) - Dosyaların üzerine güvenle yazın - Android\'in Eylül 2026 güvenlik yamaları ile bu özelliğin kapatılması, diğer uygulamaların eşitlendiklerinde paylaşılan dosyaların izinleri kaybetmesini önler. - Varsayılan olarak Syncthing, önce yeni verileri geçici bir dosyaya yazarak, eski dosyayı silerek ve ardından geçici dosyayı yeniden adlandırarak dosyaların üzerine güvenli bir şekilde yazar. Ancak Android\'in Eylül 2026 güvenlik yamaları ile bu dosya işlemleri, diğer uygulamaların her eşitlendiğinde paylaşılan dosyaların izinlerini kaybetmesine neden olacak.\n\nEğer güvenli üzerine yazmalar kapatılırsa Syncthing yeni verileri geçici bir dosyaya yazacak, buradaki orijinal dosyanın üzerine yazacak ve ardından geçici dosyayı silecek. Bu, esas olarak eşitleme sırasında uygulamanın çökmesi durumunda sorunlara neden olur. Kısmen üzerine yazılan dosya, el ile çözülmek zorunda kalacak bir eşitleme çakışması oluşturacaktır. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 18bdad2..02c55ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -110,12 +110,6 @@ Start on boot Automatically start the app after a reboot. - - Overwrite files safely - - With Android\'s September 2026 security patches, turning this off prevents other apps from losing permissions to shared files when they are synced. - - By default, Syncthing overwrites files safely by writing the new data to a temporary file first, deleting the old file, and then renaming the temporary file. However, with Android\'s September 2026 security patches, these file operations will cause other apps to lose their permissions to shared files every time they are synced.\n\nIf safe overwrites are turned off, Syncthing will write the new data to a temporary file, overwrite the original file in place, and then delete the temporary file. This mainly causes issues if the app crashes during syncing. The partially overwritten file will create a sync conflict that must be manually resolved. Version diff --git a/external/syncthing b/external/syncthing index 3e1a7cf..14a0bfc 160000 --- a/external/syncthing +++ b/external/syncthing @@ -1 +1 @@ -Subproject commit 3e1a7cf7be8934aaff86fe6bd265deb4cc262cfe +Subproject commit 14a0bfc1daa33dcea493afb58cedc6647cadf93c diff --git a/stbridge/stbridge.go b/stbridge/stbridge.go index f9b75c8..c85f07d 100644 --- a/stbridge/stbridge.go +++ b/stbridge/stbridge.go @@ -52,10 +52,6 @@ func Version() string { return build.Version } -func SetAllowSafeOverwrites(allow bool) { - fs.AllowSafeOverwritesOnAndroidFuse.Store(allow) -} - func cleanOldFiles() { // We only clean up a subset of what upstream syncthing does since the // initial release started with 2.x and certain features aren't enabled. @@ -287,6 +283,8 @@ type SyncthingStatusReceiver interface { // Can be sent before OnSyncthingStarted, but not after OnSyncthingStopped. OnConflictsUpdated(local0Sep string, safChildParent0Sep string) + OnRemoteFileUpdated(path string, isDelete bool) + // Can be sent before OnSyncthingStarted, but not after OnSyncthingStopped. OnAlertsUpdated(count int32) @@ -318,22 +316,22 @@ func isConflict(name string) bool { return strings.Contains(filepath.Base(name), ".sync-conflict-") } -type conflictsInfo struct { - byFolder map[string]map[string]struct{} - folderPaths map[string]string - filesystemTypes map[string]config.FilesystemType +type fileStateInfo struct { + conflictsByFolder map[string]map[string]struct{} + folderPaths map[string]string + filesystemTypes map[string]config.FilesystemType } func dispatchConflicts( - conflictsInfo *conflictsInfo, + fileStateInfo *fileStateInfo, receiver SyncthingStatusReceiver, ) { uniqueLocal := map[string]struct{}{} uniqueSaf := map[string]string{} - for folder, names := range conflictsInfo.byFolder { - folderPath := conflictsInfo.folderPaths[folder] - filesystemType := conflictsInfo.filesystemTypes[folder] + for folder, names := range fileStateInfo.conflictsByFolder { + folderPath := fileStateInfo.folderPaths[folder] + filesystemType := fileStateInfo.filesystemTypes[folder] if filesystemType == config.FilesystemTypeBasic { // Never fails on Android. @@ -394,6 +392,25 @@ func dispatchConflicts( receiver.OnConflictsUpdated(local0Sep.String(), safChildParent0Sep.String()) } +func dispatchRemoteChange( + fileStateInfo *fileStateInfo, + folderID string, + path string, + isDelete bool, + receiver SyncthingStatusReceiver, +) { + if fileStateInfo.filesystemTypes[folderID] != config.FilesystemTypeBasic { + return + } + + folderPath := fileStateInfo.folderPaths[folderID] + // Never fails on Android. + expanded, _ := fs.ExpandTilde(folderPath) + filePath := filepath.Join(expanded, path) + + receiver.OnRemoteFileUpdated(filePath, isDelete) +} + // The only type of alerts we currently don't track are those associated // slogutil.ErrorRecorder because it's a pain to deal with more internal types. type alertsInfo struct { @@ -513,7 +530,7 @@ func eventLoop( stopped chan struct{}, evLogger events.Logger, cfgWrapper config.Wrapper, - conflictsInfo *conflictsInfo, + fileStateInfo *fileStateInfo, alertsInfo *alertsInfo, receiver SyncthingStatusReceiver, ) { @@ -551,22 +568,35 @@ func eventLoop( data := evt.Data.(map[string]string) folderID := data["folder"] path := data["path"] + objType := data["type"] + isDelete := data["action"] == "deleted" + + if isConflict(path) { + if isDelete { + delete(fileStateInfo.conflictsByFolder[folderID], path) + } else { + if _, ok := fileStateInfo.conflictsByFolder[folderID]; !ok { + fileStateInfo.conflictsByFolder[folderID] = map[string]struct{}{} + } + fileStateInfo.conflictsByFolder[folderID][path] = struct{}{} + } - if !isConflict(path) { - continue + dispatchConflicts(fileStateInfo, receiver) } - if data["action"] == "deleted" { - delete(conflictsInfo.byFolder[folderID], path) - } else { - if _, ok := conflictsInfo.byFolder[folderID]; !ok { - conflictsInfo.byFolder[folderID] = map[string]struct{}{} - } - conflictsInfo.byFolder[folderID][path] = struct{}{} + // With Android's 2026-09 security patches, MediaStore updates + // that involve a rename or delete now result in URI permission + // revocation. On API >=31, this happens by default for writes + // to MediaProvider's FUSE filesystem. We turn this off in the + // app manifest, so we need to manually trigger MediaScanner for + // any changes synced from the remote. + // + // https://github.com/syncthing/syncthing/issues/10887 + // https://android.googlesource.com/platform/packages/providers/MediaProvider/+/91dddac65b6ef48ae54302fa852029c2fcf010aa + if evt.Type == events.RemoteChangeDetected && objType == "file" { + dispatchRemoteChange(fileStateInfo, folderID, path, isDelete, receiver) } - dispatchConflicts(conflictsInfo, receiver) - case events.PendingDevicesChanged: if data, ok := evt.Data.(map[string][]interface{}); ok { for _, device := range data["added"] { @@ -696,24 +726,24 @@ func eventLoop( case events.ConfigSaved: cfg := evt.Data.(config.Configuration) - clear(conflictsInfo.folderPaths) - clear(conflictsInfo.filesystemTypes) + clear(fileStateInfo.folderPaths) + clear(fileStateInfo.filesystemTypes) for _, folder := range cfg.Folders { - conflictsInfo.folderPaths[folder.ID] = folder.Path - conflictsInfo.filesystemTypes[folder.ID] = folder.FilesystemType + fileStateInfo.folderPaths[folder.ID] = folder.Path + fileStateInfo.filesystemTypes[folder.ID] = folder.FilesystemType } - for folderID := range conflictsInfo.byFolder { - if _, ok := conflictsInfo.folderPaths[folderID]; !ok { - delete(conflictsInfo.byFolder, folderID) + for folderID := range fileStateInfo.conflictsByFolder { + if _, ok := fileStateInfo.folderPaths[folderID]; !ok { + delete(fileStateInfo.conflictsByFolder, folderID) } } - dispatchConflicts(conflictsInfo, receiver) + dispatchConflicts(fileStateInfo, receiver) for folderID := range folderStates { - if _, ok := conflictsInfo.folderPaths[folderID]; !ok { + if _, ok := fileStateInfo.folderPaths[folderID]; !ok { delete(folderStates, folderID) } } @@ -732,7 +762,7 @@ func eventLoop( for deviceID, folders := range deviceStates.dirty { for folderID := range folders { - if _, ok := conflictsInfo.folderPaths[folderID]; !ok { + if _, ok := fileStateInfo.folderPaths[folderID]; !ok { delete(folders, folderID) } } @@ -778,10 +808,10 @@ func startEventLoop( allLocalFiles allLocalFilesFunc, receiver SyncthingStatusReceiver, ) error { - conflictsInfo := conflictsInfo{ - byFolder: map[string]map[string]struct{}{}, - folderPaths: map[string]string{}, - filesystemTypes: map[string]config.FilesystemType{}, + fileStateInfo := fileStateInfo{ + conflictsByFolder: 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 @@ -795,20 +825,20 @@ func startEventLoop( continue } - if _, ok := conflictsInfo.byFolder[folder.ID]; !ok { - conflictsInfo.byFolder[folder.ID] = map[string]struct{}{} + if _, ok := fileStateInfo.conflictsByFolder[folder.ID]; !ok { + fileStateInfo.conflictsByFolder[folder.ID] = map[string]struct{}{} } - conflictsInfo.byFolder[folder.ID][dbFile.Name] = struct{}{} + fileStateInfo.conflictsByFolder[folder.ID][dbFile.Name] = struct{}{} } if err := errFn(); err != nil { return fmt.Errorf("failed to query database for: %q: %w", folder.ID, err) } - conflictsInfo.folderPaths[folder.ID] = folder.Path - conflictsInfo.filesystemTypes[folder.ID] = folder.FilesystemType + fileStateInfo.folderPaths[folder.ID] = folder.Path + fileStateInfo.filesystemTypes[folder.ID] = folder.FilesystemType } - dispatchConflicts(&conflictsInfo, receiver) + dispatchConflicts(&fileStateInfo, receiver) alertsInfo := alertsInfo{ needsRestart: cfg.RequiresRestart(), @@ -824,7 +854,7 @@ func startEventLoop( stopped, evLogger, cfg, - &conflictsInfo, + &fileStateInfo, &alertsInfo, receiver, )