Browse Source

Log search functionality and log browser refactoring

* added log search in logs browser
* added logs browser view model test
* added universal async filtering utility
* refactored async runner and added manual runner
* migrated logs browser to Android DataBinding and Kotlin
* disabled imports ordering Ktlint rule as IDE does not
  support ktlint ordering
* added some missing tests around logger

Closes #4311

Signed-off-by: Chris Narkiewicz <hello@ezaquarii.com>
Chris Narkiewicz 5 years ago
parent
commit
c35873f5dc
33 changed files with 1333 additions and 307 deletions
  1. 4 0
      .editorconfig
  2. 4 0
      build.gradle
  3. 8 0
      spotbugs-filter.xml
  4. 1 1
      src/main/AndroidManifest.xml
  5. 1 2
      src/main/java/com/nextcloud/client/core/AsyncRunner.kt
  6. 0 65
      src/main/java/com/nextcloud/client/core/AsyncRunnerImpl.kt
  7. 76 0
      src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt
  8. 57 0
      src/main/java/com/nextcloud/client/core/Task.kt
  9. 44 0
      src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt
  10. 2 2
      src/main/java/com/nextcloud/client/di/AppModule.java
  11. 1 1
      src/main/java/com/nextcloud/client/di/ComponentsModule.java
  12. 8 4
      src/main/java/com/nextcloud/client/logger/FileLogHandler.kt
  13. 8 5
      src/main/java/com/nextcloud/client/logger/LoggerImpl.kt
  14. 5 6
      src/main/java/com/nextcloud/client/logger/LogsRepository.kt
  15. 83 0
      src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt
  16. 104 0
      src/main/java/com/nextcloud/client/logger/ui/LogsActivity.kt
  17. 2 1
      src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt
  18. 68 9
      src/main/java/com/nextcloud/client/logger/ui/LogsViewModel.kt
  19. 0 120
      src/main/java/com/owncloud/android/ui/activity/LogsActivity.java
  20. 1 0
      src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
  21. 5 0
      src/main/java/com/owncloud/android/utils/ThemeUtils.java
  22. 49 57
      src/main/res/layout/logs_activity.xml
  23. 49 0
      src/main/res/menu/logs_menu.xml
  24. 9 4
      src/main/res/values/strings.xml
  25. 1 1
      src/main/res/xml/preferences.xml
  26. 148 0
      src/test/java/com/nextcloud/client/core/ManualAsyncRunnerTest.kt
  27. 86 0
      src/test/java/com/nextcloud/client/core/TaskTest.kt
  28. 24 5
      src/test/java/com/nextcloud/client/core/ThreadPoolAsyncRunnerTest.kt
  29. 26 14
      src/test/java/com/nextcloud/client/logger/FileLogHandlerTest.kt
  30. 39 0
      src/test/java/com/nextcloud/client/logger/LevelTest.kt
  31. 13 10
      src/test/java/com/nextcloud/client/logger/LoggerTest.kt
  32. 167 0
      src/test/java/com/nextcloud/client/logger/ui/AsyncFilterTest.kt
  33. 240 0
      src/test/java/com/nextcloud/client/logger/ui/LogsViewModelTest.kt

+ 4 - 0
.editorconfig

@@ -34,3 +34,7 @@ trim_trailing_whitespace=false
 
 [.drone.yml]
 indent_size=2
+
+[*.{kt,kts}]
+# IDE does not follow this Ktlint rule strictly, but the default ordering is pretty good anyway, so let's ditch it
+disabled_rules=import-ordering

+ 4 - 0
build.gradle

@@ -244,6 +244,10 @@ android {
         sourceCompatibility JavaVersion.VERSION_1_8
         targetCompatibility JavaVersion.VERSION_1_8
     }
+
+    dataBinding {
+        enabled true
+    }
 }
 
 dependencies {

+ 8 - 0
spotbugs-filter.xml

@@ -26,6 +26,14 @@
         <Bug pattern="IICU_INCORRECT_INTERNAL_CLASS_USE" />
     </Match>
 
+    <!-- Data bindings autogenerated classes -->
+    <Match>
+        <Or>
+            <Class name="~.*BindingImpl"/>
+            <Class name="~.*\.DataBinderMapperImpl"/>
+        </Or>
+    </Match>
+
     <Bug pattern="PATH_TRAVERSAL_IN" />
     <Bug pattern="ANDROID_EXTERNAL_FILE_ACCESS" />
     <Bug pattern="BAS_BLOATED_ASSIGNMENT_SCOPE" />

+ 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.LogsActivity"/>
+        <activity android:name="com.nextcloud.client.logger.ui.LogsActivity"/>
 
         <activity android:name="com.nextcloud.client.errorhandling.ShowErrorActivity"
             android:theme="@style/Theme.ownCloud.Toolbar"

+ 1 - 2
src/main/java/com/nextcloud/client/core/AsyncRunner.kt

@@ -19,7 +19,6 @@
  */
 package com.nextcloud.client.core
 
-typealias TaskBody<T> = () -> T
 typealias OnResultCallback<T> = (T) -> Unit
 typealias OnErrorCallback = (Throwable) -> Unit
 
@@ -29,5 +28,5 @@ typealias OnErrorCallback = (Throwable) -> Unit
  * 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
+    fun <T> post(task: () -> T, onResult: OnResultCallback<T>? = null, onError: OnErrorCallback? = null): Cancellable
 }

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

@@ -1,65 +0,0 @@
-/*
- * 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
-    }
-}

+ 76 - 0
src/main/java/com/nextcloud/client/core/ManualAsyncRunner.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.core
+
+import java.util.ArrayDeque
+
+/**
+ * This async runner is suitable for tests, where manual simulation of
+ * asynchronous operations is desirable.
+ */
+class ManualAsyncRunner : AsyncRunner {
+
+    private val queue: ArrayDeque<Task<*>> = ArrayDeque()
+
+    override fun <T> post(task: () -> T, onResult: OnResultCallback<T>?, onError: OnErrorCallback?): Cancellable {
+        val taskWrapper = Task(
+            postResult = { it.run() },
+            taskBody = task,
+            onSuccess = onResult,
+            onError = onError
+        )
+        queue.push(taskWrapper)
+        return taskWrapper
+    }
+
+    val size: Int get() = queue.size
+    val isEmpty: Boolean get() = queue.size == 0
+
+    /**
+     * Run all enqueued tasks until queue is empty. This will run also tasks
+     * enqueued by task callbacks.
+     *
+     * @param maximum max number of tasks to run to avoid infinite loopss
+     * @return number of executed tasks
+     */
+    fun runAll(maximum: Int = 100): Int {
+        var c = 0
+        while (queue.size > 0) {
+            val t = queue.remove()
+            t.run()
+            c++
+            if (c > maximum) {
+                throw IllegalStateException("Maximum number of tasks run. Are you in infinite loop?")
+            }
+        }
+        return c
+    }
+
+    /**
+     * Run one pending task
+     *
+     * @return true if task has been run
+     */
+    fun runOne(): Boolean {
+        val t = queue.pollFirst()
+        t?.run()
+        return t != null
+    }
+}

+ 57 - 0
src/main/java/com/nextcloud/client/core/Task.kt

@@ -0,0 +1,57 @@
+/*
+ * 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.concurrent.atomic.AtomicBoolean
+
+/**
+ * This is a wrapper for a function run in background.
+ *
+ * Runs task function and posts result if task is not cancelled.
+ */
+internal class Task<T>(
+    private val postResult: (Runnable) -> Unit,
+    private val taskBody: () -> 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 = taskBody.invoke()
+            if (!cancelled.get()) {
+                postResult.invoke(Runnable {
+                    onSuccess?.invoke(result)
+                })
+            }
+        } catch (t: Throwable) {
+            if (!cancelled.get()) {
+                postResult(Runnable { onError?.invoke(t) })
+            }
+        }
+    }
+
+    override fun cancel() {
+        cancelled.set(true)
+    }
+}

+ 44 - 0
src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt

@@ -0,0 +1,44 @@
+/*
+ * 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
+
+/**
+ * This async runner uses [java.util.concurrent.ScheduledThreadPoolExecutor] to run tasks
+ * asynchronously.
+ *
+ * Tasks are run on multi-threaded pool. If serialized execution is desired, set [corePoolSize] to 1.
+ */
+internal class ThreadPoolAsyncRunner(private val uiThreadHandler: Handler, corePoolSize: Int) : AsyncRunner {
+
+    private val executor = ScheduledThreadPoolExecutor(corePoolSize)
+
+    override fun <T> post(task: () -> T, onResult: OnResultCallback<T>?, onError: OnErrorCallback?): Cancellable {
+        val taskWrapper = Task(this::postResult, task, onResult, onError)
+        executor.execute(taskWrapper)
+        return taskWrapper
+    }
+
+    private fun postResult(r: Runnable) {
+        uiThreadHandler.post(r)
+    }
+}

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

@@ -31,7 +31,7 @@ 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.ThreadPoolAsyncRunner;
 import com.nextcloud.client.core.Clock;
 import com.nextcloud.client.core.ClockImpl;
 import com.nextcloud.client.device.DeviceInfo;
@@ -144,6 +144,6 @@ class AppModule {
     @Singleton
     AsyncRunner asyncRunner() {
         Handler uiHandler = new Handler();
-        return new AsyncRunnerImpl(uiHandler, 4);
+        return new ThreadPoolAsyncRunner(uiHandler, 4);
     }
 }

+ 1 - 1
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.LogsActivity;
+import com.nextcloud.client.logger.ui.LogsActivity;
 import com.owncloud.android.ui.activity.ManageAccountsActivity;
 import com.owncloud.android.ui.activity.ManageSpaceActivity;
 import com.owncloud.android.ui.activity.NotificationsActivity;

+ 8 - 4
src/main/java/com/nextcloud/client/logger/FileLogHandler.kt

@@ -34,6 +34,8 @@ import java.nio.charset.Charset
  */
 internal class FileLogHandler(private val logDir: File, private val logFilename: String, private val maxSize: Long) {
 
+    data class RawLogs(val lines: List<String>, val logSize: Long)
+
     companion object {
         const val ROTATED_LOGS_COUNT = 3
     }
@@ -111,21 +113,23 @@ internal class FileLogHandler(private val logDir: File, private val logFilename:
         }
     }
 
-    fun loadLogFiles(rotated: Int = ROTATED_LOGS_COUNT): List<String> {
+    fun loadLogFiles(rotated: Int = ROTATED_LOGS_COUNT): RawLogs {
         if (rotated < 0) {
             throw IllegalArgumentException("Negative index")
         }
         val allLines = mutableListOf<String>()
+        var size = 0L
         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)
+                val lines = file.readLines(Charsets.UTF_8)
+                allLines.addAll(lines)
+                size += file.length()
             } catch (ex: IOException) {
                 // ignore failing file
             }
         }
-        return allLines
+        return RawLogs(lines = allLines, logSize = size)
     }
 }

+ 8 - 5
src/main/java/com/nextcloud/client/logger/LoggerImpl.kt

@@ -37,7 +37,7 @@ internal class LoggerImpl(
     queueCapacity: Int
 ) : Logger, LogsRepository {
 
-    data class Load(val listener: LogsRepository.Listener)
+    data class Load(val onResult: (List<LogEntry>, Long) -> Unit)
     class Delete
 
     private val looper = ThreadLoop()
@@ -92,8 +92,8 @@ internal class LoggerImpl(
         enqueue(Level.ERROR, tag, message)
     }
 
-    override fun load(listener: LogsRepository.Listener) {
-        eventQueue.put(Load(listener = listener))
+    override fun load(onLoaded: (entries: List<LogEntry>, totalLogSize: Long) -> Unit) {
+        eventQueue.put(Load(onLoaded))
     }
 
     override fun deleteAll() {
@@ -134,8 +134,11 @@ internal class LoggerImpl(
             for (event in otherEvents) {
                 when (event) {
                     is Load -> {
-                        val entries = handler.loadLogFiles().mapNotNull { LogEntry.parse(it) }
-                        mainThreadHandler.post { event.listener.onLoaded(entries) }
+                        val loaded = handler.loadLogFiles()
+                        val entries = loaded.lines.mapNotNull { LogEntry.parse(it) }
+                        mainThreadHandler.post {
+                            event.onResult(entries, loaded.logSize)
+                        }
                     }
                     is Delete -> handler.deleteAll()
                 }

+ 5 - 6
src/main/java/com/nextcloud/client/logger/LogsRepository.kt

@@ -19,17 +19,14 @@
  */
 package com.nextcloud.client.logger
 
+typealias OnLogsLoaded = (entries: List<LogEntry>, totalLogSize: Long) -> Unit
+
 /**
  * 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.
@@ -41,8 +38,10 @@ interface LogsRepository {
     /**
      * Asynchronously load available logs. Load can be scheduled on any thread,
      * but the listener will be called on main thread.
+     *
+     * @param onLoaded: Callback with loaded logs; called on main thread
      */
-    fun load(listener: Listener)
+    fun load(onLoaded: OnLogsLoaded)
 
     /**
      * Asynchronously delete logs.

+ 83 - 0
src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt

@@ -0,0 +1,83 @@
+/*
+ * 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 com.nextcloud.client.core.AsyncRunner
+import com.nextcloud.client.core.Cancellable
+
+/**
+ * This utility class allows implementation of as-you-type filtering of large collections.
+ *
+ * It asynchronously filters collection in background and provide result via callback on the main thread.
+ * If new filter request is posted before current filtering task completes, request
+ * is stored as pending and is handled after currently running task completes.
+ *
+ * If a request is already running, another request is already pending and new request is posted
+ * (ex. if somebody types faster than live search can finish), the pending request is overwritten
+ * by a new one.
+ */
+class AsyncFilter(private val asyncRunner: AsyncRunner, private val time: () -> Long = System::currentTimeMillis) {
+
+    private var filterTask: Cancellable? = null
+    private var pendingRequest: (() -> Unit)? = null
+    private val isRunning get() = filterTask != null
+    private var startTime = 0L
+
+    /**
+     * Schedule filtering request.
+     *
+     * @param collection items to appy fitler to; items should not be modified when request is being processed
+     * @param predicate filter predicate
+     * @param onResult result callback called on the main thread
+     */
+    fun <T> filter(
+        collection: Iterable<T>,
+        predicate: (T) -> Boolean,
+        onResult: (filtered: List<T>, durationMs: Long) -> Unit
+    ) {
+        pendingRequest = {
+            filterAsync(collection, predicate, onResult)
+        }
+        if (!isRunning) {
+            pendingRequest?.invoke()
+        }
+    }
+
+    private fun <T> filterAsync(collection: Iterable<T>, predicate: (T) -> Boolean, onResult: (List<T>, Long) -> Unit) {
+        startTime = time.invoke()
+        filterTask = asyncRunner.post(
+            task = {
+                collection.filter { predicate.invoke(it) }
+            },
+            onResult = { filtered: List<T> ->
+                onFilterCompleted(filtered, onResult)
+            }
+        )
+        pendingRequest = null
+    }
+
+    private fun <T> onFilterCompleted(filtered: List<T>, callback: (List<T>, Long) -> Unit) {
+        val dt = time.invoke() - startTime
+        callback.invoke(filtered, dt)
+        filterTask = null
+        startTime = 0L
+        pendingRequest?.invoke()
+    }
+}

+ 104 - 0
src/main/java/com/nextcloud/client/logger/ui/LogsActivity.kt

@@ -0,0 +1,104 @@
+/*
+ * 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.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.widget.ProgressBar
+import androidx.appcompat.widget.SearchView
+import androidx.databinding.DataBindingUtil
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.nextcloud.client.di.ViewModelFactory
+import com.owncloud.android.R
+import com.owncloud.android.databinding.LogsActivityBinding
+import com.owncloud.android.ui.activity.ToolbarActivity
+import com.owncloud.android.utils.ThemeUtils
+import javax.inject.Inject
+
+class LogsActivity : ToolbarActivity() {
+
+    @Inject
+    protected lateinit var viewModelFactory: ViewModelFactory
+    private lateinit var vm: LogsViewModel
+    private lateinit var binding: LogsActivityBinding
+    private lateinit var logsAdapter: LogsAdapter
+
+    private val searchBoxListener = object : SearchView.OnQueryTextListener {
+        override fun onQueryTextSubmit(query: String): Boolean {
+            return false
+        }
+
+        override fun onQueryTextChange(newText: String): Boolean {
+            vm.filter(newText)
+            return false
+        }
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        vm = ViewModelProvider(this, viewModelFactory).get(LogsViewModel::class.java)
+        binding = DataBindingUtil.setContentView<LogsActivityBinding>(this, R.layout.logs_activity).apply {
+            lifecycleOwner = this@LogsActivity
+            vm = this@LogsActivity.vm
+        }
+
+        findViewById<ProgressBar>(R.id.logs_loading_progress).apply {
+            ThemeUtils.themeProgressBar(context, this)
+        }
+
+        logsAdapter = LogsAdapter(this)
+        findViewById<RecyclerView>(R.id.logsList).apply {
+            layoutManager = LinearLayoutManager(this@LogsActivity)
+            adapter = logsAdapter
+        }
+
+        vm.entries.observe(this, Observer { logsAdapter.entries = it })
+        vm.load()
+
+        setupToolbar()
+        title = getText(R.string.logs_title)
+        supportActionBar?.setDisplayHomeAsUpEnabled(true)
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        menuInflater.inflate(R.menu.logs_menu, menu)
+        (menu.findItem(R.id.action_search).actionView as SearchView).apply {
+            setOnQueryTextListener(searchBoxListener)
+            ThemeUtils.themeSearchView(context, this, true)
+        }
+        return super.onCreateOptionsMenu(menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        var retval = true
+        when (item.itemId) {
+            android.R.id.home -> finish()
+            R.id.action_delete_logs -> vm.deleteAll()
+            R.id.action_send_logs -> vm.send()
+            R.id.action_refresh_logs -> vm.load()
+            else -> retval = super.onOptionsItemSelected(item)
+        }
+        return retval
+    }
+}

+ 2 - 1
src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt

@@ -52,7 +52,8 @@ class LogsAdapter(context: Context) : RecyclerView.Adapter<LogsAdapter.ViewHolde
     override fun getItemCount() = entries.size
 
     override fun onBindViewHolder(holder: ViewHolder, position: Int) {
-        val entry = entries[position]
+        val reversedPosition = entries.size - position - 1
+        val entry = entries[reversedPosition]
         val header = "${timestampFormat.format(entry.timestamp)} ${entry.level.tag} ${entry.tag}"
         holder.header.text = header
         holder.message.text = entry.message

+ 68 - 9
src/main/java/com/nextcloud/client/logger/ui/LogsViewModel.kt

@@ -27,24 +27,32 @@ 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 com.owncloud.android.R
 import javax.inject.Inject
 
 class LogsViewModel @Inject constructor(
-    context: Context,
+    private val 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
-        }
+    private companion object {
+        const val KILOBYTE = 1024L
     }
 
+    private val asyncFilter = AsyncFilter(asyncRunner)
+    private val sender = LogsEmailSender(context, clock, asyncRunner)
+    private var allEntries = emptyList<LogEntry>()
+    private var logsSize = -1L
+    private var filterDurationMs = 0L
+    private var isFiltered = false
+
+    val isLoading: LiveData<Boolean> = MutableLiveData<Boolean>().apply { value = false }
+    val size: LiveData<Long> = MutableLiveData<Long>().apply { value = 0 }
+    val entries: LiveData<List<LogEntry>> = MutableLiveData<List<LogEntry>>().apply { value = emptyList() }
+    val status: LiveData<String> = MutableLiveData<String>().apply { value = "" }
+
     fun send() {
         entries.value?.let {
             sender.send(it)
@@ -52,7 +60,22 @@ class LogsViewModel @Inject constructor(
     }
 
     fun load() {
-        logsRepository.load(listener)
+        if (isLoading.value != true) {
+            logsRepository.load(this::onLoaded)
+            (isLoading as MutableLiveData).value = true
+        }
+    }
+
+    private fun onLoaded(entries: List<LogEntry>, logsSize: Long) {
+        this.entries as MutableLiveData
+        this.isLoading as MutableLiveData
+        this.status as MutableLiveData
+
+        this.entries.value = entries
+        this.allEntries = entries
+        this.logsSize = logsSize
+        isLoading.value = false
+        this.status.value = formatStatus()
     }
 
     fun deleteAll() {
@@ -60,8 +83,44 @@ class LogsViewModel @Inject constructor(
         (entries as MutableLiveData).value = emptyList()
     }
 
+    fun filter(pattern: String) {
+        if (isLoading.value == false) {
+            isFiltered = pattern.isNotEmpty()
+            val predicate = when (isFiltered) {
+                true -> { it: LogEntry -> it.tag.contains(pattern, true) || it.message.contains(pattern, true) }
+                false -> { _ -> true }
+            }
+            asyncFilter.filter(
+                collection = allEntries,
+                predicate = predicate,
+                onResult = this::onFiltered
+            )
+        }
+    }
+
     override fun onCleared() {
         super.onCleared()
         sender.stop()
     }
+
+    private fun onFiltered(filtered: List<LogEntry>, filterDurationMs: Long) {
+        (entries as MutableLiveData).value = filtered
+        this.filterDurationMs = filterDurationMs
+        (status as MutableLiveData).value = formatStatus()
+    }
+
+    private fun formatStatus(): String {
+        val displayedEntries = entries.value?.size ?: allEntries.size
+        val sizeKb = logsSize / KILOBYTE
+        return when {
+            isLoading.value == true -> context.getString(R.string.logs_status_loading)
+            isFiltered -> context.getString(R.string.logs_status_filtered,
+                sizeKb,
+                displayedEntries,
+                allEntries.size,
+                filterDurationMs)
+            !isFiltered -> context.getString(R.string.logs_status_not_filtered, sizeKb)
+            else -> ""
+        }
+    }
 }

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

@@ -1,120 +0,0 @@
-/*
- *   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 - 0
src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java

@@ -56,6 +56,7 @@ import android.webkit.URLUtil;
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.di.Injectable;
 import com.nextcloud.client.etm.EtmActivity;
+import com.nextcloud.client.logger.ui.LogsActivity;
 import com.nextcloud.client.preferences.AppPreferences;
 import com.nextcloud.client.preferences.AppPreferencesImpl;
 import com.owncloud.android.BuildConfig;

+ 5 - 0
src/main/java/com/owncloud/android/utils/ThemeUtils.java

@@ -485,6 +485,11 @@ public final class ThemeUtils {
         themeEditText(context, editText, themedBackground);
     }
 
+    public static void themeProgressBar(Context context, ProgressBar progressBar) {
+        int color = ThemeUtils.primaryAccentColor(context);
+        progressBar.getIndeterminateDrawable().setColorFilter(color, PorterDuff.Mode.SRC_IN);
+    }
+
     public static void tintCheckbox(AppCompatCheckBox checkBox, int color) {
         CompoundButtonCompat.setButtonTintList(checkBox, new ColorStateList(
                 new int[][]{

+ 49 - 57
src/main/res/layout/logs_activity.xml

@@ -1,72 +1,64 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ownCloud Android client application
+    Nextcloud Android client application
 
-  Copyright (C) 2015 ownCloud Inc.
+    @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 General Public License version 2,
-  as published by the Free Software Foundation.
+    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 General Public License for more details.
+    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 General Public License
-  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+    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/>.
 -->
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical"
-    android:weightSum="1">
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
 
-    <include
-        layout="@layout/toolbar_standard" />
+    <data>
+        <import type="android.view.View" />
+        <variable name="vm" type="com.nextcloud.client.logger.ui.LogsViewModel" />
+    </data>
 
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/logsList"
+    <androidx.coordinatorlayout.widget.CoordinatorLayout
         android:layout_width="match_parent"
-        android:layout_height="0dp"
-        android:layout_marginBottom="@dimen/standard_margin"
-        android:layout_weight="1">
-    </androidx.recyclerview.widget.RecyclerView>
+        android:layout_height="match_parent">
 
-    <LinearLayout
-        android:id="@+id/historyButtonBar"
-        android:layout_width="fill_parent"
-        android:layout_height="wrap_content"
-        android:orientation="horizontal"
-        android:layout_marginBottom="@dimen/standard_margin"
-        android:layout_marginLeft="@dimen/standard_margin"
-        android:layout_marginRight="@dimen/standard_margin">
+        <include layout="@layout/toolbar_standard" />
 
-        <com.google.android.material.button.MaterialButton
-        android:id="@+id/deleteLogHistoryButton"
-        android:theme="@style/OutlinedButton"
-        android:layout_width="fill_parent"
-        android:layout_height="wrap_content"
-        android:layout_gravity="bottom"
-        android:layout_weight="1"
-        android:text="@string/prefs_log_delete_history_button"
-        android:layout_marginEnd="@dimen/standard_quarter_margin"
-        android:layout_marginRight="@dimen/standard_quarter_margin"
-        app:cornerRadius="@dimen/button_corner_radius" />
+        <ProgressBar
+            android:id="@+id/logs_loading_progress"
+            android:visibility="@{safeUnbox(vm.isLoading) ? View.VISIBLE : View.GONE}"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            app:layout_behavior="@string/appbar_scrolling_view_behavior"
+            android:layout_gravity="center"/>
 
-        <com.google.android.material.button.MaterialButton
-        android:id="@+id/sendLogHistoryButton"
-        android:theme="@style/Button.Primary"
-        android:layout_width="fill_parent"
-        android:layout_height="wrap_content"
-        android:layout_gravity="bottom"
-        android:layout_weight="1"
-        android:text="@string/log_send_history_button"
-        android:layout_marginStart="@dimen/standard_quarter_margin"
-        android:layout_marginLeft="@dimen/standard_quarter_margin"
-        app:cornerRadius="@dimen/button_corner_radius" />
+        <LinearLayout
+            android:visibility="@{safeUnbox(vm.isLoading) ? View.INVISIBLE : View.VISIBLE}"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            app:layout_behavior="@string/appbar_scrolling_view_behavior"
+            android:orientation="vertical">
 
-    </LinearLayout>
+            <androidx.recyclerview.widget.RecyclerView
+                android:id="@+id/logsList"
+                android:layout_width="match_parent"
+                android:layout_height="0dp"
+                android:layout_weight="1"/>
 
-</LinearLayout>
+            <TextView
+                android:id="@+id/logs_status"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:textSize="12sp"
+                android:text="@{vm.status}"/>
+        </LinearLayout>
+    </androidx.coordinatorlayout.widget.CoordinatorLayout>
+</layout>

+ 49 - 0
src/main/res/menu/logs_menu.xml

@@ -0,0 +1,49 @@
+<!--
+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/>.
+-->
+<menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
+    <item
+        android:id="@+id/action_search"
+        android:icon="@android:drawable/ic_menu_search"
+        android:orderInCategory="0"
+        android:title="@string/logs_menu_search"
+        app:showAsAction="ifRoom"
+        app:actionViewClass="androidx.appcompat.widget.SearchView" />
+
+    <item
+        android:id="@+id/action_refresh_logs"
+        android:title="@string/logs_menu_refresh"
+        app:showAsAction="never"
+        android:orderInCategory="100"
+        android:icon="@drawable/ic_action_refresh"/>
+
+    <item
+        android:id="@+id/action_send_logs"
+        android:title="@string/logs_menu_send"
+        app:showAsAction="never"
+        android:orderInCategory="200"
+        android:icon="@drawable/ic_send"/>
+
+    <item
+        android:id="@+id/action_delete_logs"
+        android:title="@string/logs_menu_delete"
+        app:showAsAction="never"
+        android:orderInCategory="300"
+        android:icon="@drawable/ic_delete"/>
+</menu>

+ 9 - 4
src/main/res/values/strings.xml

@@ -47,7 +47,6 @@
     <string name="prefs_show_hidden_files">Show hidden files</string>
     <string name="prefs_enable_media_scan_notifications">Show media scan notifications</string>
     <string name="prefs_enable_media_scan_notifications_summary">Notify about newly found media folders</string>
-    <string name="prefs_log_delete_history_button">Delete history</string>
     <string name="prefs_calendar_contacts">Sync calendar &amp; contacts</string>
     <string name="prefs_calendar_contacts_summary">Set up DAVx5 (formerly known as DAVdroid) (v1.3.0+) for current account</string>
     <string name="prefs_calendar_contacts_address_resolve_error">Server address for the account could not be resolved for DAVx5 (formerly known as DAVdroid)</string>
@@ -407,11 +406,12 @@
     <string name="drawer_manage_accounts">Manage accounts</string>
     <string name="auth_redirect_non_secure_connection_title">Secure connection redirected through an unsecured route.</string>
 
-    <string name="actionbar_logger">Logs</string>
-    <string name="log_send_history_button">Send history</string>
+    <string name="logs_title">Logs</string>
+    <string name="logs_menu_refresh">Refresh</string>
+    <string name="logs_menu_send">Send logs by e-mail</string>
+    <string name="logs_menu_delete">Delete logs</string>
     <string name="log_send_no_mail_app">No app for sending logs found. Please install an e-mail client.</string>
     <string name="log_send_mail_subject">%1$s Android app logs</string>
-    <string name="log_progress_dialog_text">Loading data…</string>
 
     <string name="saml_authentication_required_text">Password required</string>
     <string name="saml_authentication_wrong_pass">Wrong password</string>
@@ -888,6 +888,11 @@
     <string name="etm_title">Engineering Test Mode</string>
     <string name="etm_preferences">Preferences</string>
 
+    <string name="logs_status_loading">Loading…</string>
+    <string name="logs_status_filtered">Logs: %1$d kB, query matched %2$d / %3$d in %4$d ms</string>
+    <string name="logs_status_not_filtered">Logs: %1$d kB, no filter</string>
+    <string name="logs_menu_search">Search logs</string>
+
     <string name="error_report_issue_text">Report issue to tracker? (requires a Github account)</string>
     <string name="error_report_issue_action">Report</string>
     <string name="error_crash_title">%1$s crashed</string>

+ 1 - 1
src/main/res/xml/preferences.xml

@@ -69,7 +69,7 @@
 		<Preference android:title="@string/prefs_help" android:key="help" />
 		<Preference android:title="@string/prefs_recommend" android:key="recommend" />
 		<Preference android:title="@string/prefs_feedback" android:key="feedback" />
-		<Preference android:title="@string/actionbar_logger" android:key="logger" />
+		<Preference android:title="@string/logs_title" android:key="logger" />
 		<Preference android:title="@string/prefs_imprint" android:key="imprint" />
 	</PreferenceCategory>
 	<PreferenceCategory android:title="@string/prefs_category_about" android:key="about">

+ 148 - 0
src/test/java/com/nextcloud/client/core/ManualAsyncRunnerTest.kt

@@ -0,0 +1,148 @@
+/*
+ * 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 com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.times
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+
+class ManualAsyncRunnerTest {
+
+    private lateinit var runner: ManualAsyncRunner
+
+    @Mock
+    private lateinit var task: () -> Int
+
+    @Mock
+    private lateinit var onResult: OnResultCallback<Int>
+
+    @Mock
+    private lateinit var onError: OnErrorCallback
+
+    private var taskCalls: Int = 0
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+        runner = ManualAsyncRunner()
+        taskCalls = 0
+        whenever(task.invoke()).thenAnswer { taskCalls++; taskCalls }
+    }
+
+    @Test
+    fun `tasks are queued`() {
+        assertEquals(0, runner.size)
+        runner.post(task, onResult, onError)
+        runner.post(task, onResult, onError)
+        runner.post(task, onResult, onError)
+        assertEquals("Expected 3 tasks to be enqueued", 3, runner.size)
+    }
+
+    @Test
+    fun `run one enqueued task`() {
+        runner.post(task, onResult, onError)
+        runner.post(task, onResult, onError)
+        runner.post(task, onResult, onError)
+
+        assertEquals("Queue should contain all enqueued tasks", 3, runner.size)
+        val run = runner.runOne()
+        assertTrue("Executed task should be acknowledged", run)
+        assertEquals("One task should be run", 1, taskCalls)
+        verify(onResult).invoke(eq(1))
+        assertEquals("Only 1 task should be consumed", 2, runner.size)
+    }
+
+    @Test
+    fun `run all enqueued tasks`() {
+        runner.post(task, onResult, onError)
+        runner.post(task, onResult, onError)
+
+        assertEquals("Queue should contain all enqueued tasks", 2, runner.size)
+        val count = runner.runAll()
+        assertEquals("Executed tasks should be acknowledged", 2, count)
+        verify(task, times(2)).invoke()
+        verify(onResult, times(2)).invoke(any())
+        assertEquals("Entire queue should be processed", 0, runner.size)
+    }
+
+    @Test
+    fun `run one task when queue is empty`() {
+        assertFalse("No task should be run", runner.runOne())
+    }
+
+    @Test
+    fun `run all tasks when queue is empty`() {
+        assertEquals("No task should be run", 0, runner.runAll())
+    }
+
+    @Test
+    fun `tasks started from callbacks are processed`() {
+        val task = { "result" }
+        // WHEN
+        //      one task is scheduled
+        //      task callback schedules another task
+        runner.post(task, {
+            runner.post(task, {
+                runner.post(task)
+            })
+        })
+        assertEquals(1, runner.size)
+
+        // WHEN
+        //      runs all
+        val count = runner.runAll()
+
+        // THEN
+        //      all subsequently scheduled tasks are run too
+        assertEquals(3, count)
+    }
+
+    @Test(expected = IllegalStateException::class, timeout = 10000)
+    fun `runner detects infinite loops caused by scheduling tasks recusively`() {
+        val recursiveTask: () -> String = object : Function0<String> {
+            override fun invoke(): String {
+                runner.post(this)
+                return "result"
+            }
+        }
+
+        // WHEN
+        //      one task is scheduled
+        //      task will schedule itself again, causing infinite loop
+        runner.post(recursiveTask)
+
+        // WHEN
+        //      runs all
+        runner.runAll()
+
+        // THEN
+        //      maximum number of task runs is reached
+        //      exception is thrown
+    }
+}

+ 86 - 0
src/test/java/com/nextcloud/client/core/TaskTest.kt

@@ -0,0 +1,86 @@
+/*
+ * 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 com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.never
+import com.nhaarman.mockitokotlin2.same
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+
+class TaskTest {
+
+    @Mock
+    private lateinit var taskBody: () -> String
+    @Mock
+    private lateinit var onResult: OnResultCallback<String>
+    @Mock
+    private lateinit var onError: OnErrorCallback
+
+    private lateinit var task: Task<String>
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+        val postResult = { r: Runnable -> r.run() }
+        task = Task(postResult, taskBody, onResult, onError)
+    }
+
+    @Test
+    fun `task result is posted`() {
+        whenever(taskBody.invoke()).thenReturn("result")
+        task.run()
+        verify(onResult).invoke(eq("result"))
+        verify(onError, never()).invoke(any())
+    }
+
+    @Test
+    fun `task result is not posted when cancelled`() {
+        whenever(taskBody.invoke()).thenReturn("result")
+        task.cancel()
+        task.run()
+        verify(onResult, never()).invoke(any())
+        verify(onError, never()).invoke(any())
+    }
+
+    @Test
+    fun `task error is posted`() {
+        val exception = RuntimeException("")
+        whenever(taskBody.invoke()).thenThrow(exception)
+        task.run()
+        verify(onResult, never()).invoke(any())
+        verify(onError).invoke(same(exception))
+    }
+
+    @Test
+    fun `task error is not posted when cancelled`() {
+        val exception = RuntimeException("")
+        whenever(taskBody.invoke()).thenThrow(exception)
+        task.cancel()
+        task.run()
+        verify(onResult, never()).invoke(any())
+        verify(onError, never()).invoke(any())
+    }
+}

+ 24 - 5
src/test/java/com/nextcloud/client/core/AsyncRunnerTest.kt → src/test/java/com/nextcloud/client/core/ThreadPoolAsyncRunnerTest.kt

@@ -1,3 +1,22 @@
+/*
+ * 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
@@ -10,22 +29,22 @@ 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
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
 
-class AsyncRunnerTest {
+class ThreadPoolAsyncRunnerTest {
 
     private lateinit var handler: Handler
-    private lateinit var r: AsyncRunnerImpl
+    private lateinit var r: ThreadPoolAsyncRunner
 
     @Before
     fun setUp() {
         handler = spy(Handler())
-        r = AsyncRunnerImpl(handler, 1)
+        r = ThreadPoolAsyncRunner(handler, 1)
     }
 
     fun assertAwait(latch: CountDownLatch, seconds: Long = 3) {

+ 26 - 14
src/test/java/com/nextcloud/client/logger/TestFileLogHandler.kt → src/test/java/com/nextcloud/client/logger/FileLogHandlerTest.kt

@@ -19,16 +19,16 @@
  */
 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
+import java.io.File
+import java.nio.charset.Charset
+import java.nio.file.Files
 
-class TestFileLogHandler {
+class FileLogHandlerTest {
 
     private lateinit var logDir: File
 
@@ -38,9 +38,16 @@ class TestFileLogHandler {
         return String(raw, Charset.forName("UTF-8"))
     }
 
-    private fun writeLogFile(name: String, content: String) {
+    /**
+     * Write raw content to file in log dir.
+     *
+     * @return size of written data in bytes
+     */
+    private fun writeLogFile(name: String, content: String): Int {
         val logFile = File(logDir, name)
-        Files.write(logFile.toPath(), content.toByteArray(Charsets.UTF_8))
+        val rawContent = content.toByteArray(Charsets.UTF_8)
+        Files.write(logFile.toPath(), rawContent)
+        return rawContent.size
     }
 
     @Before
@@ -147,20 +154,22 @@ class TestFileLogHandler {
         // 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")
+        var totalLogsSize = 0L
+        totalLogsSize += writeLogFile("log.txt.2", "line1\nline2\nline3")
+        totalLogsSize += writeLogFile("log.txt.1", "line4\nline5\nline6")
+        totalLogsSize += writeLogFile("log.txt.0", "line7\nline8\nline9")
+        totalLogsSize += 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)
+        val rawLogs = writer.loadLogFiles(3)
 
         // THEN
         //      all files are loaded
         //      lines are loaded in correct order
-        assertEquals(12, lines.size)
+        //      log files size is correctly reported
+        assertEquals(12, rawLogs.lines.size)
         assertEquals(
             listOf(
                 "line1", "line2", "line3",
@@ -168,8 +177,9 @@ class TestFileLogHandler {
                 "line7", "line8", "line9",
                 "line10", "line11", "line12"
             ),
-            lines
+            rawLogs.lines
         )
+        assertEquals(totalLogsSize, rawLogs.logSize)
     }
 
     @Test
@@ -188,7 +198,9 @@ class TestFileLogHandler {
 
         // THEN
         //      all files are loaded
-        assertEquals(6, lines.size)
+        //      log file size is non-zero
+        assertEquals(6, lines.lines.size)
+        assertTrue(lines.logSize > 0)
     }
 
     @Test(expected = IllegalArgumentException::class)

+ 39 - 0
src/test/java/com/nextcloud/client/logger/LevelTest.kt

@@ -0,0 +1,39 @@
+/*
+ * 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 org.junit.Assert.assertEquals
+import org.junit.Test
+
+class LevelTest {
+
+    @Test
+    fun `parsing level tag`() {
+        Level.values().forEach {
+            val parsed = Level.fromTag(it.tag)
+            assertEquals(parsed, it)
+        }
+    }
+
+    @Test
+    fun `level parser handles unkown values`() {
+        assertEquals(Level.UNKNOWN, Level.fromTag("non-existing-tag"))
+    }
+}

+ 13 - 10
src/test/java/com/nextcloud/client/logger/TestLogger.kt → src/test/java/com/nextcloud/client/logger/LoggerTest.kt

@@ -32,9 +32,6 @@ 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
@@ -43,8 +40,11 @@ import org.junit.Before
 import org.junit.Test
 import org.mockito.ArgumentCaptor
 import org.mockito.MockitoAnnotations
+import java.nio.file.Files
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
 
-class TestLogger {
+class LoggerTest {
 
     private companion object {
         const val QUEUE_CAPACITY = 100
@@ -149,7 +149,7 @@ class TestLogger {
     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 listener: OnLogsLoaded = mock()
         val latch = CountDownLatch(2)
 
         // log handler will be called on bg thread
@@ -191,7 +191,8 @@ class TestLogger {
         postedCaptor.value.run()
 
         val logsCaptor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor<List<LogEntry>>
-        verify(listener).onLoaded(capture(logsCaptor))
+        val sizeCaptor = ArgumentCaptor.forClass(Long::class.java)
+        verify(listener).invoke(capture(logsCaptor), capture(sizeCaptor))
         assertEquals(3, logsCaptor.value.size)
         assertTrue("message 1" in logsCaptor.value[0].message)
         assertTrue("message 2" in logsCaptor.value[1].message)
@@ -245,13 +246,13 @@ class TestLogger {
             true
         }
 
-        val listener: LogsRepository.Listener = mock()
+        val listener: OnLogsLoaded = mock()
         logger.load(listener)
         assertTrue("Logs not loaded", posted.await(1, TimeUnit.SECONDS))
 
-        verify(listener).onLoaded(argThat {
+        verify(listener).invoke(argThat {
             "Logger queue overflow" in last().message
-        })
+        }, any())
     }
 
     @Test
@@ -280,6 +281,8 @@ class TestLogger {
         assertTrue(latch.await(3, TimeUnit.SECONDS))
         verify(logHandler, times(3)).write(any())
         verify(logHandler).deleteAll()
-        assertEquals(0, logHandler.loadLogFiles(logHandler.maxLogFilesCount).size)
+        val loaded = logHandler.loadLogFiles(logHandler.maxLogFilesCount)
+        assertEquals(0, loaded.lines.size)
+        assertEquals(0L, loaded.logSize)
     }
 }

+ 167 - 0
src/test/java/com/nextcloud/client/logger/ui/AsyncFilterTest.kt

@@ -0,0 +1,167 @@
+/*
+ * 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 com.nextcloud.client.core.ManualAsyncRunner
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.never
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+
+class AsyncFilterTest {
+
+    class OnResult<T> : (List<T>, Long) -> Unit {
+        var arg: List<T>? = null
+        var dt: Long? = null
+        override fun invoke(arg: List<T>, dt: Long) {
+            this.arg = arg
+            this.dt = dt
+        }
+    }
+
+    private lateinit var time: () -> Long
+    private lateinit var runner: ManualAsyncRunner
+    private lateinit var filter: AsyncFilter
+
+    @Before
+    fun setUp() {
+        time = mock()
+        whenever(time.invoke()).thenReturn(System.currentTimeMillis())
+        runner = ManualAsyncRunner()
+        filter = AsyncFilter(runner, time)
+    }
+
+    @Test
+    fun `collection is filtered asynchronously`() {
+        val collection = listOf(1, 2, 3, 4, 5, 6)
+        val predicate = { arg: Int -> arg > 3 }
+        val result = OnResult<Int>()
+
+        // GIVEN
+        //      filtering is scheduled
+        filter.filter(collection, predicate, result)
+        assertEquals(1, runner.size)
+        assertNull(result.arg)
+
+        // WHEN
+        //      task completes
+        runner.runOne()
+
+        // THEN
+        //      result is delivered via callback
+        assertEquals(listOf(4, 5, 6), result.arg)
+    }
+
+    @Test
+    fun `filtering request is enqueued if one already running`() {
+        val collection = listOf(1, 2, 3)
+        val firstPredicate = { arg: Int -> arg > 1 }
+        val secondPredicate = { arg: Int -> arg > 2 }
+        val firstResult = OnResult<Int>()
+        val secondResult = OnResult<Int>()
+
+        // GIVEN
+        //      filtering task is already running
+
+        filter.filter(collection, firstPredicate, firstResult)
+        assertEquals(1, runner.size)
+
+        // WHEN
+        //      new filtering is requested
+        //      first filtering task completes
+        filter.filter(collection, secondPredicate, secondResult)
+        runner.runOne()
+
+        // THEN
+        //      first filtering task result is delivered
+        //      second filtering task is scheduled immediately
+        //      second filtering result will be delivered when completes
+        assertEquals(listOf(2, 3), firstResult.arg)
+        assertEquals(1, runner.size)
+
+        runner.runOne()
+        assertEquals(listOf(3), secondResult.arg)
+        assertEquals(0, runner.size)
+    }
+
+    @Test
+    fun `pending requests are overwritten by new requests`() {
+        val collection = listOf(1, 2, 3, 4, 5, 6)
+
+        val firstPredicate = { arg: Int -> arg > 1 }
+        val firstResult = OnResult<Int>()
+
+        val secondPredicate: (Int) -> Boolean = mock()
+        whenever(secondPredicate.invoke(any())).thenReturn(false)
+        val secondResult = OnResult<Int>()
+
+        val thirdPredicate = { arg: Int -> arg > 3 }
+        val thirdResult = OnResult<Int>()
+
+        // GIVEN
+        //      filtering task is already running
+        filter.filter(collection, firstPredicate, firstResult)
+        assertEquals(1, runner.size)
+
+        // WHEN
+        //      few new filtering requests are enqueued
+        //      first filtering task completes
+        filter.filter(collection, secondPredicate, secondResult)
+        filter.filter(collection, thirdPredicate, thirdResult)
+        runner.runOne()
+        assertEquals(1, runner.size)
+        runner.runOne()
+
+        // THEN
+        //      second filtering task is overwritten
+        //      second filtering task never runs
+        //      third filtering task runs and completes
+        //      no new tasks are scheduled
+        verify(secondPredicate, never()).invoke(any())
+        assertNull(secondResult.arg)
+        assertEquals(listOf(4, 5, 6), thirdResult.arg)
+        assertEquals(0, runner.size)
+    }
+
+    @Test
+    fun `filtering is timed`() {
+        // GIVEN
+        //      filtering operation is scheduled
+        val startTime = System.currentTimeMillis()
+        whenever(time.invoke()).thenReturn(startTime)
+        val result = OnResult<Int>()
+        filter.filter(listOf(1, 2, 3), { true }, result)
+
+        // WHEN
+        //      result is delivered with a delay
+        val delayMs = 123L
+        whenever(time.invoke()).thenReturn(startTime + delayMs)
+        runner.runAll()
+
+        // THEN
+        //      delay is calculated from current time
+        assertEquals(result.dt, delayMs)
+    }
+}

+ 240 - 0
src/test/java/com/nextcloud/client/logger/ui/LogsViewModelTest.kt

@@ -0,0 +1,240 @@
+/*
+ * 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.arch.core.executor.testing.InstantTaskExecutorRule
+import com.nextcloud.client.core.Clock
+import com.nextcloud.client.core.ManualAsyncRunner
+import com.nextcloud.client.logger.Level
+import com.nextcloud.client.logger.LogEntry
+import com.nextcloud.client.logger.LogsRepository
+import com.nextcloud.client.logger.OnLogsLoaded
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Suite
+import org.mockito.MockitoAnnotations
+import java.util.Date
+
+@RunWith(Suite::class)
+@Suite.SuiteClasses(
+    LogsViewModelTest.Loading::class,
+    LogsViewModelTest.Filtering::class
+)
+class LogsViewModelTest {
+
+    private companion object {
+        val TEST_LOG_ENTRIES = listOf(
+            LogEntry(Date(), Level.DEBUG, "test", "entry 1"),
+            LogEntry(Date(), Level.DEBUG, "test", "entry 2"),
+            LogEntry(Date(), Level.DEBUG, "test", "entry 3")
+        )
+        val TEST_LOG_SIZE_KILOBYTES = 42L
+        val TEST_LOG_SIZE_BYTES = TEST_LOG_SIZE_KILOBYTES * 1024L
+    }
+
+    class TestLogRepository : LogsRepository {
+        var loadRequestCount = 0
+        var onLoadedCallback: OnLogsLoaded? = null
+
+        override val lostEntries: Boolean = false
+        override fun load(onLoaded: OnLogsLoaded) { this.onLoadedCallback = onLoaded; loadRequestCount++ }
+        override fun deleteAll() {}
+    }
+
+    abstract class Fixture {
+
+        protected lateinit var context: Context
+        protected lateinit var clock: Clock
+        protected lateinit var repository: TestLogRepository
+        protected lateinit var runner: ManualAsyncRunner
+        protected lateinit var vm: LogsViewModel
+
+        @get:Rule
+        val rule = InstantTaskExecutorRule()
+
+        @Before
+        fun setUpFixture() {
+            MockitoAnnotations.initMocks(this)
+            context = mock()
+            clock = mock()
+            repository = TestLogRepository()
+            runner = ManualAsyncRunner()
+            vm = LogsViewModel(context, clock, runner, repository)
+            whenever(context.getString(any(), any())).thenAnswer {
+                "${it.arguments}"
+            }
+        }
+    }
+
+    class Loading : Fixture() {
+
+        @Test
+        fun `all observable properties have initial values`() {
+            assertNotNull(vm.isLoading)
+            assertNotNull(vm.size)
+            assertNotNull(vm.entries)
+            assertNotNull(vm.status)
+        }
+
+        @Test
+        fun `load logs entries from repository`() {
+            // GIVEN
+            //      entries are not loaded
+            assertEquals(0, vm.entries.value!!.size)
+            assertEquals(false, vm.isLoading.value)
+
+            // WHEN
+            //      load is initiated
+            vm.load()
+
+            // THEN
+            //      loading status is true
+            //      repository request is posted
+            assertTrue(vm.isLoading.value!!)
+            assertNotNull(repository.onLoadedCallback)
+        }
+
+        @Test
+        fun `on logs loaded`() {
+            // GIVEN
+            //      logs are being loaded
+            vm.load()
+            assertNotNull(repository.onLoadedCallback)
+            assertTrue(vm.isLoading.value!!)
+
+            // WHEN
+            //      logs loading finishes
+            repository.onLoadedCallback?.invoke(TEST_LOG_ENTRIES, TEST_LOG_SIZE_BYTES)
+
+            // THEN
+            //      logs are displayed
+            //      logs size is displyed
+            //      status is displayed
+            assertFalse(vm.isLoading.value!!)
+            assertSame(vm.entries.value, TEST_LOG_ENTRIES)
+            assertNotNull(vm.status.value)
+        }
+
+        @Test
+        fun `cannot start loading when loading is in progress`() {
+            // GIVEN
+            //      logs loading is started
+            vm.load()
+            assertEquals(1, repository.loadRequestCount)
+            assertTrue(vm.isLoading.value!!)
+
+            // WHEN
+            //      load is requested
+            repository.onLoadedCallback = null
+            vm.load()
+
+            // THEN
+            //      request is ignored
+            assertNull(repository.onLoadedCallback)
+            assertEquals(1, repository.loadRequestCount)
+        }
+    }
+
+    class Filtering : Fixture() {
+
+        @Before
+        fun setUp() {
+            vm.load()
+            repository.onLoadedCallback?.invoke(TEST_LOG_ENTRIES, TEST_LOG_SIZE_BYTES)
+            assertFalse(vm.isLoading.value!!)
+            assertEquals(TEST_LOG_ENTRIES.size, vm.entries.value?.size)
+        }
+
+        @Test
+        fun `filtering  cannot be started when loading`() {
+            // GIVEN
+            //      loading is in progress
+            vm.load()
+            assertTrue(vm.isLoading.value!!)
+
+            // WHEN
+            //      filtering is requested
+            vm.filter("some pattern")
+
+            // THEN
+            //      filtering is not enqueued
+            assertTrue(runner.isEmpty)
+        }
+
+        @Test
+        fun `filtering task is started`() {
+            // GIVEN
+            //      logs are loaded
+            assertEquals(TEST_LOG_ENTRIES.size, vm.entries.value?.size)
+
+            // WHEN
+            //      logs filtering is not running
+            //      logs filtering is requested
+            assertTrue(runner.isEmpty)
+            vm.filter(TEST_LOG_ENTRIES[0].message)
+
+            // THEN
+            //      filter request is enqueued
+            assertEquals(1, runner.size)
+        }
+
+        @Test
+        fun `filtered logs are displayed`() {
+            var statusArgs: Array<Any> = emptyArray()
+            whenever(context.getString(any(), any())).thenAnswer {
+                statusArgs = it.arguments
+                "${it.arguments}"
+            }
+            // GIVEN
+            //      filtering is in progress
+            val pattern = TEST_LOG_ENTRIES[0].message
+            vm.filter(pattern)
+
+            // WHEN
+            //      filtering finishes
+            assertEquals(1, runner.runAll())
+
+            // THEN
+            //      vm displays filtered results
+            //      vm displays status
+            assertNotNull(vm.entries.value)
+            assertEquals(1, vm.entries.value?.size)
+            val filteredEntry = vm.entries.value?.get(0)!!
+            assertTrue(filteredEntry.message.contains(pattern))
+
+            assertEquals("Status should contain size in kB", TEST_LOG_SIZE_KILOBYTES, statusArgs[1])
+            assertEquals("Status should show matched entries count", vm.entries.value?.size, statusArgs[2])
+            assertEquals("Status should contain total entries count", TEST_LOG_ENTRIES.size, statusArgs[3])
+            assertTrue("Status should contain query time in ms", statusArgs[4] is Long)
+        }
+    }
+}