From c9bc514c1ec00f6bd525683ac59f357186009e71 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 1 Jul 2026 16:56:26 +0200 Subject: [PATCH 1/7] fix: promote StreamingService to foreground immediately on Android 8+ On GrapheneOS (and strictly enforced Android 12+), the 5-second window for calling startForeground() was being missed because StreamingService extended IntentService, which ran startForeground() on a background worker thread with non-deterministic scheduling delay. Convert StreamingService from deprecated IntentService to Service: - Call startForeground() in onStartCommand() on the main thread, guaranteeing foreground promotion before any background-process limits can apply - Run the blocking waitFor() in a dedicated thread - Return START_NOT_STICKY (user-initiated serve, no auto-restart) - Add tryStartForegroundService() to ActivityHelper and use it for all StreamingService start calls Co-Authored-By: Claude Sonnet 4.6 --- .../Fragments/FileExplorerFragment.java | 7 +- .../Services/StreamingService.java | 135 ++++++++++-------- .../rcloneexplorer/util/ActivityHelper.java | 13 ++ 3 files changed, 93 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/Fragments/FileExplorerFragment.java b/app/src/main/java/ca/pkay/rcloneexplorer/Fragments/FileExplorerFragment.java index ca331efe7..29bcbcdbd 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/Fragments/FileExplorerFragment.java +++ b/app/src/main/java/ca/pkay/rcloneexplorer/Fragments/FileExplorerFragment.java @@ -2,6 +2,7 @@ import static ca.pkay.rcloneexplorer.util.ActivityHelper.tryStartActivity; import static ca.pkay.rcloneexplorer.util.ActivityHelper.tryStartActivityForResult; +import static ca.pkay.rcloneexplorer.util.ActivityHelper.tryStartForegroundService; import static ca.pkay.rcloneexplorer.util.ActivityHelper.tryStartService; import android.annotation.SuppressLint; @@ -703,7 +704,7 @@ public void onServeOptionsSelected(int protocol, boolean allowRemoteAccess, Stri default: return; } - tryStartService(context, intent); + tryStartForegroundService(context, intent); } private void emptyTrash() { @@ -1306,7 +1307,7 @@ private void showFileMenu(View view, final FileItem fileItem) { } // GH-87: Release old server context.stopService(new Intent(context, StreamingService.class)); - tryStartService(context, intent); + tryStartForegroundService(context, intent); }); builder.setTitle(R.string.pick_a_protocol); builder.show(); @@ -1929,7 +1930,7 @@ protected Boolean doInBackground(FileItem... fileItems) { serveIntent.putExtra(StreamingService.SERVE_PORT, port); // GH-87: Release old stream context.stopService(new Intent(context, StreamingService.class)); - tryStartService(context, serveIntent); + tryStartForegroundService(context, serveIntent); Uri uri = Uri.parse("http://127.0.0.1:" + port) .buildUpon() diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/Services/StreamingService.java b/app/src/main/java/ca/pkay/rcloneexplorer/Services/StreamingService.java index c8ab97250..3aee44477 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/Services/StreamingService.java +++ b/app/src/main/java/ca/pkay/rcloneexplorer/Services/StreamingService.java @@ -1,13 +1,13 @@ package ca.pkay.rcloneexplorer.Services; -import android.app.IntentService; -import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; -import android.content.Context; +import android.app.Service; import android.content.Intent; import android.net.Uri; import android.os.Build; +import android.os.IBinder; + import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; @@ -19,7 +19,7 @@ import ca.pkay.rcloneexplorer.util.NotificationUtils; -public class StreamingService extends IntentService { +public class StreamingService extends Service { private static final String TAG = "StreamingService"; public static final String SERVE_PATH_ARG = "ca.pkay.rcexplorer.streaming_service.arg1"; @@ -38,109 +38,126 @@ public class StreamingService extends IntentService { private final String CHANNEL_NAME = "Streaming service"; private final int PERSISTENT_NOTIFICATION_ID = 179; private Rclone rclone; - private Process runningProcess; - - /** - * Creates an IntentService. Invoked by your subclass's constructor.* - */ - public StreamingService() { - super("ca.pkay.rcexplorer.streamingservice"); - } + private volatile Process runningProcess; + private Thread serveThread; @Override public void onCreate() { super.onCreate(); - setNotificationChannel(); + NotificationUtils.createNotificationChannel(this, + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW, + getString(R.string.streaming_service_notification_channel_description) + ); rclone = new Rclone(this); } @Override - protected void onHandleIntent(@Nullable Intent intent) { + public int onStartCommand(Intent intent, int flags, int startId) { if (intent == null) { - return; + stopSelf(); + return START_NOT_STICKY; } + final String servePath = intent.getStringExtra(SERVE_PATH_ARG); final RemoteItem remote = intent.getParcelableExtra(REMOTE_ARG); - final Boolean showNotificationText = intent.getBooleanExtra(SHOW_NOTIFICATION_TEXT, false); + final boolean showNotificationText = intent.getBooleanExtra(SHOW_NOTIFICATION_TEXT, false); final int protocol = intent.getIntExtra(SERVE_PROTOCOL, SERVE_HTTP); final int port = intent.getIntExtra(SERVE_PORT, 8080); - final Boolean allowRemoteAccess = intent.getBooleanExtra(ALLOW_REMOTE_ACCESS, false); + final boolean allowRemoteAccess = intent.getBooleanExtra(ALLOW_REMOTE_ACCESS, false); final String authenticationUsername = intent.getStringExtra(AUTHENTICATION_USERNAME); final String authenticationPassword = intent.getStringExtra(AUTHENTICATION_PASSWORD); - int flags = 0; + int pendingFlags = 0; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - flags = PendingIntent.FLAG_IMMUTABLE; + pendingFlags = PendingIntent.FLAG_IMMUTABLE; } - Intent foregroundIntent = new Intent(this, StreamingService.class); - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, foregroundIntent, flags); Intent cancelIntent = new Intent(this, ServeCancelAction.class); - PendingIntent cancelPendingIntent = PendingIntent.getBroadcast(this, 0, cancelIntent, flags); + PendingIntent cancelPendingIntent = PendingIntent.getBroadcast(this, 0, cancelIntent, pendingFlags); NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_streaming) .setContentTitle(getString(R.string.streaming_service_notification_title)) .setPriority(NotificationCompat.PRIORITY_LOW) - .setContentIntent(pendingIntent) .addAction(R.drawable.ic_cancel_download, getString(R.string.cancel), cancelPendingIntent); if (showNotificationText) { Uri uri = Uri.parse("http://127.0.0.1:" + port); Intent webPageIntent = new Intent(Intent.ACTION_VIEW, uri); webPageIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - PendingIntent webPagePendingIntent = PendingIntent.getActivity(this, 0, webPageIntent, flags); + PendingIntent webPagePendingIntent = PendingIntent.getActivity(this, 0, webPageIntent, pendingFlags); builder.setContentIntent(webPagePendingIntent); builder.setContentText(getString(R.string.streaming_service_notification_content, port)); } + // Promote to foreground immediately on the main thread — this is critical on GrapheneOS + // and Android 12+ where the 5-second window is strictly enforced. IntentService called + // startForeground() from a background thread, causing the service to be killed before + // promotion on stricter OS variants. startForeground(PERSISTENT_NOTIFICATION_ID, builder.build()); - switch (protocol) { - case SERVE_FTP: - runningProcess = rclone.serve(Rclone.SERVE_PROTOCOL_FTP, port, allowRemoteAccess, authenticationUsername, authenticationPassword, remote, servePath); - break; - case SERVE_WEBDAV: - runningProcess = rclone.serve(Rclone.SERVE_PROTOCOL_WEBDAV, port, allowRemoteAccess, authenticationUsername, authenticationPassword, remote, servePath); - break; - case SERVE_DLNA: - runningProcess = rclone.serve(Rclone.SERVE_PROTOCOL_DLNA, port, allowRemoteAccess, authenticationUsername, authenticationPassword, remote, servePath); - break; - case SERVE_HTTP: - default: - runningProcess = rclone.serve(Rclone.SERVE_PROTOCOL_HTTP, port, allowRemoteAccess, authenticationUsername, authenticationPassword, remote, servePath); - break; - } + stopServeProcess(); + + serveThread = new Thread(() -> { + switch (protocol) { + case SERVE_FTP: + runningProcess = rclone.serve(Rclone.SERVE_PROTOCOL_FTP, port, allowRemoteAccess, authenticationUsername, authenticationPassword, remote, servePath); + break; + case SERVE_WEBDAV: + runningProcess = rclone.serve(Rclone.SERVE_PROTOCOL_WEBDAV, port, allowRemoteAccess, authenticationUsername, authenticationPassword, remote, servePath); + break; + case SERVE_DLNA: + runningProcess = rclone.serve(Rclone.SERVE_PROTOCOL_DLNA, port, allowRemoteAccess, authenticationUsername, authenticationPassword, remote, servePath); + break; + case SERVE_HTTP: + default: + runningProcess = rclone.serve(Rclone.SERVE_PROTOCOL_HTTP, port, allowRemoteAccess, authenticationUsername, authenticationPassword, remote, servePath); + break; + } - if (runningProcess != null) { - try { - runningProcess.waitFor(); - } catch (InterruptedException e) { - FLog.e(TAG, "onHandleIntent: error waiting for process", e); + if (runningProcess != null) { + try { + runningProcess.waitFor(); + } catch (InterruptedException e) { + FLog.e(TAG, "serve thread interrupted while waiting for process", e); + Thread.currentThread().interrupt(); + } } - } - if (runningProcess != null && runningProcess.exitValue() != 0) { - rclone.logErrorOutput(runningProcess); - } + if (runningProcess != null && runningProcess.exitValue() != 0) { + rclone.logErrorOutput(runningProcess); + } - stopForeground(true); + stopForeground(true); + stopSelf(); + }); + serveThread.start(); + + return START_NOT_STICKY; } @Override public void onDestroy() { + stopServeProcess(); super.onDestroy(); - if (null != runningProcess) { - runningProcess.destroy(); - } } - private void setNotificationChannel() { - NotificationUtils.createNotificationChannel(this, - CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_LOW, - getString(R.string.streaming_service_notification_channel_description) - ); + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void stopServeProcess() { + if (runningProcess != null) { + runningProcess.destroy(); + runningProcess = null; + } + if (serveThread != null) { + serveThread.interrupt(); + serveThread = null; + } } } diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/util/ActivityHelper.java b/app/src/main/java/ca/pkay/rcloneexplorer/util/ActivityHelper.java index c6451445f..acd270bf3 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/util/ActivityHelper.java +++ b/app/src/main/java/ca/pkay/rcloneexplorer/util/ActivityHelper.java @@ -6,6 +6,7 @@ import android.content.Context; import android.content.Intent; import android.content.res.Configuration; +import android.os.Build; import android.os.Handler; import android.os.Looper; import android.util.TypedValue; @@ -99,6 +100,18 @@ public static void tryStartService(@NonNull Context context, @NonNull Intent int } } + public static void tryStartForegroundService(@NonNull Context context, @NonNull Intent intent) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + } catch (IllegalStateException e) { + FLog.e(TAG, "Host context state is invalid, not starting foreground service", e); + } + } + public static void applyTheme(Activity activity) { applyDarkMode(activity); } From 6a927ad1b2c9ae7a18cf4144dae05d9179d53892 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 1 Jul 2026 17:37:06 +0200 Subject: [PATCH 2/7] fix: don't crash import when backup has no filters/tasks/triggers array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getJSONArray() throws JSONException when the key is absent. Backups from older versions only contain the arrays that exist — filters in particular was added later and is missing from all pre-existing exports. Replace with optJSONArray() and return an empty list when the key is not present, so a partial backup still imports the sections that are there. Co-Authored-By: Claude Sonnet 4.6 --- .../rcloneexplorer/Database/json/Importer.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/Database/json/Importer.java b/app/src/main/java/ca/pkay/rcloneexplorer/Database/json/Importer.java index 2fcb7cb55..54d00e72a 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/Database/json/Importer.java +++ b/app/src/main/java/ca/pkay/rcloneexplorer/Database/json/Importer.java @@ -33,7 +33,10 @@ public static void importJson(String json, Context context) throws JSONException public static ArrayList createTriggerlist(String content) throws JSONException { ArrayList result = new ArrayList<>(); JSONObject reader = new JSONObject(content); - JSONArray array = reader.getJSONArray("trigger"); + JSONArray array = reader.optJSONArray("trigger"); + if (array == null) { + return result; + } for (int i = 0; i < array.length(); i++) { JSONObject triggerObject = array.getJSONObject(i); result.add(Trigger.Companion.fromString(triggerObject.toString())); @@ -44,7 +47,10 @@ public static ArrayList createTriggerlist(String content) throws JSONEx public static ArrayList createTasklist(String content) throws JSONException { ArrayList result = new ArrayList<>(); JSONObject reader = new JSONObject(content); - JSONArray array = reader.getJSONArray("tasks"); + JSONArray array = reader.optJSONArray("tasks"); + if (array == null) { + return result; + } for (int i = 0; i < array.length(); i++) { JSONObject taskObject = array.getJSONObject(i); result.add(Task.Companion.fromString(taskObject.toString())); @@ -54,7 +60,10 @@ public static ArrayList createTasklist(String content) throws JSONExceptio public static ArrayList createFilterList(String content) throws JSONException { ArrayList result = new ArrayList<>(); JSONObject reader = new JSONObject(content); - JSONArray array = reader.getJSONArray("filters"); + JSONArray array = reader.optJSONArray("filters"); + if (array == null) { + return result; + } for (int i = 0; i < array.length(); i++) { JSONObject filterObject = array.getJSONObject(i); result.add(Filter.Companion.fromString(filterObject.toString())); From a746caba130febbf095d603436b4d61b963ebc21 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 1 Jul 2026 18:28:06 +0200 Subject: [PATCH 3/7] fix: initialize log2File in SyncWorker and EphemeralWorker log2File was declared but never assigned, so log2File?.log() was always a no-op even when logging was enabled. Initialize it in doWork() when logging is enabled. Also add null guard in Log2File for cases where getExternalFilesDir() returns null (e.g. on GrapheneOS). Co-Authored-By: Claude Sonnet 4.6 --- app/src/main/java/ca/pkay/rcloneexplorer/Log2File.java | 4 ++++ .../ca/pkay/rcloneexplorer/workmanager/EphemeralWorker.kt | 4 ++++ .../java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/Log2File.java b/app/src/main/java/ca/pkay/rcloneexplorer/Log2File.java index 154595aa7..c107cdbb4 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/Log2File.java +++ b/app/src/main/java/ca/pkay/rcloneexplorer/Log2File.java @@ -22,6 +22,10 @@ public Log2File(Context context) { public void log(String message) { File path = context.getExternalFilesDir("logs"); + if (path == null) { + FLog.e(TAG, "log: external storage not available, cannot write log"); + return; + } File logFile = new File(path, "log.txt"); clearLogsIfTooBif(logFile); diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/EphemeralWorker.kt b/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/EphemeralWorker.kt index fe3d2c8a6..bb8306e05 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/EphemeralWorker.kt +++ b/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/EphemeralWorker.kt @@ -87,6 +87,10 @@ class EphemeralWorker (private var mContext: Context, workerParams: WorkerParame override fun doWork(): Result { + if (sIsLoggingEnabled) { + log2File = Log2File(mContext) + } + registerBroadcastReceivers() updateForegroundNotification(mNotificationManager?.updateNotification( diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt b/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt index 7056e7bae..1d5982a9d 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt +++ b/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt @@ -90,6 +90,10 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) override fun doWork(): Result { + if (sIsLoggingEnabled) { + log2File = Log2File(mContext) + } + prepareNotifications() registerBroadcastReceivers() From 652cc22e874eecf03b6d683b3017022d28eff090 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 1 Jul 2026 18:38:07 +0200 Subject: [PATCH 4/7] feat: log rclone INFO output to in-app sync log rclone was running at default log level (WARNING), so INFO messages like per-file operations and "There was nothing to transfer" were never visible. Added --log-level INFO to sync command and collect rclone INFO lines into a single SyncLog entry per sync run. Co-Authored-By: Claude Sonnet 4.6 --- .../java/ca/pkay/rcloneexplorer/Rclone.java | 2 +- .../rcloneexplorer/workmanager/SyncWorker.kt | 25 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java b/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java index b21b771b5..dc8a4a30d 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java +++ b/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java @@ -688,7 +688,7 @@ public Process sync(RemoteItem remoteItem, String localPath, String remotePath, String localRemotePath = (remoteItem.isRemoteType(RemoteItem.LOCAL)) ? getLocalRemotePathPrefix(remoteItem, context) + "/" : ""; String remoteSection = (remotePath.compareTo("//" + remoteName) == 0) ? remoteName + ":" + localRemotePath : remoteName + ":" + localRemotePath + remotePath; - ArrayList defaultParameter = new ArrayList<>(Arrays.asList("--transfers", "1", "--stats=1s", "--stats-log-level", "NOTICE", "--use-json-log")); + ArrayList defaultParameter = new ArrayList<>(Arrays.asList("--transfers", "1", "--stats=1s", "--stats-log-level", "NOTICE", "--log-level", "INFO", "--use-json-log")); ArrayList directionParameter = new ArrayList<>(); if(useMD5Sum){ diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt b/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt index 1d5982a9d..bb5908f01 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt +++ b/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt @@ -180,6 +180,7 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) SyncLog.info(mContext, mTitle, mContext.getString(R.string.operation_start_sync)) if (sRcloneProcess != null) { val localProcessReference = sRcloneProcess!! + val infoLines = StringBuilder() try { val reader = BufferedReader(InputStreamReader(localProcessReference.errorStream)) val iterator = reader.lineSequence().iterator() @@ -188,13 +189,22 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) try { val logline = JSONObject(line) //todo: migrate this to StatusObject, so that we can handle everything properly. - if (logline.getString("level") == "error") { - if (sIsLoggingEnabled) { - log2File?.log(line) + when (logline.getString("level")) { + "error" -> { + if (sIsLoggingEnabled) { + log2File?.log(line) + } + statusObject.parseLoglineToStatusObject(logline) + } + "warning" -> { + statusObject.parseLoglineToStatusObject(logline) + } + "info" -> { + val msg = logline.optString("msg", "") + if (msg.isNotEmpty()) { + infoLines.append(msg).append("\n") + } } - statusObject.parseLoglineToStatusObject(logline) - } else if (logline.getString("level") == "warning") { - statusObject.parseLoglineToStatusObject(logline) } updateForegroundNotification(mNotificationManager.updateSyncNotification( @@ -214,6 +224,9 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) } catch (e: IOException) { FLog.e(TAG, "onHandleIntent: error reading stdout", e) } + if (infoLines.isNotEmpty()) { + SyncLog.info(mContext, mTitle, infoLines.toString().trimEnd()) + } try { localProcessReference.waitFor() } catch (e: InterruptedException) { From 9926e3f3ba605aa04ba1f73814ff22811c11bfc9 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 1 Jul 2026 23:22:01 +0200 Subject: [PATCH 5/7] fix: SFTP sync failure on restricted shells + log-level conflict For SFTP remotes, add --sftp-md5sum-command none and --sftp-sha1sum-command none to prevent rclone from calling md5sum via SSH on the remote host. On Synology NAS with restricted shells, md5sum cannot access paths exposed only via SFTP, causing all transfers to fail with "corrupted on transfer" errors. Also fixes a silent startup crash: --log-level INFO conflicts with -vvv (added when debug logging is enabled), causing rclone to exit immediately with "Can't set -v and --log-level". Now --log-level and --stats-log-level are only added when verbose logging is off. Additionally capture NOTICE-level rclone output (transfer stats) and include it in the in-app sync log alongside INFO messages, and guard postSync() mTask accesses with isInitialized checks. Co-Authored-By: Claude Sonnet 4.6 --- .../java/ca/pkay/rcloneexplorer/Rclone.java | 14 ++++++++++- .../rcloneexplorer/workmanager/SyncWorker.kt | 23 +++++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java b/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java index dc8a4a30d..4d76589e8 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java +++ b/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java @@ -688,12 +688,24 @@ public Process sync(RemoteItem remoteItem, String localPath, String remotePath, String localRemotePath = (remoteItem.isRemoteType(RemoteItem.LOCAL)) ? getLocalRemotePathPrefix(remoteItem, context) + "/" : ""; String remoteSection = (remotePath.compareTo("//" + remoteName) == 0) ? remoteName + ":" + localRemotePath : remoteName + ":" + localRemotePath + remotePath; - ArrayList defaultParameter = new ArrayList<>(Arrays.asList("--transfers", "1", "--stats=1s", "--stats-log-level", "NOTICE", "--log-level", "INFO", "--use-json-log")); + boolean loggingEnabled = PreferenceManager + .getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.pref_key_logs), false); + ArrayList defaultParameter = new ArrayList<>(Arrays.asList("--transfers", "1", "--stats=1s", "--use-json-log")); + if (!loggingEnabled) { + defaultParameter.addAll(Arrays.asList("--stats-log-level", "NOTICE", "--log-level", "INFO")); + } ArrayList directionParameter = new ArrayList<>(); if(useMD5Sum){ defaultParameter.add("--checksum"); } + if(remoteItem.isRemoteType(RemoteItem.SFTP)){ + defaultParameter.add("--sftp-md5sum-command"); + defaultParameter.add("none"); + defaultParameter.add("--sftp-sha1sum-command"); + defaultParameter.add("none"); + } if(deleteExcluded){ defaultParameter.add("--delete-excluded"); } diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt b/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt index bb5908f01..93e52be5d 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt +++ b/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt @@ -129,6 +129,7 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) handleTask() postSync() } else { + failureReason = FAILURE_REASON.NO_TASK postSync() return Result.failure() } @@ -181,6 +182,7 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) if (sRcloneProcess != null) { val localProcessReference = sRcloneProcess!! val infoLines = StringBuilder() + var lastStats = "" try { val reader = BufferedReader(InputStreamReader(localProcessReference.errorStream)) val iterator = reader.lineSequence().iterator() @@ -205,6 +207,12 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) infoLines.append(msg).append("\n") } } + "notice" -> { + val msg = logline.optString("msg", "") + if (msg.isNotEmpty()) { + lastStats = msg + } + } } updateForegroundNotification(mNotificationManager.updateSyncNotification( @@ -224,8 +232,15 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) } catch (e: IOException) { FLog.e(TAG, "onHandleIntent: error reading stdout", e) } - if (infoLines.isNotEmpty()) { - SyncLog.info(mContext, mTitle, infoLines.toString().trimEnd()) + val detail = buildString { + if (infoLines.isNotEmpty()) append(infoLines.trimEnd()) + if (lastStats.isNotEmpty()) { + if (isNotEmpty()) append("\n\n") + append(lastStats) + } + } + if (detail.isNotEmpty()) { + SyncLog.info(mContext, mTitle, detail) } try { localProcessReference.waitFor() @@ -252,7 +267,7 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) when (failureReason) { FAILURE_REASON.NO_FAILURE -> { showSuccessNotification(notificationId) - followupTask(mTask.onSuccessFollowup) + if (::mTask.isInitialized) followupTask(mTask.onSuccessFollowup) return } FAILURE_REASON.CANCELLED -> { @@ -276,7 +291,7 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) content = mContext.getString(R.string.operation_failed_unknown_rclone_error, mTitle) } } - followupTask(mTask.onFailFollowup) + if (::mTask.isInitialized) followupTask(mTask.onFailFollowup) showFailNotification(notificationId, content) endNotificationAlreadyPosted = true finishWork() From 8b624609c16f7bc26fe39b4b9accd43b4e06a812 Mon Sep 17 00:00:00 2001 From: michael Date: Fri, 3 Jul 2026 19:05:02 +0200 Subject: [PATCH 6/7] fix: don't set --checksum for SFTP syncs with md5sum enabled Combining --checksum with --sftp-md5sum-command/--sftp-sha1sum-command "none" (set for SFTP remotes since the previous fix to avoid "corrupted on transfer" errors on restricted shells) leaves rclone with no common hash type to compare. rclone silently falls back to a size-only comparison and ignores modification time, so changed files of the same size never get re-synced. Skip --checksum for SFTP remotes so rclone uses its default size+modtime comparison instead. Verified against a live Synology SFTP remote: a same-size, content-changed file is now correctly flagged via modtime and re-transferred, where before it was silently skipped. Note: a separate, longer-standing issue also causes silent size-only comparisons independent of this fix -- RCLONE_LOCAL_NO_SET_MODTIME=true (set for every rclone invocation since 2021, commit f43949be, to avoid chtimes crashes per rclone#2446) makes rclone treat the local backend's modtime as unreliable, which disables modtime-based comparison globally. This fix does not address that; tracked separately. --- .../main/java/ca/pkay/rcloneexplorer/Rclone.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java b/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java index 4d76589e8..dd55483d2 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java +++ b/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java @@ -697,10 +697,19 @@ public Process sync(RemoteItem remoteItem, String localPath, String remotePath, } ArrayList directionParameter = new ArrayList<>(); - if(useMD5Sum){ + boolean isSftpRemote = remoteItem.isRemoteType(RemoteItem.SFTP); + if(useMD5Sum && !isSftpRemote){ defaultParameter.add("--checksum"); } - if(remoteItem.isRemoteType(RemoteItem.SFTP)){ + if(isSftpRemote){ + // Many SFTP servers (e.g. Synology restricted shells) can't run md5sum/sha1sum + // over SSH even though the files are reachable via SFTP. Disabling the remote + // hash commands avoids "corrupted on transfer: hashes differ" failures, but it + // also means no common hash type is left to compare with --checksum. If both + // were combined, rclone silently falls back to a size-only comparison and + // ignores modification time, so changed files of the same size never get + // re-synced. Skip --checksum here and let rclone use its default size+modtime + // comparison instead. defaultParameter.add("--sftp-md5sum-command"); defaultParameter.add("none"); defaultParameter.add("--sftp-sha1sum-command"); From c09aa2eb8bfe36d8811d28821dd1a95f76792d33 Mon Sep 17 00:00:00 2001 From: michael Date: Fri, 3 Jul 2026 19:11:26 +0200 Subject: [PATCH 7/7] fix: stop forcing size-only sync comparisons via RCLONE_LOCAL_NO_SET_MODTIME RCLONE_LOCAL_NO_SET_MODTIME=true has been set for every rclone invocation since 2021 (f43949be) to avoid chtimes crashes on old Android storage stacks (rclone#2446). It has a much bigger side effect than intended: rclone's local backend Precision() reports fs.ModTimeNotSupported unconditionally whenever NoSetModTime is set (backend/local/local.go), and rclone's default file comparison treats ModTimeNotSupported as "always equal" as soon as sizes match (fs/operations/operations.go) -- skipping modification-time comparison entirely. Every sync, regardless of remote type or --checksum, silently degraded to a same-size-means-unchanged check. Changed files that happened to keep the same size were never re-synced. Verified in current rclone (v1.71.0, vendored in this repo) that a failing chtimes()/SetModTime is not fatal: fs/operations/operations.go just logs and counts the error, the transfer itself still succeeds. The 2021 workaround is no longer needed and was silently breaking change detection instead. Verified against a live Synology SFTP remote: a same-size, content-changed file is now correctly detected via modtime and re-transferred ("Copied (replaced existing)"), a second sync correctly no-ops once the file is genuinely in sync, and the local destination's mtime is now correctly set to match the source with no chtimes errors logged. --- .../main/java/ca/pkay/rcloneexplorer/Rclone.java | 14 +++++++++++--- .../java/ca/pkay/rcloneexplorer/RcloneRcd.java | 6 +++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java b/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java index dd55483d2..7e60285e3 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java +++ b/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java @@ -181,9 +181,17 @@ public String[] getRcloneEnv(String... overwriteOptions) { String tmpDir = context.getCacheDir().getAbsolutePath(); environmentValues.add("TMPDIR=" + tmpDir); - // ignore chtimes errors - // ref: https://github.com/rclone/rclone/issues/2446 - environmentValues.add("RCLONE_LOCAL_NO_SET_MODTIME=true"); + // RCLONE_LOCAL_NO_SET_MODTIME used to be set here to work around chtimes + // crashes on old Android storage stacks (ref: rclone#2446). It makes the + // local backend's Precision() report ModTimeNotSupported unconditionally + // (backend/local/local.go), which makes rclone's default file comparison + // fall back to a silent size-only check for every sync, ignoring + // modification time entirely -- changed files of the same size never get + // re-synced. A failing chtimes() is not fatal in current rclone: a failed + // SetModTime is just logged and counted as an error, the transfer itself + // still succeeds (fs/operations/operations.go). So leave modtime-setting + // enabled and let rclone's own runtime precision probe (readPrecision()) + // degrade gracefully per-remote if chtimes truly isn't supported there. // Allow the caller to overwrite any option for special cases Iterator envVarIter = environmentValues.iterator(); diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/RcloneRcd.java b/app/src/main/java/ca/pkay/rcloneexplorer/RcloneRcd.java index ce6e451f8..f1258ec9a 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/RcloneRcd.java +++ b/app/src/main/java/ca/pkay/rcloneexplorer/RcloneRcd.java @@ -170,9 +170,9 @@ public String[] getEnv() { String tmpDir = context.getCacheDir().getAbsolutePath(); environmentValues.add("TMPDIR=" + tmpDir); - // ignore chtimes errors - // ref: https://github.com/rclone/rclone/issues/2446 - environmentValues.add("RCLONE_LOCAL_NO_SET_MODTIME=true"); + // See Rclone.java getRcloneEnv() for why RCLONE_LOCAL_NO_SET_MODTIME is + // deliberately not set here: it forces rclone's local-backend file + // comparison to silently ignore modification time (size-only fallback). return environmentValues.toArray(new String[0]); }