Browse Source

New logger implementation

Fixes #4228

Signed-off-by: Chris Narkiewicz <hello@ezaquarii.com>
Chris Narkiewicz 5 years ago
parent
commit
79e8d59aa1
35 changed files with 2060 additions and 313 deletions
  1. 2 1
      .gitignore
  2. 5 0
      .idea/codeStyles/Project.xml
  3. 5 1
      build.gradle
  4. 1 0
      detekt.yml
  5. 1 1
      src/main/AndroidManifest.xml
  6. 33 0
      src/main/java/com/nextcloud/client/core/AsyncRunner.kt
  7. 65 0
      src/main/java/com/nextcloud/client/core/AsyncRunnerImpl.kt
  8. 24 0
      src/main/java/com/nextcloud/client/core/Cancellable.kt
  9. 29 0
      src/main/java/com/nextcloud/client/core/Clock.kt
  10. 38 0
      src/main/java/com/nextcloud/client/core/ClockImpl.kt
  11. 42 0
      src/main/java/com/nextcloud/client/di/AppModule.java
  12. 2 2
      src/main/java/com/nextcloud/client/di/ComponentsModule.java
  13. 6 0
      src/main/java/com/nextcloud/client/di/ViewModelModule.kt
  14. 131 0
      src/main/java/com/nextcloud/client/logger/FileLogHandler.kt
  15. 61 0
      src/main/java/com/nextcloud/client/logger/LegacyLoggerAdapter.kt
  16. 43 0
      src/main/java/com/nextcloud/client/logger/Level.kt
  17. 107 0
      src/main/java/com/nextcloud/client/logger/LogEntry.kt
  18. 31 0
      src/main/java/com/nextcloud/client/logger/Logger.kt
  19. 165 0
      src/main/java/com/nextcloud/client/logger/LoggerImpl.kt
  20. 51 0
      src/main/java/com/nextcloud/client/logger/LogsRepository.kt
  21. 76 0
      src/main/java/com/nextcloud/client/logger/ThreadLoop.kt
  22. 60 0
      src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt
  23. 93 0
      src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt
  24. 67 0
      src/main/java/com/nextcloud/client/logger/ui/LogsViewModel.kt
  25. 6 0
      src/main/java/com/owncloud/android/MainApp.java
  26. 0 289
      src/main/java/com/owncloud/android/ui/activity/LogHistoryActivity.java
  27. 120 0
      src/main/java/com/owncloud/android/ui/activity/LogsActivity.java
  28. 1 1
      src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
  29. 22 0
      src/main/res/layout/log_entry_list_item.xml
  30. 3 18
      src/main/res/layout/logs_activity.xml
  31. 3 0
      src/main/res/xml/exposed_filepaths.xml
  32. 114 0
      src/test/java/com/nextcloud/client/core/AsyncRunnerTest.kt
  33. 142 0
      src/test/java/com/nextcloud/client/logger/LogEntryTest.kt
  34. 226 0
      src/test/java/com/nextcloud/client/logger/TestFileLogHandler.kt
  35. 285 0
      src/test/java/com/nextcloud/client/logger/TestLogger.kt

+ 2 - 1
.gitignore

@@ -32,6 +32,7 @@ tests/proguard-project.txt
 *.iml
 build
 /gradle.properties
-
+.attach_pid*
 fastlane/Fastfile
+*.hprof
 

+ 5 - 0
.idea/codeStyles/Project.xml

@@ -60,6 +60,11 @@
       </option>
     </JavaCodeStyleSettings>
     <JetCodeStyleSettings>
+      <option name="PACKAGES_TO_USE_STAR_IMPORTS">
+        <value>
+          <package name="kotlinx.android.synthetic" withSubpackages="true" static="false" />
+        </value>
+      </option>
       <option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
       <option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
     </JetCodeStyleSettings>

+ 5 - 1
build.gradle

@@ -57,7 +57,7 @@ configurations {
 ext {
     jacocoVersion = "0.8.2"
     daggerVersion = "2.24"
-    androidLibraryVersion = "master-SNAPSHOT"
+    androidLibraryVersion = "plug-custom-impl-into-legacy-logger-SNAPSHOT"
 
     travisBuild = System.getenv("TRAVIS") == "true"
 
@@ -164,6 +164,10 @@ android {
                 versionName "1"
             }
         }
+
+        testOptions {
+            unitTests.returnDefaultValues = true
+        }
     }
 
     // adapt structure from Eclipse to Gradle/Android Studio expectations;

+ 1 - 0
detekt.yml

@@ -22,6 +22,7 @@ test-pattern: # Configure exclusions for test sources
     - 'ForEachOnRange'
     - 'FunctionMaxLength'
     - 'TooGenericExceptionCaught'
+    - 'TooGenericExceptionThrown'
     - 'InstanceOfCheckForException'
 
 build:

+ 1 - 1
src/main/AndroidManifest.xml

@@ -318,7 +318,7 @@
         <activity android:name=".ui.activity.ConflictsResolveActivity"/>
         <activity android:name=".ui.activity.ErrorsWhileCopyingHandlerActivity"/>
 
-        <activity android:name=".ui.activity.LogHistoryActivity"/>
+        <activity android:name=".ui.activity.LogsActivity"/>
 
         <activity android:name="com.nextcloud.client.errorhandling.ShowErrorActivity"
             android:theme="@style/Theme.ownCloud.Toolbar"

+ 33 - 0
src/main/java/com/nextcloud/client/core/AsyncRunner.kt

@@ -0,0 +1,33 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.core
+
+typealias TaskBody<T> = () -> T
+typealias OnResultCallback<T> = (T) -> Unit
+typealias OnErrorCallback = (Throwable) -> Unit
+
+/**
+ * This interface allows to post background tasks that report results via callbacks invoked on main thread.
+ *
+ * It is provided as an alternative for heavy, platform specific and virtually untestable [android.os.AsyncTask]
+ */
+interface AsyncRunner {
+    fun <T> post(block: () -> T, onResult: OnResultCallback<T>? = null, onError: OnErrorCallback? = null): Cancellable
+}

+ 65 - 0
src/main/java/com/nextcloud/client/core/AsyncRunnerImpl.kt

@@ -0,0 +1,65 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.core
+
+import android.os.Handler
+import java.util.concurrent.ScheduledThreadPoolExecutor
+import java.util.concurrent.atomic.AtomicBoolean
+
+internal class AsyncRunnerImpl(private val uiThreadHandler: Handler, corePoolSize: Int) : AsyncRunner {
+
+    private class Task<T>(
+        private val handler: Handler,
+        private val callable: () -> T,
+        private val onSuccess: OnResultCallback<T>?,
+        private val onError: OnErrorCallback?
+    ) : Runnable, Cancellable {
+
+        private val cancelled = AtomicBoolean(false)
+
+        override fun run() {
+            @Suppress("TooGenericExceptionCaught") // this is exactly what we want here
+            try {
+                val result = callable.invoke()
+                if (!cancelled.get()) {
+                    handler.post {
+                        onSuccess?.invoke(result)
+                    }
+                }
+            } catch (t: Throwable) {
+                if (!cancelled.get()) {
+                    handler.post { onError?.invoke(t) }
+                }
+            }
+        }
+
+        override fun cancel() {
+            cancelled.set(true)
+        }
+    }
+
+    private val executor = ScheduledThreadPoolExecutor(corePoolSize)
+
+    override fun <T> post(block: () -> T, onResult: OnResultCallback<T>?, onError: OnErrorCallback?): Cancellable {
+        val task = Task(uiThreadHandler, block, onResult, onError)
+        executor.execute(task)
+        return task
+    }
+}

+ 24 - 0
src/main/java/com/nextcloud/client/core/Cancellable.kt

@@ -0,0 +1,24 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.core
+
+interface Cancellable {
+    fun cancel()
+}

+ 29 - 0
src/main/java/com/nextcloud/client/core/Clock.kt

@@ -0,0 +1,29 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.core
+
+import java.util.Date
+import java.util.TimeZone
+
+interface Clock {
+    val currentTime: Long
+    val currentDate: Date
+    val tz: TimeZone
+}

+ 38 - 0
src/main/java/com/nextcloud/client/core/ClockImpl.kt

@@ -0,0 +1,38 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.core
+
+import java.util.Date
+import java.util.TimeZone
+
+class ClockImpl : Clock {
+    override val currentTime: Long
+        get() {
+            return System.currentTimeMillis()
+        }
+
+    override val currentDate: Date
+        get() {
+            return Date(currentTime)
+        }
+
+    override val tz: TimeZone
+        get() = TimeZone.getDefault()
+}

+ 42 - 0
src/main/java/com/nextcloud/client/di/AppModule.java

@@ -25,11 +25,20 @@ import android.app.Application;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.res.Resources;
+import android.os.Handler;
 
 import com.nextcloud.client.account.CurrentAccountProvider;
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.account.UserAccountManagerImpl;
+import com.nextcloud.client.core.AsyncRunner;
+import com.nextcloud.client.core.AsyncRunnerImpl;
+import com.nextcloud.client.core.Clock;
+import com.nextcloud.client.core.ClockImpl;
 import com.nextcloud.client.device.DeviceInfo;
+import com.nextcloud.client.logger.FileLogHandler;
+import com.nextcloud.client.logger.Logger;
+import com.nextcloud.client.logger.LoggerImpl;
+import com.nextcloud.client.logger.LogsRepository;
 import com.owncloud.android.datamodel.ArbitraryDataProvider;
 import com.owncloud.android.datamodel.UploadsStorageManager;
 import com.owncloud.android.ui.activities.data.activities.ActivitiesRepository;
@@ -40,6 +49,10 @@ import com.owncloud.android.ui.activities.data.files.FilesRepository;
 import com.owncloud.android.ui.activities.data.files.FilesServiceApiImpl;
 import com.owncloud.android.ui.activities.data.files.RemoteFilesRepository;
 
+import java.io.File;
+
+import javax.inject.Singleton;
+
 import dagger.Module;
 import dagger.Provides;
 
@@ -104,4 +117,33 @@ class AppModule {
     DeviceInfo deviceInfo() {
         return new DeviceInfo();
     }
+
+    @Provides
+    @Singleton
+    Clock clock() {
+        return new ClockImpl();
+    }
+
+    @Provides
+    @Singleton
+    Logger logger(Context context, Clock clock) {
+        File logDir = new File(context.getFilesDir(), "logs");
+        FileLogHandler handler = new FileLogHandler(logDir, "log.txt", 1024*1024);
+        LoggerImpl logger = new LoggerImpl(clock, handler, new Handler(), 1000);
+        logger.start();
+        return logger;
+    }
+
+    @Provides
+    @Singleton
+    LogsRepository logsRepository(Logger logger) {
+        return (LogsRepository)logger;
+    }
+
+    @Provides
+    @Singleton
+    AsyncRunner asyncRunner() {
+        Handler uiHandler = new Handler();
+        return new AsyncRunnerImpl(uiHandler, 4);
+    }
 }

+ 2 - 2
src/main/java/com/nextcloud/client/di/ComponentsModule.java

@@ -45,7 +45,7 @@ import com.owncloud.android.ui.activity.ExternalSiteWebView;
 import com.owncloud.android.ui.activity.FileDisplayActivity;
 import com.owncloud.android.ui.activity.FilePickerActivity;
 import com.owncloud.android.ui.activity.FolderPickerActivity;
-import com.owncloud.android.ui.activity.LogHistoryActivity;
+import com.owncloud.android.ui.activity.LogsActivity;
 import com.owncloud.android.ui.activity.ManageAccountsActivity;
 import com.owncloud.android.ui.activity.ManageSpaceActivity;
 import com.owncloud.android.ui.activity.NotificationsActivity;
@@ -101,7 +101,7 @@ abstract class ComponentsModule {
     @ContributesAndroidInjector abstract FilePickerActivity filePickerActivity();
     @ContributesAndroidInjector abstract FirstRunActivity firstRunActivity();
     @ContributesAndroidInjector abstract FolderPickerActivity folderPickerActivity();
-    @ContributesAndroidInjector abstract LogHistoryActivity logHistoryActivity();
+    @ContributesAndroidInjector abstract LogsActivity logsActivity();
     @ContributesAndroidInjector abstract ManageAccountsActivity manageAccountsActivity();
     @ContributesAndroidInjector abstract ManageSpaceActivity manageSpaceActivity();
     @ContributesAndroidInjector abstract NotificationsActivity notificationsActivity();

+ 6 - 0
src/main/java/com/nextcloud/client/di/ViewModelModule.kt

@@ -22,6 +22,7 @@ package com.nextcloud.client.di
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.ViewModelProvider
 import com.nextcloud.client.etm.EtmViewModel
+import com.nextcloud.client.logger.ui.LogsViewModel
 import dagger.Binds
 import dagger.Module
 import dagger.multibindings.IntoMap
@@ -33,6 +34,11 @@ abstract class ViewModelModule {
     @ViewModelKey(EtmViewModel::class)
     abstract fun etmViewModel(vm: EtmViewModel): ViewModel
 
+    @Binds
+    @IntoMap
+    @ViewModelKey(LogsViewModel::class)
+    abstract fun logsViewModel(vm: LogsViewModel): ViewModel
+
     @Binds
     abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
 }

+ 131 - 0
src/main/java/com/nextcloud/client/logger/FileLogHandler.kt

@@ -0,0 +1,131 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger
+
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.FileOutputStream
+import java.io.IOException
+import java.nio.charset.Charset
+
+/**
+ * Very simple log writer with file rotations.
+ *
+ * Files are rotated when writing entry causes log file to exceed it's maximum size.
+ * Last entry is not truncated and final log file can exceed max file size, but
+ * no further entries will be written to it.
+ */
+internal class FileLogHandler(private val logDir: File, private val logFilename: String, private val maxSize: Long) {
+
+    companion object {
+        const val ROTATED_LOGS_COUNT = 3
+    }
+
+    private var writer: FileOutputStream? = null
+    private var size: Long = 0
+    private val rotationList = listOf(
+        "$logFilename.2",
+        "$logFilename.1",
+        "$logFilename.0",
+        logFilename
+    )
+
+    val logFile: File
+        get() {
+            return File(logDir, logFilename)
+        }
+
+    val isOpened: Boolean
+        get() {
+            return writer != null
+        }
+
+    val maxLogFilesCount get() = rotationList.size
+
+    fun open() {
+        try {
+            writer = FileOutputStream(logFile, true)
+            size = logFile.length()
+        } catch (ex: FileNotFoundException) {
+            logFile.parentFile.mkdirs()
+            writer = FileOutputStream(logFile, true)
+            size = logFile.length()
+        }
+    }
+
+    fun write(logEntry: String) {
+        val rawLogEntry = logEntry.toByteArray(Charset.forName("UTF-8"))
+        writer?.write(rawLogEntry)
+        size += rawLogEntry.size
+        if (size > maxSize) {
+            rotateLogs()
+        }
+    }
+
+    fun close() {
+        writer?.close()
+        writer = null
+        size = 0L
+    }
+
+    fun deleteAll() {
+        rotationList
+            .map { File(logDir, it) }
+            .forEach { it.delete() }
+    }
+
+    fun rotateLogs() {
+        val rotatatingOpenedLog = isOpened
+        if (rotatatingOpenedLog) {
+            close()
+        }
+
+        val existingLogFiles = logDir.listFiles().associate { it.name to it }
+        existingLogFiles[rotationList.first()]?.delete()
+
+        for (i in 0 until rotationList.size - 1) {
+            val nextFile = File(logDir, rotationList[i])
+            val previousFile = existingLogFiles[rotationList[i + 1]]
+            previousFile?.renameTo(nextFile)
+        }
+
+        if (rotatatingOpenedLog) {
+            open()
+        }
+    }
+
+    fun loadLogFiles(rotated: Int = ROTATED_LOGS_COUNT): List<String> {
+        if (rotated < 0) {
+            throw IllegalArgumentException("Negative index")
+        }
+        val allLines = mutableListOf<String>()
+        for (i in 0..Math.min(rotated, rotationList.size - 1)) {
+            val file = File(logDir, rotationList[i])
+            if (!file.exists()) continue
+            try {
+                val rotatedLines = file.readLines(charset = Charsets.UTF_8)
+                allLines.addAll(rotatedLines)
+            } catch (ex: IOException) {
+                // ignore failing file
+            }
+        }
+        return allLines
+    }
+}

+ 61 - 0
src/main/java/com/nextcloud/client/logger/LegacyLoggerAdapter.kt

@@ -0,0 +1,61 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger
+
+import com.owncloud.android.lib.common.utils.Log_OC
+import java.lang.Exception
+
+/**
+ * This adapter is used by legacy [Log_OC] logger to redirect logs to custom logger implementation.
+ */
+class LegacyLoggerAdapter(private val logger: Logger) : Log_OC.Adapter {
+
+    override fun i(tag: String, message: String) {
+        logger.d(tag, message)
+    }
+
+    override fun d(tag: String, message: String) {
+        logger.d(tag, message)
+    }
+
+    override fun d(tag: String, message: String, e: Exception) {
+        logger.d(tag, message, e)
+    }
+
+    override fun e(tag: String, message: String) {
+        logger.e(tag, message)
+    }
+
+    override fun e(tag: String, message: String, t: Throwable) {
+        logger.e(tag, message, t)
+    }
+
+    override fun v(tag: String, message: String) {
+        logger.v(tag, message)
+    }
+
+    override fun w(tag: String, message: String) {
+        logger.w(tag, message)
+    }
+
+    override fun wtf(tag: String, message: String) {
+        logger.e(tag, message)
+    }
+}

+ 43 - 0
src/main/java/com/nextcloud/client/logger/Level.kt

@@ -0,0 +1,43 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger
+
+enum class Level(val tag: String) {
+    UNKNOWN("U"),
+    VERBOSE("V"),
+    DEBUG("D"),
+    INFO("I"),
+    WARNING("W"),
+    ERROR("E"),
+    ASSERT("A");
+
+    companion object {
+        @JvmStatic
+        fun fromTag(tag: String): Level = when (tag) {
+            "V" -> VERBOSE
+            "D" -> DEBUG
+            "I" -> INFO
+            "W" -> WARNING
+            "E" -> ERROR
+            "A" -> ASSERT
+            else -> UNKNOWN
+        }
+    }
+}

+ 107 - 0
src/main/java/com/nextcloud/client/logger/LogEntry.kt

@@ -0,0 +1,107 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger
+
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+data class LogEntry(val timestamp: Date, val level: Level, val tag: String, val message: String) {
+
+    companion object {
+        private const val UTC_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
+        private const val TZ_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
+        private val TIME_ZONE = TimeZone.getTimeZone("UTC")
+        private val DATE_GROUP_INDEX = 1
+        private val LEVEL_GROUP_INDEX = 2
+        private val TAG_GROUP_INDEX = 3
+        private val MESSAGE_GROUP_INDEX = 4
+
+        /**
+         *  <iso8601 date>;<level tag>;<entry tag>;<message>
+         *  1970-01-01T00:00:00.000Z;D;tag;some message
+         */
+        private val ENTRY_PARSE_REGEXP = Regex(
+            pattern = """(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z);([ADEIVW]);([^;]+);(.*)"""
+        )
+
+        @JvmStatic
+        fun buildDateFormat(tz: TimeZone? = null): SimpleDateFormat {
+            return if (tz == null) {
+                SimpleDateFormat(UTC_DATE_FORMAT, Locale.US).apply {
+                    timeZone = TIME_ZONE
+                    isLenient = false
+                }
+            } else {
+                SimpleDateFormat(TZ_DATE_FORMAT, Locale.US).apply {
+                    timeZone = tz
+                    isLenient = false
+                }
+            }
+        }
+
+        @Suppress("ReturnCount")
+        @JvmStatic
+        fun parse(s: String): LogEntry? {
+            val result = ENTRY_PARSE_REGEXP.matchEntire(s) ?: return null
+
+            val date = try {
+                buildDateFormat().parse(result.groupValues[DATE_GROUP_INDEX])
+            } catch (ex: ParseException) {
+                return null
+            }
+
+            val level: Level = Level.fromTag(result.groupValues[LEVEL_GROUP_INDEX])
+            val tag = result.groupValues[TAG_GROUP_INDEX]
+            val message = result.groupValues[MESSAGE_GROUP_INDEX].replace("\\n", "\n")
+
+            return LogEntry(
+                timestamp = date,
+                level = level,
+                tag = tag,
+                message = message
+            )
+        }
+    }
+
+    override fun toString(): String {
+        val sb = StringBuilder()
+        format(sb, buildDateFormat())
+        return sb.toString()
+    }
+
+    fun toString(tz: TimeZone): String {
+        val sb = StringBuilder()
+        format(sb, buildDateFormat(tz))
+        return sb.toString()
+    }
+
+    private fun format(sb: StringBuilder, dateFormat: SimpleDateFormat) {
+        sb.append(dateFormat.format(timestamp))
+        sb.append(';')
+        sb.append(level.tag)
+        sb.append(';')
+        sb.append(tag.replace(';', ' '))
+        sb.append(';')
+        sb.append(message.replace("\n", "\\n"))
+    }
+}

+ 31 - 0
src/main/java/com/nextcloud/client/logger/Logger.kt

@@ -0,0 +1,31 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger
+
+interface Logger {
+
+    fun v(tag: String, message: String)
+    fun d(tag: String, message: String)
+    fun d(tag: String, message: String, t: Throwable)
+    fun i(tag: String, message: String)
+    fun w(tag: String, message: String)
+    fun e(tag: String, message: String)
+    fun e(tag: String, message: String, t: Throwable)
+}

+ 165 - 0
src/main/java/com/nextcloud/client/logger/LoggerImpl.kt

@@ -0,0 +1,165 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger
+
+import android.os.Handler
+import android.util.Log
+import com.nextcloud.client.core.Clock
+import java.util.Date
+import java.util.concurrent.BlockingQueue
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
+
+@Suppress("TooManyFunctions")
+internal class LoggerImpl(
+    private val clock: Clock,
+    private val handler: FileLogHandler,
+    private val mainThreadHandler: Handler,
+    queueCapacity: Int
+) : Logger, LogsRepository {
+
+    data class Load(val listener: LogsRepository.Listener)
+    class Delete
+
+    private val looper = ThreadLoop()
+    private val eventQueue: BlockingQueue<Any> = LinkedBlockingQueue(queueCapacity)
+
+    private val processedEvents = mutableListOf<Any>()
+    private val otherEvents = mutableListOf<Any>()
+    private val missedLogs = AtomicBoolean()
+    private val missedLogsCount = AtomicLong()
+
+    override val lostEntries: Boolean
+        get() {
+            return missedLogs.get()
+        }
+
+    fun start() {
+        looper.start(this::eventLoop)
+    }
+
+    override fun v(tag: String, message: String) {
+        Log.v(tag, message)
+        enqueue(Level.VERBOSE, tag, message)
+    }
+
+    override fun d(tag: String, message: String) {
+        Log.d(tag, message)
+        enqueue(Level.DEBUG, tag, message)
+    }
+
+    override fun d(tag: String, message: String, t: Throwable) {
+        Log.d(tag, message)
+        enqueue(Level.DEBUG, tag, message)
+    }
+
+    override fun i(tag: String, message: String) {
+        Log.i(tag, message)
+        enqueue(Level.INFO, tag, message)
+    }
+
+    override fun w(tag: String, message: String) {
+        Log.w(tag, message)
+        enqueue(Level.WARNING, tag, message)
+    }
+
+    override fun e(tag: String, message: String) {
+        Log.e(tag, message)
+        enqueue(Level.ERROR, tag, message)
+    }
+
+    override fun e(tag: String, message: String, t: Throwable) {
+        Log.e(tag, message)
+        enqueue(Level.ERROR, tag, message)
+    }
+
+    override fun load(listener: LogsRepository.Listener) {
+        eventQueue.put(Load(listener = listener))
+    }
+
+    override fun deleteAll() {
+        eventQueue.put(Delete())
+    }
+
+    private fun enqueue(level: Level, tag: String, message: String) {
+        val entry = LogEntry(timestamp = clock.currentDate, level = level, tag = tag, message = message)
+        val enqueued = eventQueue.offer(entry, 1, TimeUnit.SECONDS)
+        if (!enqueued) {
+            missedLogs.set(true)
+            missedLogsCount.incrementAndGet()
+        }
+    }
+
+    private fun eventLoop() {
+        try {
+            processedEvents.clear()
+            otherEvents.clear()
+
+            processedEvents.add(eventQueue.take())
+            eventQueue.drainTo(processedEvents)
+
+            // process all writes in bulk - this is most frequest use case and we can
+            // assume handler must be opened 99.999% of time; anything that is not a log
+            // write should be deferred
+            handler.open()
+            for (event in processedEvents) {
+                if (event is LogEntry) {
+                    handler.write(event.toString() + "\n")
+                } else {
+                    otherEvents.add(event)
+                }
+            }
+            handler.close()
+
+            // Those events are very sporadic and we don't have to be clever here
+            for (event in otherEvents) {
+                when (event) {
+                    is Load -> {
+                        val entries = handler.loadLogFiles().mapNotNull { LogEntry.parse(it) }
+                        mainThreadHandler.post { event.listener.onLoaded(entries) }
+                    }
+                    is Delete -> handler.deleteAll()
+                }
+            }
+
+            checkAndLogLostMessages()
+        } catch (ex: InterruptedException) {
+            handler.close()
+            throw ex
+        }
+    }
+
+    private fun checkAndLogLostMessages() {
+        val lastMissedLogsCount = missedLogsCount.getAndSet(0)
+        if (lastMissedLogsCount > 0) {
+            handler.open()
+            val warning = LogEntry(
+                timestamp = Date(),
+                level = Level.WARNING,
+                tag = "Logger",
+                message = "Logger queue overflow. Approx $lastMissedLogsCount entries lost. You write too much."
+            ).toString()
+            handler.write(warning)
+            handler.close()
+        }
+    }
+}

+ 51 - 0
src/main/java/com/nextcloud/client/logger/LogsRepository.kt

@@ -0,0 +1,51 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger
+
+/**
+ * This interface provides safe, read only access to application
+ * logs stored on a device.
+ */
+interface LogsRepository {
+
+    @FunctionalInterface
+    interface Listener {
+        fun onLoaded(entries: List<LogEntry>)
+    }
+
+    /**
+     * If true, logger was unable to handle some messages, which means
+     * it cannot cope with amount of logged data.
+     *
+     * This property is thread-safe.
+     */
+    val lostEntries: Boolean
+
+    /**
+     * Asynchronously load available logs. Load can be scheduled on any thread,
+     * but the listener will be called on main thread.
+     */
+    fun load(listener: Listener)
+
+    /**
+     * Asynchronously delete logs.
+     */
+    fun deleteAll()
+}

+ 76 - 0
src/main/java/com/nextcloud/client/logger/ThreadLoop.kt

@@ -0,0 +1,76 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger
+
+/**
+ * This utility runs provided loop body continuously in a loop on a background thread
+ * and allows start and stop the loop thread in a safe way.
+ */
+internal class ThreadLoop {
+
+    private val lock = Object()
+    private var thread: Thread? = null
+    private var loopBody: (() -> Unit)? = null
+
+    /**
+     * Start running [loopBody] in a loop on a background [Thread].
+     * If loop is already started, it no-ops.
+     *
+     * This method is thread safe.
+     *
+     * @throws IllegalStateException if loop is already running
+     */
+    fun start(loopBody: () -> Unit) {
+        synchronized(lock) {
+            if (thread == null) {
+                this.loopBody = loopBody
+                this.thread = Thread(this::loop)
+                this.thread?.start()
+            }
+        }
+    }
+
+    /**
+     * Stops the background [Thread] by interrupting it and waits for [Thread.join].
+     * If loop is not started, it no-ops.
+     *
+     * This method is thread safe.
+     *
+     * @throws IllegalStateException if thread is not running
+     */
+    fun stop() {
+        synchronized(lock) {
+            if (thread != null) {
+                thread?.interrupt()
+                thread?.join()
+            }
+        }
+    }
+
+    private fun loop() {
+        try {
+            while (true) {
+                loopBody?.invoke()
+            }
+        } catch (ex: InterruptedException) {
+            return
+        }
+    }
+}

+ 60 - 0
src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt

@@ -0,0 +1,60 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger.ui
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.nextcloud.client.logger.LogEntry
+import com.owncloud.android.R
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class LogsAdapter(context: Context) : RecyclerView.Adapter<LogsAdapter.ViewHolder>() {
+
+    class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+        val header = view.findViewById<TextView>(R.id.log_entry_list_item_header)
+        val message = view.findViewById<TextView>(R.id.log_entry_list_item_message)
+    }
+
+    private val timestampFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
+    private val inflater = LayoutInflater.from(context)
+
+    var entries: List<LogEntry> = listOf()
+        set(value) {
+            field = value
+            notifyDataSetChanged()
+        }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
+        ViewHolder(inflater.inflate(R.layout.log_entry_list_item, parent, false))
+
+    override fun getItemCount() = entries.size
+
+    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+        val entry = entries[position]
+        val header = "${timestampFormat.format(entry.timestamp)} ${entry.level.tag} ${entry.tag}"
+        holder.header.text = header
+        holder.message.text = entry.message
+    }
+}

+ 93 - 0
src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt

@@ -0,0 +1,93 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger.ui
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.widget.Toast
+import androidx.core.content.FileProvider
+import com.nextcloud.client.core.AsyncRunner
+import com.nextcloud.client.core.Cancellable
+import com.nextcloud.client.core.Clock
+import com.nextcloud.client.logger.LogEntry
+import com.owncloud.android.R
+import java.io.File
+import java.io.FileWriter
+import java.util.TimeZone
+
+class LogsEmailSender(private val context: Context, private val clock: Clock, private val runner: AsyncRunner) {
+
+    private companion object {
+        const val LOGS_MIME_TYPE = "text/plain"
+    }
+
+    private class Task(
+        private val context: Context,
+        private val logs: List<LogEntry>,
+        private val file: File,
+        private val tz: TimeZone
+    ) : Function0<Uri?> {
+
+        override fun invoke(): Uri? {
+            file.parentFile.mkdirs()
+            val fo = FileWriter(file, false)
+            logs.forEach {
+                fo.write(it.toString(tz))
+                fo.write("\n")
+            }
+            fo.close()
+            return FileProvider.getUriForFile(context, context.getString(R.string.file_provider_authority), file)
+        }
+    }
+
+    private var task: Cancellable? = null
+
+    fun send(logs: List<LogEntry>) {
+        if (task == null) {
+            val outFile = File(context.cacheDir, "attachments/logs.txt")
+            task = runner.post(Task(context, logs, outFile, clock.tz), onResult = { task = null; send(it) })
+        }
+    }
+
+    fun stop() {
+        if (task != null) {
+            task?.cancel()
+            task = null
+        }
+    }
+
+    private fun send(uri: Uri?) {
+        task = null
+        val intent = Intent(Intent.ACTION_SEND_MULTIPLE)
+        intent.putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.mail_logger))
+        val subject = context.getString(R.string.log_send_mail_subject).format(context.getString(R.string.app_name))
+        intent.putExtra(Intent.EXTRA_SUBJECT, subject)
+        intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+        intent.type = LOGS_MIME_TYPE
+        intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(uri))
+        try {
+            context.startActivity(intent)
+        } catch (ex: ActivityNotFoundException) {
+            Toast.makeText(context, R.string.log_send_no_mail_app, Toast.LENGTH_SHORT).show()
+        }
+    }
+}

+ 67 - 0
src/main/java/com/nextcloud/client/logger/ui/LogsViewModel.kt

@@ -0,0 +1,67 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger.ui
+
+import android.content.Context
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import com.nextcloud.client.core.AsyncRunner
+import com.nextcloud.client.core.Clock
+import com.nextcloud.client.logger.LogEntry
+import com.nextcloud.client.logger.LogsRepository
+import javax.inject.Inject
+
+class LogsViewModel @Inject constructor(
+    context: Context,
+    clock: Clock,
+    asyncRunner: AsyncRunner,
+    private val logsRepository: LogsRepository
+) : ViewModel() {
+
+    private val sender = LogsEmailSender(context, clock, asyncRunner)
+    val entries: LiveData<List<LogEntry>> = MutableLiveData()
+    private val listener = object : LogsRepository.Listener {
+        override fun onLoaded(entries: List<LogEntry>) {
+            this@LogsViewModel.entries as MutableLiveData
+            this@LogsViewModel.entries.value = entries
+        }
+    }
+
+    fun send() {
+        entries.value?.let {
+            sender.send(it)
+        }
+    }
+
+    fun load() {
+        logsRepository.load(listener)
+    }
+
+    fun deleteAll() {
+        logsRepository.deleteAll()
+        (entries as MutableLiveData).value = emptyList()
+    }
+
+    override fun onCleared() {
+        super.onCleared()
+        sender.stop()
+    }
+}

+ 6 - 0
src/main/java/com/owncloud/android/MainApp.java

@@ -48,6 +48,8 @@ import com.nextcloud.client.device.PowerManagementService;
 import com.nextcloud.client.di.ActivityInjector;
 import com.nextcloud.client.di.DaggerAppComponent;
 import com.nextcloud.client.errorhandling.ExceptionHandler;
+import com.nextcloud.client.logger.LegacyLoggerAdapter;
+import com.nextcloud.client.logger.Logger;
 import com.nextcloud.client.network.ConnectivityService;
 import com.nextcloud.client.onboarding.OnboardingService;
 import com.nextcloud.client.preferences.AppPreferences;
@@ -137,6 +139,9 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
 
     @Inject PowerManagementService powerManagementService;
 
+    @Inject
+    Logger logger;
+
     private PassCodeManager passCodeManager;
 
     @SuppressWarnings("unused")
@@ -248,6 +253,7 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
 
         if (BuildConfig.DEBUG || getApplicationContext().getResources().getBoolean(R.bool.logger_enabled)) {
             // use app writable dir, no permissions needed
+            Log_OC.setLoggerImplementation(new LegacyLoggerAdapter(logger));
             Log_OC.startLogging(getAppContext());
             Log_OC.d("Debug", "start logging");
         }

+ 0 - 289
src/main/java/com/owncloud/android/ui/activity/LogHistoryActivity.java

@@ -1,289 +0,0 @@
-/*
- *   ownCloud Android client application
- *
- *   Copyright (C) 2015 ownCloud Inc.
- *
- *   This program is free software: you can redistribute it and/or modify
- *   it under the terms of the GNU General Public License version 2,
- *   as published by the Free Software Foundation.
- *
- *   This program is distributed in the hope that it will be useful,
- *   but WITHOUT ANY WARRANTY; without even the implied warranty of
- *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *   GNU General Public License for more details.
- *
- *   You should have received a copy of the GNU General Public License
- *   along with this program.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-package com.owncloud.android.ui.activity;
-
-import android.content.ActivityNotFoundException;
-import android.content.Intent;
-import android.graphics.PorterDuff;
-import android.net.Uri;
-import android.os.AsyncTask;
-import android.os.Build;
-import android.os.Bundle;
-import android.view.MenuItem;
-import android.widget.Button;
-import android.widget.TextView;
-
-import com.google.android.material.snackbar.Snackbar;
-import com.owncloud.android.R;
-import com.owncloud.android.lib.common.utils.Log_OC;
-import com.owncloud.android.ui.dialog.LoadingDialog;
-import com.owncloud.android.utils.ThemeUtils;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.lang.ref.WeakReference;
-import java.nio.charset.Charset;
-import java.util.ArrayList;
-
-import androidx.core.content.FileProvider;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentManager;
-import androidx.fragment.app.FragmentTransaction;
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import butterknife.OnClick;
-import butterknife.Unbinder;
-
-
-public class LogHistoryActivity extends ToolbarActivity {
-
-    private static final String MAIL_ATTACHMENT_TYPE = "text/plain";
-
-    private static final String KEY_LOG_TEXT = "LOG_TEXT";
-
-    private static final String TAG = LogHistoryActivity.class.getSimpleName();
-
-    private static final String DIALOG_WAIT_TAG = "DIALOG_WAIT";
-
-    private Unbinder unbinder;
-
-    private String logPath = Log_OC.getLogPath();
-    private File logDir;
-    private String logText;
-
-    @BindView(R.id.deleteLogHistoryButton)
-    Button deleteHistoryButton;
-
-    @BindView(R.id.sendLogHistoryButton)
-    Button sendHistoryButton;
-
-    @BindView(R.id.logTV)
-    TextView logTV;
-
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-
-        setContentView(R.layout.log_send_file);
-        unbinder = ButterKnife.bind(this);
-
-        setupToolbar();
-
-        setTitle(getText(R.string.actionbar_logger));
-        if (getSupportActionBar() != null) {
-            getSupportActionBar().setDisplayHomeAsUpEnabled(true);
-        }
-
-        sendHistoryButton.getBackground().setColorFilter(ThemeUtils.primaryColor(this), PorterDuff.Mode.SRC_ATOP);
-        deleteHistoryButton.setTextColor(ThemeUtils.primaryColor(this, true));
-
-        if (savedInstanceState == null) {
-            if (logPath != null) {
-                logDir = new File(logPath);
-            }
-
-            if (logDir != null && logDir.isDirectory()) {
-                // Show a dialog while log data is being loaded
-                showLoadingDialog();
-
-                // Start a new thread that will load all the log data
-                LoadingLogTask task = new LoadingLogTask(logTV);
-                task.execute();
-            }
-        } else {
-            logText = savedInstanceState.getString(KEY_LOG_TEXT);
-            logTV.setText(logText);
-        }
-    }
-
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        boolean retval = true;
-        switch (item.getItemId()) {
-            case android.R.id.home:
-                finish();
-                break;
-            default:
-                retval = super.onOptionsItemSelected(item);
-                break;
-        }
-        return retval;
-    }
-
-    @OnClick(R.id.deleteLogHistoryButton)
-    void deleteHistoryLogging() {
-        Log_OC.deleteHistoryLogging();
-        finish();
-    }
-
-    /**
-     * Start activity for sending email with logs attached
-     */
-    @OnClick(R.id.sendLogHistoryButton)
-    void sendMail() {
-        String emailAddress = getString(R.string.mail_logger);
-
-        ArrayList<Uri> uris = new ArrayList<>();
-
-        // Convert from paths to Android friendly Parcelable Uri's
-        for (String file : Log_OC.getLogFileNames()) {
-            File logFile = new File(logPath, file);
-            if (logFile.exists()) {
-                if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
-                    uris.add(Uri.fromFile(logFile));
-                } else {
-                    uris.add(FileProvider.getUriForFile(this, getString(R.string.file_provider_authority), logFile));
-                }
-            }
-        }
-
-        Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
-
-        intent.putExtra(Intent.EXTRA_EMAIL, emailAddress);
-        String subject = String.format(getString(R.string.log_send_mail_subject), getString(R.string.app_name));
-        intent.putExtra(Intent.EXTRA_SUBJECT, subject);
-        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-        intent.setType(MAIL_ATTACHMENT_TYPE);
-        intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
-        try {
-            startActivity(intent);
-        } catch (ActivityNotFoundException e) {
-            Snackbar.make(findViewById(android.R.id.content), R.string.log_send_no_mail_app, Snackbar.LENGTH_LONG).show();
-            Log_OC.i(TAG, "Could not find app for sending log history.");
-        }
-
-    }
-
-    /**
-     * Class for loading the log data async
-     */
-    private class LoadingLogTask extends AsyncTask<String, Void, String> {
-        private final WeakReference<TextView> textViewReference;
-
-        LoadingLogTask(TextView logTV) {
-            // Use of a WeakReference to ensure the TextView can be garbage collected
-            textViewReference = new WeakReference<>(logTV);
-        }
-
-        protected String doInBackground(String... args) {
-            return readLogFile();
-        }
-
-        protected void onPostExecute(String result) {
-            if (result != null) {
-                final TextView logTV = textViewReference.get();
-                if (logTV != null) {
-                    logText = result;
-                    logTV.setText(logText);
-                    dismissLoadingDialog();
-                }
-            }
-        }
-
-        /**
-         * Read and show log file info
-         */
-        private String readLogFile() {
-
-            String[] logFileName = Log_OC.getLogFileNames();
-
-            //Read text from files
-            StringBuilder text = new StringBuilder();
-
-            BufferedReader br = null;
-            try {
-                String line;
-
-                for (int i = logFileName.length - 1; i >= 0; i--) {
-                    File file = new File(logPath, logFileName[i]);
-                    if (file.exists()) {
-                        // Check if FileReader is ready
-                        try (InputStreamReader inputStreamReader = new InputStreamReader(new FileInputStream(file),
-                                                                                         Charset.forName("UTF-8"))) {
-                            if (inputStreamReader.ready()) {
-                                br = new BufferedReader(inputStreamReader);
-                                while ((line = br.readLine()) != null) {
-                                    // Append the log info
-                                    text.append(line);
-                                    text.append('\n');
-                                }
-                            }
-                        }
-                    }
-                }
-            } catch (IOException e) {
-                Log_OC.d(TAG, e.getMessage());
-
-            } finally {
-                if (br != null) {
-                    try {
-                        br.close();
-                    } catch (IOException e) {
-                        // ignore
-                        Log_OC.d(TAG, "Error closing log reader", e);
-                    }
-                }
-            }
-
-            return text.toString();
-        }
-    }
-
-    /**
-     * Show loading dialog
-     */
-    public void showLoadingDialog() {
-        // Construct dialog
-        LoadingDialog loading = LoadingDialog.newInstance(getResources().getString(R.string.log_progress_dialog_text));
-        FragmentManager fm = getSupportFragmentManager();
-        FragmentTransaction ft = fm.beginTransaction();
-        loading.show(ft, DIALOG_WAIT_TAG);
-    }
-
-    /**
-     * Dismiss loading dialog
-     */
-    public void dismissLoadingDialog() {
-        Fragment frag = getSupportFragmentManager().findFragmentByTag(DIALOG_WAIT_TAG);
-        if (frag != null) {
-            LoadingDialog loading = (LoadingDialog) frag;
-            loading.dismissAllowingStateLoss();
-        }
-    }
-
-    @Override
-    protected void onSaveInstanceState(Bundle outState) {
-        super.onSaveInstanceState(outState);
-
-        if (isChangingConfigurations()) {
-            // global state
-            outState.putString(KEY_LOG_TEXT, logText);
-        }
-    }
-
-    @Override
-    protected void onDestroy() {
-        super.onDestroy();
-        unbinder.unbind();
-    }
-}

+ 120 - 0
src/main/java/com/owncloud/android/ui/activity/LogsActivity.java

@@ -0,0 +1,120 @@
+/*
+ *   ownCloud Android client application
+ *
+ *   Copyright (C) 2015 ownCloud Inc.
+ *   Copyright (C) Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ *   This program is free software: you can redistribute it and/or modify
+ *   it under the terms of the GNU General Public License version 2,
+ *   as published by the Free Software Foundation.
+ *
+ *   This program is distributed in the hope that it will be useful,
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *   GNU General Public License for more details.
+ *
+ *   You should have received a copy of the GNU General Public License
+ *   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package com.owncloud.android.ui.activity;
+
+import android.graphics.PorterDuff;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.Button;
+
+import com.nextcloud.client.di.ViewModelFactory;
+import com.nextcloud.client.logger.ui.LogsAdapter;
+import com.nextcloud.client.logger.ui.LogsViewModel;
+import com.owncloud.android.R;
+import com.owncloud.android.utils.ThemeUtils;
+
+import javax.inject.Inject;
+
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import butterknife.BindView;
+import butterknife.ButterKnife;
+import butterknife.OnClick;
+import butterknife.Unbinder;
+
+
+public class LogsActivity extends ToolbarActivity {
+
+    private Unbinder unbinder;
+
+    @BindView(R.id.deleteLogHistoryButton)
+    Button deleteHistoryButton;
+
+    @BindView(R.id.sendLogHistoryButton)
+    Button sendHistoryButton;
+
+    @BindView(R.id.logsList)
+    RecyclerView logListView;
+
+    @Inject ViewModelFactory viewModelFactory;
+    private LogsViewModel vm;
+
+    @Override
+    public boolean onPrepareOptionsMenu(Menu menu) {
+        return super.onPrepareOptionsMenu(menu);
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.logs_activity);
+        unbinder = ButterKnife.bind(this);
+        final LogsAdapter logsAdapter = new LogsAdapter(this);
+        logListView.setLayoutManager(new LinearLayoutManager(this));
+        logListView.setAdapter(logsAdapter);
+
+        vm = new ViewModelProvider(this, viewModelFactory).get(LogsViewModel.class);
+        vm.getEntries().observe(this, logsAdapter::setEntries);
+        vm.load();
+
+        setupToolbar();
+
+        setTitle(getText(R.string.actionbar_logger));
+        if (getSupportActionBar() != null) {
+            getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        }
+
+        sendHistoryButton.getBackground().setColorFilter(ThemeUtils.primaryColor(this), PorterDuff.Mode.SRC_ATOP);
+        deleteHistoryButton.setTextColor(ThemeUtils.primaryColor(this, true));
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        boolean retval = true;
+        switch (item.getItemId()) {
+            case android.R.id.home:
+                finish();
+                break;
+            default:
+                retval = super.onOptionsItemSelected(item);
+                break;
+        }
+        return retval;
+    }
+
+    @OnClick(R.id.deleteLogHistoryButton)
+    void deleteLogs() {
+        vm.deleteAll();
+        finish();
+    }
+
+    @OnClick(R.id.sendLogHistoryButton)
+    void sendLogs() {
+        vm.send();
+    }
+
+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        unbinder.unbind();
+    }
+}

+ 1 - 1
src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java

@@ -360,7 +360,7 @@ public class SettingsActivity extends PreferenceActivity
         if (pLogger != null) {
             if (loggerEnabled) {
                 pLogger.setOnPreferenceClickListener(preference -> {
-                    Intent loggerIntent = new Intent(getApplicationContext(), LogHistoryActivity.class);
+                    Intent loggerIntent = new Intent(getApplicationContext(), LogsActivity.class);
                     startActivity(loggerIntent);
 
                     return true;

+ 22 - 0
src/main/res/layout/log_entry_list_item.xml

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:padding="@dimen/standard_quarter_margin">
+
+    <TextView
+        android:id="@+id/log_entry_list_item_header"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textStyle="bold"
+        android:ellipsize="end"
+        android:lines="1"/>
+
+    <TextView
+        android:id="@+id/log_entry_list_item_message"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"/>
+
+</LinearLayout>

+ 3 - 18
src/main/res/layout/log_send_file.xml → src/main/res/layout/logs_activity.xml

@@ -26,28 +26,13 @@
     <include
         layout="@layout/toolbar_standard" />
 
-    <ScrollView
-        android:id="@+id/scrollView1"
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/logsList"
         android:layout_width="match_parent"
         android:layout_height="0dp"
         android:layout_marginBottom="@dimen/standard_margin"
         android:layout_weight="1">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="vertical"
-            android:paddingLeft="@dimen/standard_padding"
-            android:paddingRight="@dimen/standard_padding">
-
-            <TextView
-                android:id="@+id/logTV"
-                android:layout_width="match_parent"
-                android:layout_height="match_parent"
-                android:text="@string/empty"
-                android:typeface="monospace"/>
-        </LinearLayout>
-    </ScrollView>
+    </androidx.recyclerview.widget.RecyclerView>
 
     <LinearLayout
         android:id="@+id/historyButtonBar"

+ 3 - 0
src/main/res/xml/exposed_filepaths.xml

@@ -4,6 +4,9 @@
     <files-path
         path="log/"
         name="log"/>
+    <cache-path
+        name="attachments"
+        path="attachments"/>
     <external-path name="external_files" path="."/>
     <root-path name="external_files" path="/storage/" />
     <!-- yes, valid for ALL external storage and not only our app folder, since we can't use @string/data_folder

+ 114 - 0
src/test/java/com/nextcloud/client/core/AsyncRunnerTest.kt

@@ -0,0 +1,114 @@
+package com.nextcloud.client.core
+
+import android.os.Handler
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.argThat
+import com.nhaarman.mockitokotlin2.doAnswer
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.never
+import com.nhaarman.mockitokotlin2.spy
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class AsyncRunnerTest {
+
+    private lateinit var handler: Handler
+    private lateinit var r: AsyncRunnerImpl
+
+    @Before
+    fun setUp() {
+        handler = spy(Handler())
+        r = AsyncRunnerImpl(handler, 1)
+    }
+
+    fun assertAwait(latch: CountDownLatch, seconds: Long = 3) {
+        val called = latch.await(seconds, TimeUnit.SECONDS)
+        assertTrue(called)
+    }
+
+    @Test
+    fun `posted task is run on background thread`() {
+        val latch = CountDownLatch(1)
+        val callerThread = Thread.currentThread()
+        var taskThread: Thread? = null
+        r.post({
+            taskThread = Thread.currentThread()
+            latch.countDown()
+        })
+        assertAwait(latch)
+        assertNotEquals(callerThread.id, taskThread?.id)
+    }
+
+    @Test
+    fun `returns result via handler`() {
+        val afterPostLatch = CountDownLatch(1)
+        doAnswer {
+            (it.arguments[0] as Runnable).run()
+            afterPostLatch.countDown()
+        }.whenever(handler).post(any())
+
+        val onResult: OnResultCallback<String> = mock()
+        r.post({
+            "result"
+        }, onResult = onResult)
+        assertAwait(afterPostLatch)
+        verify(onResult).invoke(eq("result"))
+    }
+
+    @Test
+    fun `returns error via handler`() {
+        val afterPostLatch = CountDownLatch(1)
+        doAnswer {
+            (it.arguments[0] as Runnable).run()
+            afterPostLatch.countDown()
+        }.whenever(handler).post(any())
+
+        val onResult: OnResultCallback<String> = mock()
+        val onError: OnErrorCallback = mock()
+        r.post({
+            throw IllegalArgumentException("whatever")
+        }, onResult = onResult, onError = onError)
+        assertAwait(afterPostLatch)
+        verify(onResult, never()).invoke(any())
+        verify(onError).invoke(argThat { this is java.lang.IllegalArgumentException })
+    }
+
+    @Test
+    fun `cancelled task does not return result`() {
+        val taskIsCancelled = CountDownLatch(1)
+        val taskIsRunning = CountDownLatch(1)
+        val t = r.post({
+            taskIsRunning.countDown()
+            taskIsCancelled.await()
+            "result"
+        }, onResult = {}, onError = {})
+        assertAwait(taskIsRunning)
+        t.cancel()
+        taskIsCancelled.countDown()
+        Thread.sleep(500) // yuck!
+        verify(handler, never()).post(any())
+    }
+
+    @Test
+    fun `cancelled task does not return error`() {
+        val taskIsCancelled = CountDownLatch(1)
+        val taskIsRunning = CountDownLatch(1)
+        val t = r.post({
+            taskIsRunning.countDown()
+            taskIsCancelled.await()
+            throw RuntimeException("whatever")
+        }, onResult = {}, onError = {})
+        assertAwait(taskIsRunning)
+        t.cancel()
+        taskIsCancelled.countDown()
+        Thread.sleep(500) // yuck!
+        verify(handler, never()).post(any())
+    }
+}

+ 142 - 0
src/test/java/com/nextcloud/client/logger/LogEntryTest.kt

@@ -0,0 +1,142 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger
+
+import java.util.Date
+import java.util.SimpleTimeZone
+import java.util.concurrent.TimeUnit
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Suite
+
+@RunWith(Suite::class)
+@Suite.SuiteClasses(
+    LogEntryTest.ToString::class,
+    LogEntryTest.Parse::class
+)
+class LogEntryTest {
+
+    class ToString {
+        @Test
+        fun `to string`() {
+            val entry = LogEntry(
+                timestamp = Date(0),
+                level = Level.DEBUG,
+                tag = "tag",
+                message = "some message"
+            )
+            assertEquals("1970-01-01T00:00:00.000Z;D;tag;some message", entry.toString())
+        }
+
+        @Test
+        fun `to string with custom time zone`() {
+            val entry = LogEntry(
+                timestamp = Date(0),
+                level = Level.DEBUG,
+                tag = "tag",
+                message = "some message"
+            )
+            val sevenHours = TimeUnit.HOURS.toMillis(7).toInt()
+            val tz = SimpleTimeZone(sevenHours, "+0700")
+            assertEquals("1970-01-01T07:00:00.000+0700;D;tag;some message", entry.toString(tz))
+        }
+
+        @Test
+        fun `semicolons are removed from entry tags`() {
+            val entry = LogEntry(
+                timestamp = Date(0),
+                level = Level.DEBUG,
+                tag = "t;a;g",
+                message = "some message"
+            )
+            assertEquals("1970-01-01T00:00:00.000Z;D;t a g;some message", entry.toString())
+        }
+
+        @Test
+        fun `message newline is converted`() {
+            val entry = LogEntry(
+                timestamp = Date(0),
+                level = Level.DEBUG,
+                tag = "tag",
+                message = "multine\nmessage\n"
+            )
+            assertTrue(entry.toString().endsWith(";multine\\nmessage\\n"))
+        }
+
+        @Test
+        fun `tag can contain unicode characters`() {
+            val entry = LogEntry(
+                timestamp = Date(0),
+                level = Level.DEBUG,
+                tag = """靖康緗素雜記""",
+                message = "夏炉冬扇"
+            )
+            assertEquals("1970-01-01T00:00:00.000Z;D;靖康緗素雜記;夏炉冬扇", entry.toString())
+        }
+    }
+
+    class Parse {
+        @Test
+        fun `regexp parser`() {
+            val entry = "1970-01-01T00:00:00.000Z;D;tag;some message"
+            val parsed = LogEntry.parse(entry)
+            assertNotNull(parsed)
+            parsed as LogEntry
+            assertEquals(Date(0), parsed.timestamp)
+            assertEquals(Level.DEBUG, parsed.level)
+            assertEquals("tag", parsed.tag)
+            assertEquals("some message", parsed.message)
+        }
+
+        @Test
+        fun `malformed log entries are rejected`() {
+            assertNull("no miliseconds", LogEntry.parse("1970-01-01T00:00:00Z;D;tag;a message"))
+            assertNull("not zulu", LogEntry.parse("1970-01-01T00:00:00.000+00:00;D;tag;a message"))
+            assertNull("not utc", LogEntry.parse("1970-01-01T01:00:00.000+01:00;D;tag;a message"))
+            assertNull("bad month", LogEntry.parse("1970-13-01T00:00:00.000Z;D;tag;a message"))
+            assertNull("bad year", LogEntry.parse("0000-01-01T00:00:00.000Z;D;tag;a message"))
+            assertNull("bad day", LogEntry.parse("1970-01-32T00:00:00.000Z;D;tag;a message"))
+            assertNull("bad hour", LogEntry.parse("1970-01-01T25:00:00.000Z;D;tag;a message"))
+            assertNull("bad minute", LogEntry.parse("1970-01-01T00:61:00.000Z;D;tag;a message"))
+            assertNull("bad second", LogEntry.parse("1970-01-01T00:00:61.000Z;D;tag;a message"))
+            assertNull("bad level", LogEntry.parse("1970-01-01T00:00:00.000Z;?;tag;a message"))
+            assertNull("empty tag", LogEntry.parse("1970-01-01T00:00:00.000Z;D;;a message"))
+            assertNull("empty string", LogEntry.parse(""))
+        }
+
+        @Test
+        fun `semicolon in tag tears the tag`() {
+            val parsed = LogEntry.parse("1970-01-01T00:00:00.000Z;D;t;ag;a message")
+            assertNotNull(parsed)
+            assertEquals("Tag is cut; no parse error expected", "t", parsed?.tag)
+            assertEquals("Tag is cut; no parse error expected", "ag;a message", parsed?.message)
+        }
+
+        @Test
+        fun `message can have semicolons`() {
+            val parsed = LogEntry.parse("1970-01-01T00:00:00.000Z;D;tag;a;message;with;semi;colons")
+            assertEquals("a;message;with;semi;colons", parsed?.message)
+        }
+    }
+}

+ 226 - 0
src/test/java/com/nextcloud/client/logger/TestFileLogHandler.kt

@@ -0,0 +1,226 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger
+
+import java.io.File
+import java.nio.charset.Charset
+import java.nio.file.Files
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class TestFileLogHandler {
+
+    private lateinit var logDir: File
+
+    private fun readLogFile(name: String): String {
+        val logFile = File(logDir, name)
+        val raw = Files.readAllBytes(logFile.toPath())
+        return String(raw, Charset.forName("UTF-8"))
+    }
+
+    private fun writeLogFile(name: String, content: String) {
+        val logFile = File(logDir, name)
+        Files.write(logFile.toPath(), content.toByteArray(Charsets.UTF_8))
+    }
+
+    @Before
+    fun setUp() {
+        logDir = Files.createTempDirectory("logger-test-").toFile()
+    }
+
+    @Test
+    fun `logs dir is created on open`() {
+        // GIVEN
+        //      logs directory does not exist
+        val nonexistingLogsDir = File(logDir, "subdir")
+        assertFalse(nonexistingLogsDir.exists())
+
+        // WHEN
+        //      file is opened
+        val handler = FileLogHandler(nonexistingLogsDir, "log.txt", 1000)
+        handler.open()
+
+        // THEN
+        //      directory is created
+        assertTrue(nonexistingLogsDir.exists())
+    }
+
+    @Test
+    fun `log test helpers`() {
+        val filename = "test.txt"
+        val expected = "Hello, world!"
+        writeLogFile(filename, expected)
+        val readBack = readLogFile(filename)
+        assertEquals(expected, readBack)
+    }
+
+    @Test
+    fun `rotate files`() {
+        // GIVEN
+        //      log contains files
+        writeLogFile("log.txt", "0")
+        writeLogFile("log.txt.0", "1")
+        writeLogFile("log.txt.1", "2")
+        writeLogFile("log.txt.2", "3")
+
+        val writer = FileLogHandler(logDir, "log.txt", 1024)
+
+        // WHEN
+        //      files are rotated
+        writer.rotateLogs()
+
+        // THEN
+        //      last file is removed
+        //      all remaining files are advanced by 1 step
+        assertFalse(File(logDir, "log.txt").exists())
+        assertEquals("0", readLogFile("log.txt.0"))
+        assertEquals("1", readLogFile("log.txt.1"))
+        assertEquals("2", readLogFile("log.txt.2"))
+    }
+
+    @Test
+    fun `log file is rotated when crossed max size`() {
+        // GIVEN
+        //      log file contains 10 bytes
+        //      log file limit is 20 bytes
+        //      log writer is opened
+        writeLogFile("log.txt", "0123456789")
+        val writer = FileLogHandler(logDir, "log.txt", 20)
+        writer.open()
+
+        // WHEN
+        //      writing 2nd log entry of 11 bytes
+        writer.write("0123456789!") // 11 bytes
+
+        // THEN
+        //      log file is closed and rotated
+        val rotatedContent = readLogFile("log.txt.0")
+        assertEquals("01234567890123456789!", rotatedContent)
+    }
+
+    @Test
+    fun `log file is reopened after rotation`() {
+        // GIVEN
+        //      log file contains 10 bytes
+        //      log file limit is 20 bytes
+        //      log writer is opened
+        writeLogFile("log.txt", "0123456789")
+        val writer = FileLogHandler(logDir, "log.txt", 20)
+        writer.open()
+
+        // WHEN
+        //      writing 2nd log entry of 11 bytes
+        //      writing another log entry
+        //      closing log
+        writer.write("0123456789!") // 11 bytes
+        writer.write("Hello!")
+        writer.close()
+
+        // THEN
+        //      current log contains last entry
+        val lastEntry = readLogFile("log.txt")
+        assertEquals("Hello!", lastEntry)
+    }
+
+    @Test
+    fun `load log lines from files`() {
+        // GIVEN
+        //      multiple log files exist
+        //      log files have lines
+        writeLogFile("log.txt.2", "line1\nline2\nline3")
+        writeLogFile("log.txt.1", "line4\nline5\nline6")
+        writeLogFile("log.txt.0", "line7\nline8\nline9")
+        writeLogFile("log.txt", "line10\nline11\nline12")
+
+        // WHEN
+        //      log file is read including rotated content
+        val writer = FileLogHandler(logDir, "log.txt", 1000)
+        val lines = writer.loadLogFiles(3)
+
+        // THEN
+        //      all files are loaded
+        //      lines are loaded in correct order
+        assertEquals(12, lines.size)
+        assertEquals(
+            listOf(
+                "line1", "line2", "line3",
+                "line4", "line5", "line6",
+                "line7", "line8", "line9",
+                "line10", "line11", "line12"
+            ),
+            lines
+        )
+    }
+
+    @Test
+    fun `load log lines from files with gaps between rotated files`() {
+        // GIVEN
+        //      multiple log files exist
+        //      log files have lines
+        //      some rotated files are deleted
+        writeLogFile("log.txt", "line1\nline2\nline3")
+        writeLogFile("log.txt.2", "line4\nline5\nline6")
+
+        // WHEN
+        //      log file is read including rotated content
+        val writer = FileLogHandler(logDir, "log.txt", 1000)
+        val lines = writer.loadLogFiles(3)
+
+        // THEN
+        //      all files are loaded
+        assertEquals(6, lines.size)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun `load log lines - negative count is illegal`() {
+        // WHEN
+        //      requesting negative number of rotated files
+        val writer = FileLogHandler(logDir, "log.txt", 1000)
+        val lines = writer.loadLogFiles(-1)
+
+        // THEN
+        //      illegal argument exception
+    }
+
+    @Test
+    fun `all log files are deleted`() {
+        // GIVEN
+        //      log files exist
+        val handler = FileLogHandler(logDir, "log.txt", 100)
+        for (i in 0 until handler.maxLogFilesCount) {
+            handler.rotateLogs()
+            handler.open()
+            handler.write("new log entry")
+            handler.close()
+        }
+        assertEquals(handler.maxLogFilesCount, logDir.listFiles().size)
+
+        // WHEN
+        //      files are deleted
+        handler.deleteAll()
+
+        // THEN
+        //      all files are deleted
+        assertEquals(0, logDir.listFiles().size)
+    }
+}

+ 285 - 0
src/test/java/com/nextcloud/client/logger/TestLogger.kt

@@ -0,0 +1,285 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.logger
+
+import android.os.Handler
+import com.nextcloud.client.core.Clock
+import com.nextcloud.client.core.ClockImpl
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.argThat
+import com.nhaarman.mockitokotlin2.capture
+import com.nhaarman.mockitokotlin2.doAnswer
+import com.nhaarman.mockitokotlin2.inOrder
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.spy
+import com.nhaarman.mockitokotlin2.times
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import java.nio.file.Files
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mockito.ArgumentCaptor
+import org.mockito.MockitoAnnotations
+
+class TestLogger {
+
+    private companion object {
+        const val QUEUE_CAPACITY = 100
+    }
+
+    private lateinit var clock: Clock
+    private lateinit var logHandler: FileLogHandler
+    private lateinit var osHandler: Handler
+    private lateinit var logger: LoggerImpl
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+        val tempDir = Files.createTempDirectory("log-test").toFile()
+        clock = ClockImpl()
+        logHandler = spy(FileLogHandler(tempDir, "log.txt", 1024))
+        osHandler = mock()
+        logger = LoggerImpl(clock, logHandler, osHandler, QUEUE_CAPACITY)
+    }
+
+    @Test
+    fun `write is done on background thread`() {
+        val callerThreadId = Thread.currentThread().id
+        val writerThreadIds = mutableListOf<Long>()
+        val latch = CountDownLatch(3)
+
+        doAnswer {
+            writerThreadIds.add(Thread.currentThread().id)
+            it.callRealMethod()
+            latch.countDown()
+        }.whenever(logHandler).open()
+
+        doAnswer {
+            writerThreadIds.add(Thread.currentThread().id)
+            it.callRealMethod()
+            latch.countDown()
+        }.whenever(logHandler).write(any())
+
+        doAnswer {
+            writerThreadIds.add(Thread.currentThread().id)
+            it.callRealMethod()
+            latch.countDown()
+        }.whenever(logHandler).close()
+
+        // GIVEN
+        //      logger event loop is running
+        logger.start()
+
+        // WHEN
+        //      message is logged
+        logger.d("tag", "message")
+
+        // THEN
+        //      message is processed on bg thread
+        //      all handler invocations happen on bg thread
+        //      all handler invocations happen on single thread
+        assertTrue(latch.await(3, TimeUnit.SECONDS))
+
+        writerThreadIds.forEach { writerThreadId ->
+            assertNotEquals("All requests must be made on bg thread", callerThreadId, writerThreadId)
+        }
+
+        writerThreadIds.forEach {
+            assertEquals("All requests must be made on single thread", writerThreadIds[0], it)
+        }
+    }
+
+    @Test
+    fun `message is written via log handler`() {
+        val tag = "test tag"
+        val message = "test log message"
+        val latch = CountDownLatch(3)
+        doAnswer { it.callRealMethod(); latch.countDown() }.whenever(logHandler).open()
+        doAnswer { it.callRealMethod(); latch.countDown() }.whenever(logHandler).write(any())
+        doAnswer { it.callRealMethod(); latch.countDown() }.whenever(logHandler).close()
+
+        // GIVEN
+        //      logger event loop is running
+        logger.start()
+
+        // WHEN
+        //      log message is written
+        logger.d(tag, message)
+
+        // THEN
+        //      log handler opens log file
+        //      log handler writes entry
+        //      log handler closes log file
+        //      no lost messages
+        val called = latch.await(3, TimeUnit.SECONDS)
+        assertTrue("Expected open(), write() and close() calls on bg thread", called)
+        val inOrder = inOrder(logHandler)
+        inOrder.verify(logHandler).open()
+        inOrder.verify(logHandler).write(argThat {
+            tag in this && message in this
+        })
+        inOrder.verify(logHandler).close()
+        assertFalse(logger.lostEntries)
+    }
+
+    @Test
+    fun `logs are loaded in background thread and posted to main thread`() {
+        val currentThreadId = Thread.currentThread().id
+        var loggerThreadId: Long = -1
+        val listener: LogsRepository.Listener = mock()
+        val latch = CountDownLatch(2)
+
+        // log handler will be called on bg thread
+        doAnswer {
+            loggerThreadId = Thread.currentThread().id
+            latch.countDown()
+            it.callRealMethod()
+        }.whenever(logHandler).loadLogFiles(any())
+
+        // os handler will be called on bg thread
+        whenever(osHandler.post(any())).thenAnswer {
+            latch.countDown()
+            true
+        }
+
+        // GIVEN
+        //      logger event loop is running
+        logger.start()
+
+        // WHEN
+        //      messages are logged
+        //      log contents are requested
+        logger.d("tag", "message 1")
+        logger.d("tag", "message 2")
+        logger.d("tag", "message 3")
+        logger.load(listener)
+        val called = latch.await(3, TimeUnit.SECONDS)
+        assertTrue("Response not posted", called)
+
+        // THEN
+        //      log contents are loaded on background thread
+        //      logs are posted to main thread handler
+        //      contents contain logged messages
+        //      messages are in order of writes
+        assertNotEquals(currentThreadId, loggerThreadId)
+
+        val postedCaptor = ArgumentCaptor.forClass(Runnable::class.java)
+        verify(osHandler).post(capture(postedCaptor))
+        postedCaptor.value.run()
+
+        val logsCaptor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor<List<LogEntry>>
+        verify(listener).onLoaded(capture(logsCaptor))
+        assertEquals(3, logsCaptor.value.size)
+        assertTrue("message 1" in logsCaptor.value[0].message)
+        assertTrue("message 2" in logsCaptor.value[1].message)
+        assertTrue("message 3" in logsCaptor.value[2].message)
+    }
+
+    @Test
+    fun `log level can be decoded from tags`() {
+        Level.values().forEach {
+            val decodedLevel = Level.fromTag(it.tag)
+            assertEquals(it, decodedLevel)
+        }
+    }
+
+    @Test
+    fun `queue limit is enforced`() {
+        // GIVEN
+        //      logger event loop is no running
+
+        // WHEN
+        //      queue is filled up to it's capacity
+        for (i in 0 until QUEUE_CAPACITY + 1) {
+            logger.d("tag", "Message $i")
+        }
+
+        // THEN
+        //      overflow flag is raised
+        assertTrue(logger.lostEntries)
+    }
+
+    @Test
+    fun `queue overflow warning is logged`() {
+
+        // GIVEN
+        //      logger loop is overflown
+        for (i in 0..QUEUE_CAPACITY + 1) {
+            logger.d("tag", "Message $i")
+        }
+
+        // WHEN
+        //      logger event loop processes events
+        //
+        logger.start()
+
+        // THEN
+        //      overflow occurence is logged
+        val posted = CountDownLatch(1)
+        whenever(osHandler.post(any())).thenAnswer {
+            (it.arguments[0] as Runnable).run()
+            posted.countDown()
+            true
+        }
+
+        val listener: LogsRepository.Listener = mock()
+        logger.load(listener)
+        assertTrue("Logs not loaded", posted.await(1, TimeUnit.SECONDS))
+
+        verify(listener).onLoaded(argThat {
+            "Logger queue overflow" in last().message
+        })
+    }
+
+    @Test
+    fun `all log files are deleted`() {
+        val latch = CountDownLatch(1)
+        doAnswer {
+            it.callRealMethod()
+            latch.countDown()
+        }.whenever(logHandler).deleteAll()
+
+        // GIVEN
+        //      logger is started
+        logger.start()
+
+        // WHEN
+        //      logger has some writes
+        //      logs are deleted
+        logger.d("tag", "message")
+        logger.d("tag", "message")
+        logger.d("tag", "message")
+        logger.deleteAll()
+
+        // THEN
+        //      handler writes files
+        //      handler deletes all files
+        assertTrue(latch.await(3, TimeUnit.SECONDS))
+        verify(logHandler, times(3)).write(any())
+        verify(logHandler).deleteAll()
+        assertEquals(0, logHandler.loadLogFiles(logHandler.maxLogFilesCount).size)
+    }
+}