diff --git a/.gitignore b/.gitignore index 8b4e176b3..76567067c 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ debug/ .idea/codeStyles/codeStyleConfig.xml .idea/inspectionProfiles/Project_Default.xml .idea/ + +# exported app backup from Android studio +**/application.backup diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/Activities/MainActivity.java b/app/src/main/java/ca/pkay/rcloneexplorer/Activities/MainActivity.java index 981f26cf3..493ca96e7 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/Activities/MainActivity.java +++ b/app/src/main/java/ca/pkay/rcloneexplorer/Activities/MainActivity.java @@ -229,6 +229,17 @@ protected void onResume() { } } + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + // MainActivity is singleTop, so notification taps can arrive here instead of onCreate(). + if(MAIN_ACTIVITY_START_LOG.equals(intent.getAction())){ + startLogFragment(); + navigationView.setCheckedItem(R.id.nav_logs); + } + } + @Override protected void onPostCreate(@Nullable Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/notifications/ReportNotifications.kt b/app/src/main/java/ca/pkay/rcloneexplorer/notifications/ReportNotifications.kt index c70984915..0a0678382 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/notifications/ReportNotifications.kt +++ b/app/src/main/java/ca/pkay/rcloneexplorer/notifications/ReportNotifications.kt @@ -2,6 +2,7 @@ package ca.pkay.rcloneexplorer.notifications import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE +import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat @@ -12,6 +13,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import ca.pkay.rcloneexplorer.Activities.MainActivity import ca.pkay.rcloneexplorer.BroadcastReceivers.ClearReportBroadcastReciever import ca.pkay.rcloneexplorer.R import ca.pkay.rcloneexplorer.util.NotificationUtils @@ -87,9 +89,11 @@ class ReportNotifications(var mContext: Context) { .setSmallIcon(R.drawable.ic_twotone_cloud_done_24) .setContentTitle(mContext.getString(R.string.operation_report_success_title)) .setContentText(mContext.getString(R.string.operation_report_success_short_content, notificationContent.lines().size-1)) + .setContentIntent(createLogsPendingIntent()) + .setAutoCancel(true) .setStyle( NotificationCompat.BigTextStyle().bigText( - notificationContent + content ) ) .setPriority(NotificationCompat.PRIORITY_LOW) @@ -142,9 +146,11 @@ class ReportNotifications(var mContext: Context) { .setSmallIcon(R.drawable.ic_twotone_cloud_error_24) .setContentTitle(mContext.getString(R.string.operation_report_fail_title)) .setContentText(mContext.getString(R.string.operation_report_fail_short_content, notificationContent.lines().size-1)) + .setContentIntent(createLogsPendingIntent()) + .setAutoCancel(true) .setStyle( NotificationCompat.BigTextStyle().bigText( - notificationContent + "$title: $line\n" ) ) .setPriority(NotificationCompat.PRIORITY_LOW) @@ -167,6 +173,17 @@ class ReportNotifications(var mContext: Context) { ) } + private fun createLogsPendingIntent(): PendingIntent { + val intent = Intent(mContext, MainActivity::class.java) + intent.action = MainActivity.MAIN_ACTIVITY_START_LOG + return PendingIntent.getActivity( + mContext, + 0, + intent, + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + ) + } + fun getFailures(): Int { val prefMap = runBlocking { mContext.dataStore.data.first().asMap() } val notificationContent = prefMap[NOTIFICATION_CACHE_FAIL_PREFERENCE].toString() diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/notifications/SyncServiceNotifications.kt b/app/src/main/java/ca/pkay/rcloneexplorer/notifications/SyncServiceNotifications.kt index dbfbc61ea..a3f7820b4 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/notifications/SyncServiceNotifications.kt +++ b/app/src/main/java/ca/pkay/rcloneexplorer/notifications/SyncServiceNotifications.kt @@ -8,6 +8,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.preference.PreferenceManager import androidx.work.WorkManager +import ca.pkay.rcloneexplorer.Activities.MainActivity import ca.pkay.rcloneexplorer.BroadcastReceivers.SyncRestartAction import ca.pkay.rcloneexplorer.R import ca.pkay.rcloneexplorer.util.FLog @@ -29,6 +30,7 @@ class SyncServiceNotifications(var mContext: Context) { const val PERSISTENT_NOTIFICATION_ID_FOR_SYNC = 162 const val CANCEL_ID_NOTSET = "CANCEL_ID_NOTSET" const val TAG = "SyncServiceNotifications" + private const val LOGS_PENDING_INTENT_REQUEST = 163 } @@ -83,6 +85,8 @@ class SyncServiceNotifications(var mContext: Context) { .setSmallIcon(R.drawable.ic_twotone_cloud_error_24) .setContentTitle(mContext.getString(R.string.operation_failed)) .setContentText(content) + .setContentIntent(createLogsPendingIntent()) + .setAutoCancel(true) .setStyle( NotificationCompat.BigTextStyle().bigText(content) ) @@ -128,6 +132,8 @@ class SyncServiceNotifications(var mContext: Context) { .setSmallIcon(R.drawable.ic_twotone_cloud_error_24) .setContentTitle(mContext.getString(R.string.operation_failed_cancelled)) .setContentText(content) + .setContentIntent(createLogsPendingIntent()) + .setAutoCancel(true) .setStyle( NotificationCompat.BigTextStyle().bigText(content) ) @@ -166,6 +172,8 @@ class SyncServiceNotifications(var mContext: Context) { .setSmallIcon(R.drawable.ic_twotone_cloud_done_24) .setContentTitle(mContext.getString(R.string.operation_success, title)) .setContentText(content) + .setContentIntent(createLogsPendingIntent()) + .setAutoCancel(true) .setStyle( NotificationCompat.BigTextStyle().bigText( content @@ -227,6 +235,7 @@ class SyncServiceNotifications(var mContext: Context) { intent ) } + builder.setContentIntent(createLogsPendingIntent()) return builder.build() } @@ -235,4 +244,15 @@ class SyncServiceNotifications(var mContext: Context) { val notificationManagerCompat = NotificationManagerCompat.from(mContext) notificationManagerCompat.cancel(notificationId) } + + private fun createLogsPendingIntent(): PendingIntent { + val intent = Intent(mContext, MainActivity::class.java) + intent.action = MainActivity.MAIN_ACTIVITY_START_LOG + return PendingIntent.getActivity( + mContext, + LOGS_PENDING_INTENT_REQUEST, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } } diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/notifications/support/StatusObject.kt b/app/src/main/java/ca/pkay/rcloneexplorer/notifications/support/StatusObject.kt index 9b59eec45..3097bc655 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/notifications/support/StatusObject.kt +++ b/app/src/main/java/ca/pkay/rcloneexplorer/notifications/support/StatusObject.kt @@ -16,10 +16,16 @@ class StatusObject(var mContext: Context){ var mErrorList = ArrayList() var mStats = JSONObject() var mLogline = JSONObject() + private var retryAttemptErrorCount = 0 var estimatedAverageSpeed = 0L var lastItemAverageSpeed = 0L + companion object { + private val retryAttemptPattern = + Regex("""^Attempt (\d+)/(\d+) failed with (\d+) errors?.*""") + } + fun getSpeed(): String { return Formatter.formatFileSize(mContext, mStats.optLong("speed", 0)) + "/s" } @@ -63,6 +69,22 @@ class StatusObject(var mContext: Context){ return mStats.optInt("deletes", 0) + mStats.optInt("deletedDirs", 0) } + fun getRenames(): Int { + return mStats.optInt("renames", 0) + } + + fun getErrorCount(): Int { + return when { + retryAttemptErrorCount > 0 -> retryAttemptErrorCount + mErrorList.isNotEmpty() -> mErrorList.size + else -> mStats.optInt("errors", 0) + } + } + + fun hasErrors(): Boolean { + return getErrorCount() > 0 + } + fun getErrorMessage(): String { if(mLogline.has("msg") && mLogline.getString("level") == "error") { return mLogline.getString("msg") @@ -82,9 +104,24 @@ class StatusObject(var mContext: Context){ clearObject() mLogline = logLine - var error = ErrorObject(getErrorObject(), getErrorMessage()) + val retryAttempt = retryAttemptPattern.matchEntire(getErrorMessage()) + if (retryAttempt != null) { + val attempt = retryAttempt.groupValues[1].toInt() + val attempts = retryAttempt.groupValues[2].toInt() + retryAttemptErrorCount = retryAttempt.groupValues[3].toInt() + // Rclone repeats the same underlying errors on retries; keep only the final attempt details. + if (attempt < attempts) { + mErrorList.clear() + retryAttemptErrorCount = 0 + } + return + } + + val error = ErrorObject(getErrorObject(), getErrorMessage()) Log.e(TAG, error.mErrorObject + " - " + error.mErrorMessage) - mErrorList.add(error) + if (mErrorList.none { it.mErrorObject == error.mErrorObject && it.mErrorMessage == error.mErrorMessage }) { + mErrorList.add(error) + } } if(logLine.has("stats")) { @@ -231,7 +268,9 @@ class StatusObject(var mContext: Context){ var all = "" mErrorList.forEach { all += it.mErrorMessage + "\n" - all += mContext.getString(R.string.status_offendingfile) + it.mErrorObject + "\n" + if (it.mErrorObject.isNotEmpty() && !it.mErrorMessage.startsWith("not deleting ")) { + all += mContext.getString(R.string.status_offendingfile) + it.mErrorObject + "\n" + } } return all } @@ -305,4 +344,4 @@ class StatusObject(var mContext: Context){ "$daysText$hoursText$minutesText$secondsText" } } -} \ No newline at end of file +} diff --git a/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncResultFormatter.kt b/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncResultFormatter.kt new file mode 100644 index 000000000..d68fc9a62 --- /dev/null +++ b/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncResultFormatter.kt @@ -0,0 +1,82 @@ +package ca.pkay.rcloneexplorer.workmanager + +/** + * Formats the final sync result from already-collected stats. + * + * Example: transfers=1, deletions=2 -> "Successfully synced 4 MB in 1 file.\nAlso deleted 2 files/folders." + */ +object SyncResultFormatter { + /** + * Final rclone counters used for user-facing summaries. `totalTransfers` is copied or updated files, + * while deletions and renames are separate change types. + */ + data class Stats( + val totalTransfers: Int, + val totalSize: String, + val deletions: Int, + val renames: Int, + val errors: Int + ) { + fun changedItems(): Int = totalTransfers + deletions + renames + fun hasErrors(): Boolean = errors > 0 + fun hasChanges(): Boolean = changedItems() > 0 + } + + /** + * Localized message builders supplied by Android resources. + */ + data class Labels( + val nothingToDo: String, + val completedWithErrors: (Int) -> String, + val completed: String, + val transferSummary: (String, Int) -> String, + val deletionSummary: (Int, Boolean) -> String, + val renameSummary: (Int, Boolean) -> String + ) + + /** + * Clean success message. "Nothing to do" is only valid when there were no changes and no errors. + */ + fun successMessage(stats: Stats, labels: Labels): String { + if (!stats.hasChanges() && !stats.hasErrors()) { + return labels.nothingToDo + } + val lines = summaryLines(stats, labels) + return if (lines.isEmpty()) { + labels.completed + } else { + lines.joinToString("\n") + } + } + + /** + * Partial result message: keep the clear error state, but still include any work rclone completed. + */ + fun completedWithErrorsMessage(stats: Stats, labels: Labels): String { + return (listOf(labels.completedWithErrors(stats.errors)) + summaryLines(stats, labels)) + .joinToString("\n") + } + + private fun summaryLines(stats: Stats, labels: Labels): List { + val lines = ArrayList() + var hasActionLine = false + + if (stats.totalTransfers > 0) { + lines.add(labels.transferSummary(stats.totalSize, stats.totalTransfers)) + hasActionLine = true + } else if (stats.hasChanges()) { + lines.add(labels.completed) + } + + if (stats.deletions > 0) { + lines.add(labels.deletionSummary(stats.deletions, hasActionLine)) + hasActionLine = true + } + + if (stats.renames > 0) { + lines.add(labels.renameSummary(stats.renames, hasActionLine)) + } + + return lines + } +} 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..3179d567f 100644 --- a/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt +++ b/app/src/main/java/ca/pkay/rcloneexplorer/workmanager/SyncWorker.kt @@ -66,7 +66,7 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) private val mPreferences = PreferenceManager.getDefaultSharedPreferences(mContext) - private var log2File: Log2File? = null + private val log2File = Log2File(mContext) @@ -129,8 +129,11 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) return Result.failure() } - // Indicate whether the work finished successfully with the Result - return Result.success() + return if (failureReason == FAILURE_REASON.NO_FAILURE) { + Result.success() + } else { + Result.failure() + } } override fun onStopped() { @@ -183,13 +186,15 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) val line = iterator.next() try { val logline = JSONObject(line) + val level = logline.optString("level") + if (sIsLoggingEnabled && (level == "error" || level == "warning")) { + log2File.log(line) + } + //todo: migrate this to StatusObject, so that we can handle everything properly. - if (logline.getString("level") == "error") { - if (sIsLoggingEnabled) { - log2File?.log(line) - } + if (level == "error") { statusObject.parseLoglineToStatusObject(logline) - } else if (logline.getString("level") == "warning") { + } else if (level == "warning" || logline.has("stats")) { statusObject.parseLoglineToStatusObject(logline) } @@ -211,7 +216,10 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) FLog.e(TAG, "onHandleIntent: error reading stdout", e) } try { - localProcessReference.waitFor() + val exitCode = localProcessReference.waitFor() + if (exitCode != 0 || statusObject.hasErrors()) { + failureReason = FAILURE_REASON.RCLONE_ERROR + } } catch (e: InterruptedException) { FLog.e(TAG, "onHandleIntent: error waiting for process", e) } @@ -256,7 +264,11 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) content = mContext.getString(R.string.operation_failed_no_connection, mTitle) } FAILURE_REASON.RCLONE_ERROR -> { - content = mContext.getString(R.string.operation_failed_unknown_rclone_error, mTitle) + content = if (statusObject.hasErrors()) { + generateCompletedWithErrorsMessage(statusObject) + } else { + mContext.getString(R.string.operation_failed_unknown_rclone_error, mTitle) + } } } followupTask(mTask.onFailFollowup) @@ -295,28 +307,70 @@ class SyncWorker (private var mContext: Context, workerParams: WorkerParameters) // this is currently only a useless mapper. It is supposed to keep this worker in sync with the ephemeral one. // when they are merged eventually, this can be easily extracted. private fun generateSuccessMessage(statusObject: StatusObject): String { - var message = mContext.resources.getQuantityString( - R.plurals.operation_success_description, - statusObject.getTotalTransfers(), - mTitle, - statusObject.getTotalSize(), - statusObject.getTotalTransfers() + return SyncResultFormatter.successMessage(statusObject.toResultStats(), resultLabels()) + } + + private fun generateCompletedWithErrorsMessage(statusObject: StatusObject): String { + return SyncResultFormatter.completedWithErrorsMessage(statusObject.toResultStats(), resultLabels()) + } + + private fun StatusObject.toResultStats(): SyncResultFormatter.Stats { + return SyncResultFormatter.Stats( + getTotalTransfers(), + getTotalSize(), + getDeletions(), + getRenames(), + getErrorCount() ) - if (statusObject.getTotalTransfers() == 0) { - message = mContext.resources.getString(R.string.operation_success_description_zero) - } - if (statusObject.getDeletions() > 0) { - message += """ - - ${ - mContext.getString( - R.string.operation_success_description_deletions_prefix, - statusObject.getDeletions() + } + + /** + * Android resource-backed labels for SyncResultFormatter. Keeping these here avoids putting Context + * or plural-resource knowledge into the formatter. + */ + private fun resultLabels(): SyncResultFormatter.Labels { + return SyncResultFormatter.Labels( + nothingToDo = mContext.getString(R.string.operation_success_description_zero), + completedWithErrors = { errors -> + mContext.resources.getQuantityString( + R.plurals.operation_completed_with_errors, + errors, + errors + ) + }, + completed = mContext.getString(R.string.operation_success_description_completed), + transferSummary = { totalSize, transfers -> + mContext.resources.getQuantityString( + R.plurals.operation_success_description, + transfers, + mTitle, + totalSize, + transfers + ) + }, + deletionSummary = { deletions, isAdditional -> + mContext.resources.getQuantityString( + if (isAdditional) { + R.plurals.operation_success_description_deletions_also + } else { + R.plurals.operation_success_description_deletions + }, + deletions, + deletions + ) + }, + renameSummary = { renames, isAdditional -> + mContext.resources.getQuantityString( + if (isAdditional) { + R.plurals.operation_success_description_renames_also + } else { + R.plurals.operation_success_description_renames + }, + renames, + renames ) } - """.trimIndent() - } - return message + ) } private fun showFailNotification(notificationId: Int, content: String, wasCancelled: Boolean = false) { diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 41445b278..c40b30c20 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -312,11 +312,31 @@ Copia %s sincronitzat amb èxit No hi ha cap operació pendent. + Sincronització completada. + + Sincronització completada amb %d error. + Sincronització completada amb %d errors. + S\'ha sincronitzat amb èxit %2$s al fitxer %3$d. S\'han sincronitzat amb èxit %2$s als fitxers %3$d. - S\'han suprimit també %d fitxers i carpetes. + + S\'ha suprimit %d fitxer/carpeta. + S\'han suprimit %d fitxers/carpetes. + + + També s\'ha suprimit %d fitxer/carpeta. + També s\'han suprimit %d fitxers/carpetes. + + + S\'ha canviat el nom de %d fitxer/carpeta. + S\'ha canviat el nom de %d fitxers/carpetes. + + + També s\'ha canviat el nom de %d fitxer/carpeta. + També s\'ha canviat el nom de %d fitxers/carpetes. + Informe de les sincronitzacions reeixides %d tasques reeixides Informe de les fallades en les sincronitzacions diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e4f6f558c..4e56dc258 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -299,13 +299,32 @@ Kopiere von entferntem Speicher nach lokalem Speicher Kopieren %s war erfolgreich + Es war nichts zu tun. + Synchronisierung abgeschlossen. + + Synchronisierung mit %d Fehler abgeschlossen. + Synchronisierung mit %d Fehlern abgeschlossen. + - Es gab nichts zu tun. Es wurden %2$s in %3$d Datei erfolgreich synchronisiert. Es wurden %2$s in %3$d Dateien erfolgreich synchronisiert. - Es war nichts zu tun. - Es wurden ebenfalls %d Dateien und Ordner gelöscht. + + %d Datei/Ordner gelöscht. + %d Dateien/Ordner gelöscht. + + + Außerdem wurde %d Datei/Ordner gelöscht. + Außerdem wurden %d Dateien/Ordner gelöscht. + + + %d Datei/Ordner umbenannt. + %d Dateien/Ordner umbenannt. + + + Außerdem wurde %d Datei/Ordner umbenannt. + Außerdem wurden %d Dateien/Ordner umbenannt. + Sync-Bericht Erfolge %d Aufgaben erfolgreich Sync-Bericht Fehlschläge diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1cc7751dd..f3d1937d4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -306,11 +306,31 @@ 复制 %s 同步成功 没什么事可做。 + 同步已完成。 + + 同步已完成,但有 %d 个错误。 + 同步已完成,但有 %d 个错误。 + 已成功同步 %3$d 文件中的 %2$s。 已成功同步 %3$d 文件中的 %2$s。 - 还删除了 %d 个文件和文件夹。 + + 已删除 %d 个文件/文件夹。 + 已删除 %d 个文件/文件夹。 + + + 还删除了 %d 个文件/文件夹。 + 还删除了 %d 个文件/文件夹。 + + + 已重命名 %d 个文件/文件夹。 + 已重命名 %d 个文件/文件夹。 + + + 还重命名了 %d 个文件/文件夹。 + 还重命名了 %d 个文件/文件夹。 + Sync-Report 成功 %d 任务成功 同步报告 - 失败 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 685fb6fe1..0ad187572 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -337,11 +337,31 @@ Copy %s sync successful There was nothing to do. + Sync completed. + + Sync completed with %d error. + Sync completed with %d errors. + Successfully synced %2$s in %3$d file. Successfully synced %2$s in %3$d files. - Also deleted %d files and folders. + + Deleted %d file/folder. + Deleted %d files/folders. + + + Also deleted %d file/folder. + Also deleted %d files/folders. + + + Renamed %d file/folder. + Renamed %d files/folders. + + + Also renamed %d file/folder. + Also renamed %d files/folders. + Sync-Report Successes %d Tasks succeeded Sync-Report Failures