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())); 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/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/Rclone.java b/app/src/main/java/ca/pkay/rcloneexplorer/Rclone.java index b21b771b5..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(); @@ -688,12 +696,33 @@ 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")); + 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){ + boolean isSftpRemote = remoteItem.isRemoteType(RemoteItem.SFTP); + if(useMD5Sum && !isSftpRemote){ defaultParameter.add("--checksum"); } + 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"); + defaultParameter.add("none"); + } if(deleteExcluded){ defaultParameter.add("--delete-excluded"); } 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]); } 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); } 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..93e52be5d 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() @@ -125,6 +129,7 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) handleTask() postSync() } else { + failureReason = FAILURE_REASON.NO_TASK postSync() return Result.failure() } @@ -176,6 +181,8 @@ 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() + var lastStats = "" try { val reader = BufferedReader(InputStreamReader(localProcessReference.errorStream)) val iterator = reader.lineSequence().iterator() @@ -184,13 +191,28 @@ 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") + } + } + "notice" -> { + val msg = logline.optString("msg", "") + if (msg.isNotEmpty()) { + lastStats = msg + } } - statusObject.parseLoglineToStatusObject(logline) - } else if (logline.getString("level") == "warning") { - statusObject.parseLoglineToStatusObject(logline) } updateForegroundNotification(mNotificationManager.updateSyncNotification( @@ -210,6 +232,16 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) } catch (e: IOException) { FLog.e(TAG, "onHandleIntent: error reading stdout", e) } + 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() } catch (e: InterruptedException) { @@ -235,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 -> { @@ -259,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()