diff --git a/app/src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt b/app/src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt index 4100364039f8..14dfbe6ff0fe 100644 --- a/app/src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt +++ b/app/src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt @@ -16,6 +16,7 @@ import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.net.Uri import android.os.Build +import android.text.format.Formatter import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.content.FileProvider @@ -24,13 +25,19 @@ import com.nextcloud.client.core.Cancellable import com.nextcloud.client.core.Clock import com.nextcloud.client.logger.LogEntry import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.notifications.NotificationUtils import com.owncloud.android.utils.FileExportUtils import java.io.File import java.security.SecureRandom import java.util.TimeZone +import javax.inject.Inject -class LogsEmailSender(private val context: Context, private val clock: Clock, private val runner: AsyncRunner) { +class LogsEmailSender @Inject constructor( + private val context: Context, + private val clock: Clock, + private val runner: AsyncRunner +) { private companion object { const val LOGS_MIME_TYPE = "text/plain" @@ -74,10 +81,27 @@ class LogsEmailSender(private val context: Context, private val clock: Clock, pr fun export(logs: List) { if (task == null) { val outFile = File(context.cacheDir, "attachments/logs.txt") - task = runner.postQuickTask(Task(context, logs, outFile, clock.tz), onResult = { - task = null - export(outFile) - }) + task = runner.postQuickTask( + task = { + Task(context, logs, outFile, clock.tz).invoke() + export(outFile) + outFile.length() + }, + onResult = { size -> + task = null + val message = context.getString( + R.string.logs_export_success, + Formatter.formatFileSize(context, size) + ) + showExportResult(message) + }, + onError = { error -> + task = null + Log_OC.e(this, "Could not export logs", error) + val reason = error.localizedMessage ?: context.getString(R.string.common_error_unknown) + showExportResult(context.getString(R.string.logs_export_failed, reason)) + } + ) } } @@ -89,27 +113,17 @@ class LogsEmailSender(private val context: Context, private val clock: Clock, pr } private fun export(file: File) { - FileExportUtils().exportFile( + FileExportUtils("Nextcloud").exportFile( "Nextcloud Android Files Logs", "text/plain", context.contentResolver, null, file ) - showSuccessNotification(1) - } - - fun showSuccessNotification(successfulExports: Int) { - showNotification( - context.resources.getQuantityString( - R.plurals.export_successful, - successfulExports, - successfulExports - ) - ) } - private fun showNotification(message: String) { + private fun showExportResult(message: String) { + Toast.makeText(context, message, Toast.LENGTH_LONG).show() val notificationId = SecureRandom().nextInt() val notificationBuilder = NotificationCompat.Builder( @@ -117,7 +131,9 @@ class LogsEmailSender(private val context: Context, private val clock: Clock, pr NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD ) .setSmallIcon(R.drawable.notification_icon) - .setContentTitle(message) + .setContentTitle(context.getString(R.string.logs_menu_export)) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) .setAutoCancel(true) val actionIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS).apply { diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java index 0b8c51755f78..c2740ac0702b 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java @@ -15,15 +15,18 @@ */ package com.owncloud.android.ui.activity; +import android.Manifest; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.res.Configuration; import android.graphics.drawable.Drawable; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -46,7 +49,9 @@ import com.nextcloud.client.account.UserAccountManager; import com.nextcloud.client.di.Injectable; import com.nextcloud.client.etm.EtmActivity; +import com.nextcloud.client.logger.LogsRepository; import com.nextcloud.client.logger.ui.LogsActivity; +import com.nextcloud.client.logger.ui.LogsEmailSender; import com.nextcloud.client.network.ClientFactory; import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.client.preferences.AppPreferences; @@ -128,6 +133,7 @@ public class SettingsActivity extends PreferenceActivity private static final int ACTION_REQUEST_CODE_DAVDROID_SETUP = 10; private static final int ACTION_SHOW_MNEMONIC = 11; private static final int ACTION_E2E = 12; + private static final int REQUEST_EXPORT_LOGS_PERMISSION = 101; private static final int TRUE_VALUE = 1; private static final String DAV_PATH = "/remote.php/dav"; @@ -155,6 +161,8 @@ public class SettingsActivity extends PreferenceActivity @Inject ClientFactory clientFactory; @Inject ViewThemeUtils viewThemeUtils; @Inject ConnectivityService connectivityService; + @Inject LogsRepository logsRepository; + @Inject LogsEmailSender logsEmailSender; @SuppressWarnings("deprecation") @Override @@ -416,6 +424,15 @@ private void setupImprintPreference(PreferenceCategory preferenceCategoryMore) { } private void setupLoggingPreference(PreferenceCategory preferenceCategoryMore) { + Preference pExport = findPreference("export_logs"); + if (MDMConfig.INSTANCE.isLogEnabled(this)) { + pExport.setOnPreferenceClickListener(preference -> { + exportLogs(); + return true; + }); + } else { + preferenceCategoryMore.removePreference(pExport); + } Preference pLogger = findPreference("logger"); if (pLogger != null) { if (MDMConfig.INSTANCE.isLogEnabled(this)) { @@ -431,6 +448,20 @@ private void setupLoggingPreference(PreferenceCategory preferenceCategoryMore) { } } + private void exportLogs() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + && !PermissionUtil.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUEST_EXPORT_LOGS_PERMISSION); + return; + } + logsRepository.load((entries, size) -> { + if (!isDestroyed()) { + logsEmailSender.export(entries); + } + return Unit.INSTANCE; + }); + } + private void setupRecommendPreference(PreferenceCategory preferenceCategoryMore) { boolean recommendEnabled = getResources().getBoolean(R.bool.recommend_enabled); @@ -1239,10 +1270,20 @@ protected void onPostCreate(Bundle savedInstanceState) { @Override protected void onDestroy() { + logsEmailSender.stop(); super.onDestroy(); getDelegate().onDestroy(); } + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == REQUEST_EXPORT_LOGS_PERMISSION + && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + exportLogs(); + } + } + @Override protected void onStop() { super.onStop(); diff --git a/app/src/main/java/com/owncloud/android/utils/FileExportUtils.kt b/app/src/main/java/com/owncloud/android/utils/FileExportUtils.kt index b13936b94699..a7a0a1ac0dc0 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileExportUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/FileExportUtils.kt @@ -22,9 +22,10 @@ import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException import java.io.InputStream +import java.io.OutputStream -class FileExportUtils { - @Throws(IllegalStateException::class) +class FileExportUtils(private val subdirectory: String = "") { + @Throws(IllegalStateException::class, IOException::class) fun exportFile(fileName: String, mimeType: String, contentResolver: ContentResolver, ocFile: OCFile?, file: File?) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { exportFileAndroid10AndAbove( @@ -56,27 +57,17 @@ class FileExportUtils { val cv = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) put(MediaStore.MediaColumns.MIME_TYPE, mimeType) - put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/$subdirectory") } - var uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv) - - if (uri == null) { - var count = INITIAL_RENAME_COUNT - do { - val name = generateNewName(fileName, count) - cv.put(MediaStore.MediaColumns.DISPLAY_NAME, name) - uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv) - - count++ - } while (uri == null) - } + val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv) + ?: throw IOException("Could not create export in Downloads") copy( ocFile, file, contentResolver, - FileOutputStream(contentResolver.openFileDescriptor(uri, "w")?.fileDescriptor) + contentResolver.openOutputStream(uri, "w") ?: throw IOException("Could not open export in Downloads") ) } @@ -87,8 +78,13 @@ class FileExportUtils { file: File? ) { try { - var target = File( + val directory = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + subdirectory + ) + directory.mkdirs() + var target = File( + directory, fileName ) @@ -97,7 +93,7 @@ class FileExportUtils { do { val name = generateNewName(fileName, count) target = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + directory, name ) @@ -113,31 +109,34 @@ class FileExportUtils { ) } catch (e: FileNotFoundException) { Log_OC.e(this, "File not found", e) + throw e } catch (e: IOException) { Log_OC.e(this, "Cannot write file", e) + throw e } } - @Throws(IllegalStateException::class) - private fun copy(ocFile: OCFile?, file: File?, contentResolver: ContentResolver, outputStream: FileOutputStream) { + @Throws(IllegalStateException::class, IOException::class) + private fun copy(ocFile: OCFile?, file: File?, contentResolver: ContentResolver, outputStream: OutputStream) { outputStream.use { fos -> try { val inputStream = when { ocFile != null -> contentResolver.openInputStream(ocFile.storageUri) file != null -> FileInputStream(file) else -> error("ocFile and file both may not be null") - }!! + } ?: throw IOException("Could not open export source") inputStream.use { fis -> copyStream(fis, fos) } } catch (e: IOException) { Log_OC.e(this, "Cannot write file", e) + throw e } } } - private fun copyStream(inputStream: InputStream, outputStream: FileOutputStream) { + private fun copyStream(inputStream: InputStream, outputStream: OutputStream) { val buffer = ByteArray(COPY_BUFFER_SIZE) var len: Int while (inputStream.read(buffer).also { len = it } != -1) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3d6f60bf3e1..199e31e0cf8e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -689,6 +689,8 @@ Refresh Send logs by email Export logs + Logs saved to Download/Nextcloud/ (%1$s) + Could not save logs to Download/Nextcloud/: %1$s Delete logs No app for sending logs found. Please install an email client. %1$s Android app logs diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index aa73c18f2f11..cc3b4a5745b2 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -129,6 +129,9 @@ + diff --git a/app/src/test/java/com/nextcloud/client/logger/LogExportFailureTest.kt b/app/src/test/java/com/nextcloud/client/logger/LogExportFailureTest.kt new file mode 100644 index 000000000000..732a0c03ec8a --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/logger/LogExportFailureTest.kt @@ -0,0 +1,77 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.logger + +import android.content.ContentResolver +import android.os.Environment +import com.owncloud.android.utils.FileExportUtils +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.Mockito.mockConstruction +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +class LogExportFailureTest { + @get:Rule + val directory = TemporaryFolder() + + private val resolver: ContentResolver = mock() + private val exporter = FileExportUtils("Nextcloud") + + @Test + fun exportedBytesAndSizeMatchTheSerializedLog() = withDownloads { + val source = directory.newFile("source.txt").apply { writeText("ERROR: key verification failed 验证失败\n") } + + exporter.exportFile("logs.txt", "text/plain", resolver, null, source) + + val exported = File(directory.root, "Nextcloud/logs.txt") + assertArrayEquals(source.readBytes(), exported.readBytes()) + assertEquals(source.length(), exported.length()) + } + + @Test + fun missingSourceIsReportedAsFailure() = withDownloads { + assertThrows(IOException::class.java) { + exporter.exportFile("logs.txt", "text/plain", resolver, null, File(directory.root, "missing")) + } + } + + @Test + fun diskWriteErrorIsReportedAndOutputIsClosed() = withDownloads { + val source = directory.newFile("source.txt").apply { writeText("ERROR: verification failed\n") } + mockConstruction(FileOutputStream::class.java) { output, _ -> + doThrow(IOException("Disk full")).whenever(output).write(any(), any(), any()) + }.use { outputs -> + val error = assertThrows(IOException::class.java) { + exporter.exportFile("logs.txt", "text/plain", resolver, null, source) + } + assertEquals("Disk full", error.message) + verify(outputs.constructed().single()).close() + } + } + + private fun withDownloads(block: () -> Unit) { + mockStatic(Environment::class.java).use { environment -> + environment.`when` { + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + }.thenReturn(directory.root) + block() + } + } +}