diff --git a/.editorconfig b/.editorconfig
index 8aa4377dd23a..10cf82d2f242 100644
--- a/.editorconfig
+++ b/.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
diff --git a/build.gradle b/build.gradle
index 79f884062c80..d03235c6187b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -244,6 +244,10 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
+
+ dataBinding {
+ enabled true
+ }
}
dependencies {
diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt
index 07b704560add..fd047cb4a658 100644
--- a/scripts/analysis/lint-results.txt
+++ b/scripts/analysis/lint-results.txt
@@ -1,2 +1,2 @@
DO NOT TOUCH; GENERATED BY DRONE
- Lint Report: 59 warnings
+ Lint Report: 58 warnings
diff --git a/spotbugs-filter.xml b/spotbugs-filter.xml
index b37c066a698d..e2fcf8223de4 100644
--- a/spotbugs-filter.xml
+++ b/spotbugs-filter.xml
@@ -26,6 +26,14 @@
+
+
+
+
+
+
+
+
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index 622340b0b64a..c0a6c4a66a99 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -318,7 +318,7 @@
-
+
= () -> T
typealias OnResultCallback = (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 post(block: () -> T, onResult: OnResultCallback? = null, onError: OnErrorCallback? = null): Cancellable
+ fun post(task: () -> T, onResult: OnResultCallback? = null, onError: OnErrorCallback? = null): Cancellable
}
diff --git a/src/main/java/com/nextcloud/client/core/AsyncRunnerImpl.kt b/src/main/java/com/nextcloud/client/core/AsyncRunnerImpl.kt
deleted file mode 100644
index 836eb8880c84..000000000000
--- a/src/main/java/com/nextcloud/client/core/AsyncRunnerImpl.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Nextcloud Android client application
- *
- * @author Chris Narkiewicz
- * Copyright (C) 2019 Chris Narkiewicz
- *
- * 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 .
- */
-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(
- private val handler: Handler,
- private val callable: () -> T,
- private val onSuccess: OnResultCallback?,
- 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 post(block: () -> T, onResult: OnResultCallback?, onError: OnErrorCallback?): Cancellable {
- val task = Task(uiThreadHandler, block, onResult, onError)
- executor.execute(task)
- return task
- }
-}
diff --git a/src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt b/src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt
new file mode 100644
index 000000000000..6130aa57a402
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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> = ArrayDeque()
+
+ override fun post(task: () -> T, onResult: OnResultCallback?, 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
+ }
+}
diff --git a/src/main/java/com/nextcloud/client/core/Task.kt b/src/main/java/com/nextcloud/client/core/Task.kt
new file mode 100644
index 000000000000..6de6dfdfd316
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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(
+ private val postResult: (Runnable) -> Unit,
+ private val taskBody: () -> T,
+ private val onSuccess: OnResultCallback?,
+ 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)
+ }
+}
diff --git a/src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt b/src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt
new file mode 100644
index 000000000000..096e0cafafea
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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 post(task: () -> T, onResult: OnResultCallback?, onError: OnErrorCallback?): Cancellable {
+ val taskWrapper = Task(this::postResult, task, onResult, onError)
+ executor.execute(taskWrapper)
+ return taskWrapper
+ }
+
+ private fun postResult(r: Runnable) {
+ uiThreadHandler.post(r)
+ }
+}
diff --git a/src/main/java/com/nextcloud/client/di/AppModule.java b/src/main/java/com/nextcloud/client/di/AppModule.java
index a04f92188463..d7ca17a02d73 100644
--- a/src/main/java/com/nextcloud/client/di/AppModule.java
+++ b/src/main/java/com/nextcloud/client/di/AppModule.java
@@ -31,7 +31,7 @@
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 @@ LogsRepository logsRepository(Logger logger) {
@Singleton
AsyncRunner asyncRunner() {
Handler uiHandler = new Handler();
- return new AsyncRunnerImpl(uiHandler, 4);
+ return new ThreadPoolAsyncRunner(uiHandler, 4);
}
}
diff --git a/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/src/main/java/com/nextcloud/client/di/ComponentsModule.java
index 8054366f39c5..a55aa14a9b78 100644
--- a/src/main/java/com/nextcloud/client/di/ComponentsModule.java
+++ b/src/main/java/com/nextcloud/client/di/ComponentsModule.java
@@ -45,7 +45,7 @@
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;
diff --git a/src/main/java/com/nextcloud/client/logger/FileLogHandler.kt b/src/main/java/com/nextcloud/client/logger/FileLogHandler.kt
index f477c2c38e47..3f701d5759a3 100644
--- a/src/main/java/com/nextcloud/client/logger/FileLogHandler.kt
+++ b/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, 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 {
+ fun loadLogFiles(rotated: Int = ROTATED_LOGS_COUNT): RawLogs {
if (rotated < 0) {
throw IllegalArgumentException("Negative index")
}
val allLines = mutableListOf()
+ 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)
}
}
diff --git a/src/main/java/com/nextcloud/client/logger/LoggerImpl.kt b/src/main/java/com/nextcloud/client/logger/LoggerImpl.kt
index 45b5a2730bec..d6105f9b4841 100644
--- a/src/main/java/com/nextcloud/client/logger/LoggerImpl.kt
+++ b/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, 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, 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()
}
diff --git a/src/main/java/com/nextcloud/client/logger/LogsRepository.kt b/src/main/java/com/nextcloud/client/logger/LogsRepository.kt
index 714321e1e706..9468fe5e946b 100644
--- a/src/main/java/com/nextcloud/client/logger/LogsRepository.kt
+++ b/src/main/java/com/nextcloud/client/logger/LogsRepository.kt
@@ -19,17 +19,14 @@
*/
package com.nextcloud.client.logger
+typealias OnLogsLoaded = (entries: List, 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)
- }
-
/**
* 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.
diff --git a/src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt b/src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt
new file mode 100644
index 000000000000..7846469f97e8
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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 filter(
+ collection: Iterable,
+ predicate: (T) -> Boolean,
+ onResult: (filtered: List, durationMs: Long) -> Unit
+ ) {
+ pendingRequest = {
+ filterAsync(collection, predicate, onResult)
+ }
+ if (!isRunning) {
+ pendingRequest?.invoke()
+ }
+ }
+
+ private fun filterAsync(collection: Iterable, predicate: (T) -> Boolean, onResult: (List, Long) -> Unit) {
+ startTime = time.invoke()
+ filterTask = asyncRunner.post(
+ task = {
+ collection.filter { predicate.invoke(it) }
+ },
+ onResult = { filtered: List ->
+ onFilterCompleted(filtered, onResult)
+ }
+ )
+ pendingRequest = null
+ }
+
+ private fun onFilterCompleted(filtered: List, callback: (List, Long) -> Unit) {
+ val dt = time.invoke() - startTime
+ callback.invoke(filtered, dt)
+ filterTask = null
+ startTime = 0L
+ pendingRequest?.invoke()
+ }
+}
diff --git a/src/main/java/com/nextcloud/client/logger/ui/LogsActivity.kt b/src/main/java/com/nextcloud/client/logger/ui/LogsActivity.kt
new file mode 100644
index 000000000000..c36697e589b2
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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(this, R.layout.logs_activity).apply {
+ lifecycleOwner = this@LogsActivity
+ vm = this@LogsActivity.vm
+ }
+
+ findViewById(R.id.logs_loading_progress).apply {
+ ThemeUtils.themeProgressBar(context, this)
+ }
+
+ logsAdapter = LogsAdapter(this)
+ findViewById(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
+ }
+}
diff --git a/src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt b/src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt
index 1600e8e6cb97..ffc43a76803a 100644
--- a/src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt
+++ b/src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt
@@ -52,7 +52,8 @@ class LogsAdapter(context: Context) : RecyclerView.Adapter> = MutableLiveData()
- private val listener = object : LogsRepository.Listener {
- override fun onLoaded(entries: List) {
- 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()
+ private var logsSize = -1L
+ private var filterDurationMs = 0L
+ private var isFiltered = false
+
+ val isLoading: LiveData = MutableLiveData().apply { value = false }
+ val size: LiveData = MutableLiveData().apply { value = 0 }
+ val entries: LiveData> = MutableLiveData>().apply { value = emptyList() }
+ val status: LiveData = MutableLiveData().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, 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, 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 -> ""
+ }
+ }
}
diff --git a/src/main/java/com/owncloud/android/ui/activity/LogsActivity.java b/src/main/java/com/owncloud/android/ui/activity/LogsActivity.java
deleted file mode 100644
index fabdc7a90ccd..000000000000
--- a/src/main/java/com/owncloud/android/ui/activity/LogsActivity.java
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * ownCloud Android client application
- *
- * Copyright (C) 2015 ownCloud Inc.
- * Copyright (C) Chris Narkiewicz
- *
- * 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 .
- */
-
-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();
- }
-}
diff --git a/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java b/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
index 2d3893fa639f..a3963a898a25 100644
--- a/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
+++ b/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
@@ -56,6 +56,7 @@
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;
diff --git a/src/main/java/com/owncloud/android/utils/ThemeUtils.java b/src/main/java/com/owncloud/android/utils/ThemeUtils.java
index c8829aca5a78..9d4e9ad412a9 100644
--- a/src/main/java/com/owncloud/android/utils/ThemeUtils.java
+++ b/src/main/java/com/owncloud/android/utils/ThemeUtils.java
@@ -485,6 +485,11 @@ public static void themeSearchView(Context context, SearchView searchView, boole
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[][]{
diff --git a/src/main/res/layout/logs_activity.xml b/src/main/res/layout/logs_activity.xml
index 9ee6e8074e55..2fe7cefc3d9d 100644
--- a/src/main/res/layout/logs_activity.xml
+++ b/src/main/res/layout/logs_activity.xml
@@ -1,72 +1,64 @@
-
+
-
+
+
+
+
-
-
+ android:layout_height="match_parent">
-
+
-
+
-
+
-
+
-
+
+
+
+
diff --git a/src/main/res/menu/logs_menu.xml b/src/main/res/menu/logs_menu.xml
new file mode 100644
index 000000000000..a1bafa4cfab9
--- /dev/null
+++ b/src/main/res/menu/logs_menu.xml
@@ -0,0 +1,49 @@
+
+
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index ad7db048adb5..1154ff12b9a6 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -47,7 +47,6 @@
Show hidden files
Show media scan notifications
Notify about newly found media folders
- Delete history
Sync calendar & contacts
Set up DAVx5 (formerly known as DAVdroid) (v1.3.0+) for current account
Server address for the account could not be resolved for DAVx5 (formerly known as DAVdroid)
@@ -407,11 +406,12 @@
Manage accounts
Secure connection redirected through an unsecured route.
- Logs
- Send history
+ Logs
+ Refresh
+ Send logs by e-mail
+ Delete logs
No app for sending logs found. Please install an e-mail client.
%1$s Android app logs
- Loading data…
Password required
Wrong password
@@ -888,6 +888,11 @@
Engineering Test Mode
Preferences
+ Loading…
+ Logs: %1$d kB, query matched %2$d / %3$d in %4$d ms
+ Logs: %1$d kB, no filter
+ Search logs
+
Report issue to tracker? (requires a Github account)
Report
%1$s crashed
diff --git a/src/main/res/xml/preferences.xml b/src/main/res/xml/preferences.xml
index f1cd4d475dbf..d348f7d88dc0 100644
--- a/src/main/res/xml/preferences.xml
+++ b/src/main/res/xml/preferences.xml
@@ -69,7 +69,7 @@
-
+
diff --git a/src/test/java/com/nextcloud/client/core/ManualAsyncRunnerTest.kt b/src/test/java/com/nextcloud/client/core/ManualAsyncRunnerTest.kt
new file mode 100644
index 000000000000..aff2b7b1a60b
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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
+
+ @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 {
+ 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
+ }
+}
diff --git a/src/test/java/com/nextcloud/client/core/TaskTest.kt b/src/test/java/com/nextcloud/client/core/TaskTest.kt
new file mode 100644
index 000000000000..968fc73cbaec
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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
+ @Mock
+ private lateinit var onError: OnErrorCallback
+
+ private lateinit var task: Task
+
+ @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())
+ }
+}
diff --git a/src/test/java/com/nextcloud/client/core/AsyncRunnerTest.kt b/src/test/java/com/nextcloud/client/core/ThreadPoolAsyncRunnerTest.kt
similarity index 78%
rename from src/test/java/com/nextcloud/client/core/AsyncRunnerTest.kt
rename to src/test/java/com/nextcloud/client/core/ThreadPoolAsyncRunnerTest.kt
index b63bc0408c58..bd921ea71498 100644
--- a/src/test/java/com/nextcloud/client/core/AsyncRunnerTest.kt
+++ b/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
+ *
+ * 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 .
+ */
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) {
diff --git a/src/test/java/com/nextcloud/client/logger/TestFileLogHandler.kt b/src/test/java/com/nextcloud/client/logger/FileLogHandlerTest.kt
similarity index 86%
rename from src/test/java/com/nextcloud/client/logger/TestFileLogHandler.kt
rename to src/test/java/com/nextcloud/client/logger/FileLogHandlerTest.kt
index d930761fe867..708fa4f7df78 100644
--- a/src/test/java/com/nextcloud/client/logger/TestFileLogHandler.kt
+++ b/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)
diff --git a/src/test/java/com/nextcloud/client/logger/LevelTest.kt b/src/test/java/com/nextcloud/client/logger/LevelTest.kt
new file mode 100644
index 000000000000..4af6d81f3816
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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"))
+ }
+}
diff --git a/src/test/java/com/nextcloud/client/logger/TestLogger.kt b/src/test/java/com/nextcloud/client/logger/LoggerTest.kt
similarity index 95%
rename from src/test/java/com/nextcloud/client/logger/TestLogger.kt
rename to src/test/java/com/nextcloud/client/logger/LoggerTest.kt
index 3f6ec62095db..ab12042c9c0c 100644
--- a/src/test/java/com/nextcloud/client/logger/TestLogger.kt
+++ b/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>
- 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)
}
}
diff --git a/src/test/java/com/nextcloud/client/logger/ui/AsyncFilterTest.kt b/src/test/java/com/nextcloud/client/logger/ui/AsyncFilterTest.kt
new file mode 100644
index 000000000000..08532141838f
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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 : (List, Long) -> Unit {
+ var arg: List? = null
+ var dt: Long? = null
+ override fun invoke(arg: List, 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()
+
+ // 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()
+ val secondResult = OnResult()
+
+ // 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()
+
+ val secondPredicate: (Int) -> Boolean = mock()
+ whenever(secondPredicate.invoke(any())).thenReturn(false)
+ val secondResult = OnResult()
+
+ val thirdPredicate = { arg: Int -> arg > 3 }
+ val thirdResult = OnResult()
+
+ // 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()
+ 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)
+ }
+}
diff --git a/src/test/java/com/nextcloud/client/logger/ui/LogsViewModelTest.kt b/src/test/java/com/nextcloud/client/logger/ui/LogsViewModelTest.kt
new file mode 100644
index 000000000000..99af65fd8235
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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 = 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)
+ }
+ }
+}