Quellcode durchsuchen

New download manager

Signed-off-by: Chris Narkiewicz <hello@ezaquarii.com>
Chris Narkiewicz vor 4 Jahren
Ursprung
Commit
75b9fa8551
50 geänderte Dateien mit 3194 neuen und 106 gelöschten Zeilen
  1. 1 0
      CONTRIBUTING.md
  2. 15 4
      build.gradle
  3. 6 6
      detekt.yml
  4. 19 0
      src/androidTest/java/com/nextcloud/client/ScreenshotTestRunner.java
  5. 71 0
      src/androidTest/java/com/nextcloud/client/account/MockUserTest.kt
  6. 240 0
      src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderConnectionTest.kt
  7. 58 0
      src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderServiceTest.kt
  8. 281 0
      src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderTest.kt
  9. 519 0
      src/androidTest/java/com/nextcloud/client/files/downloader/RegistryTest.kt
  10. 1 0
      src/main/AndroidManifest.xml
  11. 80 0
      src/main/java/com/nextcloud/client/account/MockUser.kt
  12. 37 7
      src/main/java/com/nextcloud/client/core/AsyncRunner.kt
  13. 2 2
      src/main/java/com/nextcloud/client/core/Cancellable.kt
  14. 28 0
      src/main/java/com/nextcloud/client/core/LocalBinder.kt
  15. 105 0
      src/main/java/com/nextcloud/client/core/LocalConnection.kt
  16. 25 4
      src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt
  17. 20 9
      src/main/java/com/nextcloud/client/core/Task.kt
  18. 34 7
      src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt
  19. 19 2
      src/main/java/com/nextcloud/client/di/AppModule.java
  20. 2 0
      src/main/java/com/nextcloud/client/di/ComponentsModule.java
  21. 14 0
      src/main/java/com/nextcloud/client/etm/EtmViewModel.kt
  22. 133 0
      src/main/java/com/nextcloud/client/etm/pages/EtmDownloaderFragment.kt
  23. 49 0
      src/main/java/com/nextcloud/client/files/downloader/Download.kt
  24. 27 0
      src/main/java/com/nextcloud/client/files/downloader/DownloadState.kt
  25. 100 0
      src/main/java/com/nextcloud/client/files/downloader/DownloadTask.kt
  26. 98 0
      src/main/java/com/nextcloud/client/files/downloader/Downloader.kt
  27. 122 0
      src/main/java/com/nextcloud/client/files/downloader/DownloaderConnection.kt
  28. 144 0
      src/main/java/com/nextcloud/client/files/downloader/DownloaderImpl.kt
  29. 155 0
      src/main/java/com/nextcloud/client/files/downloader/DownloaderService.kt
  30. 162 0
      src/main/java/com/nextcloud/client/files/downloader/Registry.kt
  31. 79 0
      src/main/java/com/nextcloud/client/files/downloader/Request.kt
  32. 2 4
      src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt
  33. 1 1
      src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt
  34. 1 1
      src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt
  35. 38 0
      src/main/java/com/nextcloud/client/notifications/AppNotificationManager.kt
  36. 88 0
      src/main/java/com/nextcloud/client/notifications/AppNotificationManagerImpl.kt
  37. 4 0
      src/main/java/com/owncloud/android/operations/DownloadFileOperation.java
  38. 0 1
      src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java
  39. 7 0
      src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java
  40. 27 27
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListFragment.java
  41. 7 2
      src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.java
  42. 119 0
      src/main/res/layout/etm_download_list_item.xml
  43. 31 0
      src/main/res/layout/fragment_etm_downloader.xml
  44. 33 0
      src/main/res/menu/fragment_etm_downloader.xml
  45. 7 0
      src/main/res/values/strings.xml
  46. 132 0
      src/test/java/com/nextcloud/client/core/LocalConnectionTest.kt
  47. 13 13
      src/test/java/com/nextcloud/client/core/ManualAsyncRunnerTest.kt
  48. 17 8
      src/test/java/com/nextcloud/client/core/TaskTest.kt
  49. 11 8
      src/test/java/com/nextcloud/client/core/ThreadPoolAsyncRunnerTest.kt
  50. 10 0
      src/test/java/com/nextcloud/client/etm/TestEtmViewModel.kt

+ 1 - 0
CONTRIBUTING.md

@@ -379,6 +379,7 @@ Generally, whenever you need:
 * media playback
 * networking
 * logging
+* notifications management
 
 we have something more suitable.
 

+ 15 - 4
build.gradle

@@ -357,7 +357,9 @@ dependencies {
     testImplementation 'org.powermock:powermock-api-mockito2:2.0.7'
     testImplementation 'org.json:json:20200518'
     testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
-    testImplementation "androidx.arch.core:core-testing:2.1.0"
+    testImplementation 'androidx.arch.core:core-testing:2.1.0'
+    testImplementation 'io.mockk:mockk:1.10.0'
+    testImplementation 'io.mockk:mockk-android:1.10.0'
 
     // dependencies for instrumented tests
     // JUnit4 Rules
@@ -370,7 +372,19 @@ dependencies {
     // Espresso core
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
     androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0'
+
+    // Mocking support
+    androidTestImplementation 'com.github.tmurakami:dexopener:2.0.5' // required to allow mocking on API 27 and older
+    androidTestImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
     androidTestImplementation 'org.mockito:mockito-core:3.4.4'
+    androidTestImplementation("org.mockito:mockito-android:3.3.3") {
+        exclude group: "net.bytebuddy", module: "byte-buddy-android"
+    }
+    androidTestImplementation 'net.bytebuddy:byte-buddy:1.10.13'
+    androidTestImplementation "net.bytebuddy:byte-buddy-android:1.10.10"
+    androidTestImplementation "io.mockk:mockk-android:1.10.0"
+    androidTestImplementation 'androidx.arch.core:core-testing:2.0.1'
+
     // UIAutomator - for cross-app UI tests, and to grant screen is turned on in Espresso tests
     // androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
     // fix conflict in dependencies; see http://g.co/androidstudio/app-test-app-conflict for details
@@ -388,9 +402,6 @@ dependencies {
 //    androidJacocoAnt "org.jacoco:org.jacoco.agent:${jacocoVersion}"
 
     implementation "com.github.stateless4j:stateless4j:2.6.0"
-    androidTestImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
-    androidTestImplementation "org.mockito:mockito-android:3.4.4"
-    androidTestImplementation 'net.bytebuddy:byte-buddy:1.10.13'
 }
 
 spotbugs {

+ 6 - 6
detekt.yml

@@ -83,14 +83,14 @@ complexity:
     ignoreStringsRegex: '$^'
   TooManyFunctions:
     active: true
-    thresholdInFiles: 11
-    thresholdInClasses: 11
-    thresholdInInterfaces: 11
-    thresholdInObjects: 11
+    thresholdInFiles: 15
+    thresholdInClasses: 15
+    thresholdInInterfaces: 15
+    thresholdInObjects: 15
     thresholdInEnums: 11
-    ignoreDeprecated: false
+    ignoreDeprecated: true
     ignorePrivate: false
-    ignoreOverridden: false
+    ignoreOverridden: true
 
 empty-blocks:
   active: true

+ 19 - 0
src/androidTest/java/com/nextcloud/client/ScreenshotTestRunner.java

@@ -22,14 +22,33 @@
 
 package com.nextcloud.client;
 
+import android.app.Application;
+import android.content.Context;
+import android.os.Build;
 import android.os.Bundle;
 
 import com.facebook.testing.screenshot.ScreenshotRunner;
+import com.github.tmurakami.dexopener.DexOpener;
 
 import androidx.test.runner.AndroidJUnitRunner;
 
 public class ScreenshotTestRunner extends AndroidJUnitRunner {
 
+    @Override
+    public Application newApplication(ClassLoader cl, String className, Context context)
+        throws ClassNotFoundException, IllegalAccessException, InstantiationException {
+
+        /*
+         * Initialize DexOpener only on API below 28 to enable mocking of Kotlin classes.
+         * On API 28+ the platform supports mocking natively.
+         */
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
+            DexOpener.install(this);
+        }
+
+        return super.newApplication(cl, className, context);
+    }
+
     @Override
     public void onCreate(Bundle args) {
         super.onCreate(args);

+ 71 - 0
src/androidTest/java/com/nextcloud/client/account/MockUserTest.kt

@@ -0,0 +1,71 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz <hello@ezaquarii.com>
+ * Copyright (C) 2020 Chris Narkiewicz
+ * Copyright (C) 2020 Nextcloud GmbH
+ *
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.account
+
+import android.os.Parcel
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MockUserTest {
+
+    private companion object {
+        const val ACCOUNT_NAME = "test_account_name"
+        const val ACCOUNT_TYPE = "test_account_type"
+    }
+
+    @Test
+    fun mock_user_is_parcelable() {
+        // GIVEN
+        //      mock user instance
+        val original = MockUser(ACCOUNT_NAME, ACCOUNT_TYPE)
+
+        // WHEN
+        //      instance is serialized into Parcel
+        //      instance is retrieved from Parcel
+        val parcel = Parcel.obtain()
+        parcel.setDataPosition(0)
+        parcel.writeParcelable(original, 0)
+        parcel.setDataPosition(0)
+        val retrieved = parcel.readParcelable<User>(User::class.java.classLoader)
+
+        // THEN
+        //      retrieved instance in distinct
+        //      instances are equal
+        assertNotSame(original, retrieved)
+        assertTrue(retrieved is MockUser)
+        assertEquals(original, retrieved)
+    }
+
+    @Test
+    fun mock_user_has_platform_account() {
+        // GIVEN
+        //      mock user instance
+        val mock = MockUser(ACCOUNT_NAME, ACCOUNT_TYPE)
+
+        // THEN
+        //      can convert to platform account
+        val account = mock.toPlatformAccount()
+        assertEquals(ACCOUNT_NAME, account.name)
+        assertEquals(ACCOUNT_TYPE, account.type)
+    }
+}

+ 240 - 0
src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderConnectionTest.kt

@@ -0,0 +1,240 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+import android.content.ComponentName
+import android.content.Context
+import com.nextcloud.client.account.MockUser
+import com.owncloud.android.datamodel.OCFile
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class DownloaderConnectionTest {
+
+    lateinit var connection: DownloaderConnection
+
+    @MockK
+    lateinit var context: Context
+
+    @MockK
+    lateinit var firstDownloadListener: (Download) -> Unit
+
+    @MockK
+    lateinit var secondDownloadListener: (Download) -> Unit
+
+    @MockK
+    lateinit var firstStatusListener: (Downloader.Status) -> Unit
+
+    @MockK
+    lateinit var secondStatusListener: (Downloader.Status) -> Unit
+
+    @MockK
+    lateinit var binder: DownloaderService.Binder
+
+    val file get() = OCFile("/path")
+    val componentName = ComponentName("", DownloaderService::class.java.simpleName)
+    val user = MockUser()
+
+    @Before
+    fun setUp() {
+        MockKAnnotations.init(this, relaxed = true)
+        connection = DownloaderConnection(context, user)
+    }
+
+    @Test
+    fun listeners_are_set_after_connection() {
+        // GIVEN
+        //      not connected
+        //      listener is added
+        connection.registerDownloadListener(firstDownloadListener)
+        connection.registerDownloadListener(secondDownloadListener)
+
+        // WHEN
+        //      service is bound
+        connection.onServiceConnected(componentName, binder)
+
+        // THEN
+        //      all listeners are passed to the service
+        val listeners = mutableListOf<(Download) -> Unit>()
+        verify { binder.registerDownloadListener(capture(listeners)) }
+        assertEquals(listOf(firstDownloadListener, secondDownloadListener), listeners)
+    }
+
+    @Test
+    fun listeners_are_set_immediately_when_connected() {
+        // GIVEN
+        //      service is bound
+        connection.onServiceConnected(componentName, binder)
+
+        // WHEN
+        //      listeners are added
+        connection.registerDownloadListener(firstDownloadListener)
+
+        // THEN
+        //      listener is forwarded to service
+        verify { binder.registerDownloadListener(firstDownloadListener) }
+    }
+
+    @Test
+    fun listeners_are_removed_when_unbinding() {
+        // GIVEN
+        //      service is bound
+        //      service has some listeners
+        connection.onServiceConnected(componentName, binder)
+        connection.registerDownloadListener(firstDownloadListener)
+        connection.registerDownloadListener(secondDownloadListener)
+
+        // WHEN
+        //      service unbound
+        connection.unbind()
+
+        // THEN
+        //      listeners removed from service
+        verify { binder.removeDownloadListener(firstDownloadListener) }
+        verify { binder.removeDownloadListener(secondDownloadListener) }
+    }
+
+    @Test
+    fun missed_updates_are_delivered_on_connection() {
+        // GIVEN
+        //      not bound
+        //      has listeners
+        //      download is scheduled and is progressing
+        connection.registerDownloadListener(firstDownloadListener)
+        connection.registerDownloadListener(secondDownloadListener)
+
+        val request1 = Request(user, file)
+        connection.download(request1)
+        val download1 = Download(request1.uuid, DownloadState.RUNNING, 50, request1.file, request1)
+
+        val request2 = Request(user, file)
+        connection.download(request2)
+        val download2 = Download(request2.uuid, DownloadState.RUNNING, 50, request2.file, request1)
+
+        every { binder.getDownload(request1.uuid) } returns download1
+        every { binder.getDownload(request2.uuid) } returns download2
+
+        // WHEN
+        //      service is bound
+        connection.onServiceConnected(componentName, binder)
+
+        // THEN
+        //      listeners receive current download state for pending downloads
+        val firstListenerNotifications = mutableListOf<Download>()
+        verify { firstDownloadListener(capture(firstListenerNotifications)) }
+        assertEquals(listOf(download1, download2), firstListenerNotifications)
+
+        val secondListenerNotifications = mutableListOf<Download>()
+        verify { secondDownloadListener(capture(secondListenerNotifications)) }
+        assertEquals(listOf(download1, download2), secondListenerNotifications)
+    }
+
+    @Test
+    fun downloader_status_updates_are_delivered_on_connection() {
+        // GIVEN
+        //      not bound
+        //      has status listeners
+        val mockStatus: Downloader.Status = mockk()
+        every { binder.status } returns mockStatus
+        connection.registerStatusListener(firstStatusListener)
+        connection.registerStatusListener(secondStatusListener)
+
+        // WHEN
+        //      service is bound
+        connection.onServiceConnected(componentName, binder)
+
+        // THEN
+        //      downloader status is delivered
+        verify { firstStatusListener(mockStatus) }
+        verify { secondStatusListener(mockStatus) }
+    }
+
+    @Test
+    fun downloader_status_not_requested_if_no_listeners() {
+        // GIVEN
+        //      not bound
+        //      no status listeners
+
+        // WHEN
+        //      service is bound
+        connection.onServiceConnected(componentName, binder)
+
+        // THEN
+        //      downloader status is not requested
+        verify(exactly = 0) { binder.status }
+    }
+
+    @Test
+    fun not_running_if_not_connected() {
+        // GIVEN
+        //      downloader is running
+        //      connection not bound
+        every { binder.isRunning } returns true
+
+        // THEN
+        //      not running
+        assertFalse(connection.isRunning)
+    }
+
+    @Test
+    fun is_running_from_binder_if_connected() {
+        // GIVEN
+        //      service bound
+        every { binder.isRunning } returns true
+        connection.onServiceConnected(componentName, binder)
+
+        // WHEN
+        //      is runnign flag accessed
+        val isRunning = connection.isRunning
+
+        // THEN
+        //      call delegated to binder
+        assertTrue(isRunning)
+        verify(exactly = 1) { binder.isRunning }
+    }
+
+    @Test
+    fun missed_updates_not_tracked_before_listeners_registered() {
+        // GIVEN
+        //      not bound
+        //      some downloads requested without listener
+        val request = Request(user, file)
+        connection.download(request)
+        val download = Download(request.uuid, DownloadState.RUNNING, 50, request.file, request)
+        connection.registerDownloadListener(firstDownloadListener)
+        every { binder.getDownload(request.uuid) } returns download
+
+        // WHEN
+        //      service is bound
+        connection.onServiceConnected(componentName, binder)
+
+        // THEN
+        //      missed updates not redelivered
+        verify(exactly = 0) { firstDownloadListener(any()) }
+    }
+}

+ 58 - 0
src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderServiceTest.kt

@@ -0,0 +1,58 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.rule.ServiceTestRule
+import com.nextcloud.client.account.MockUser
+import io.mockk.MockKAnnotations
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+class DownloaderServiceTest {
+
+    @get:Rule
+    val service = ServiceTestRule.withTimeout(3, TimeUnit.SECONDS)
+
+    val user = MockUser()
+
+    @Before
+    fun setUp() {
+        MockKAnnotations.init(this, relaxed = true)
+    }
+
+    @Test(expected = TimeoutException::class)
+    fun cannot_bind_to_service_without_user() {
+        val intent = DownloaderService.createBindIntent(getApplicationContext(), user)
+        intent.removeExtra(DownloaderService.EXTRA_USER)
+        service.bindService(intent)
+    }
+
+    @Test
+    fun bind_with_user() {
+        val intent = DownloaderService.createBindIntent(getApplicationContext(), user)
+        val binder = service.bindService(intent)
+        assertTrue(binder is DownloaderService.Binder)
+    }
+}

+ 281 - 0
src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderTest.kt

@@ -0,0 +1,281 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import com.nextcloud.client.account.User
+import com.nextcloud.client.core.ManualAsyncRunner
+import com.nextcloud.client.core.OnProgressCallback
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.OwnCloudClient
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+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
+
+@RunWith(Suite::class)
+@Suite.SuiteClasses(
+    DownloaderTest.Enqueue::class,
+    DownloaderTest.DownloadStatusUpdates::class
+)
+class DownloaderTest {
+
+    abstract class Base {
+
+        companion object {
+            const val MAX_DOWNLOAD_THREADS = 4
+        }
+
+        @MockK
+        lateinit var user: User
+
+        @MockK
+        lateinit var client: OwnCloudClient
+
+        @MockK
+        lateinit var mockTaskFactory: DownloadTask.Factory
+
+        /**
+         * All task mock functions created during test run are
+         * stored here.
+         */
+        lateinit var downloadTaskMocks: MutableList<DownloadTask>
+        lateinit var runner: ManualAsyncRunner
+        lateinit var downloader: DownloaderImpl
+
+        /**
+         * Response value for all download tasks
+         */
+        var downloadTaskResult: Boolean = true
+
+        /**
+         * Progress values posted by all download task mocks before
+         * returning result value
+         */
+        var taskProgress = listOf<Int>()
+
+        @Before
+        fun setUpBase() {
+            MockKAnnotations.init(this, relaxed = true)
+            MockitoAnnotations.initMocks(this)
+            downloadTaskMocks = mutableListOf()
+            runner = ManualAsyncRunner()
+            downloader = DownloaderImpl(
+                runner = runner,
+                taskFactory = mockTaskFactory,
+                threads = MAX_DOWNLOAD_THREADS
+            )
+            downloadTaskResult = true
+            every { mockTaskFactory.create() } answers { createMockTask() }
+        }
+
+        private fun createMockTask(): DownloadTask {
+            val task = mockk<DownloadTask>()
+            every { task.download(any(), any(), any()) } answers {
+                taskProgress.forEach {
+                    arg<OnProgressCallback<Int>>(1).invoke(it)
+                }
+                val request = arg<Request>(0)
+                DownloadTask.Result(request.file, downloadTaskResult)
+            }
+            downloadTaskMocks.add(task)
+            return task
+        }
+    }
+
+    class Enqueue : Base() {
+
+        @Test
+        fun enqueued_download_is_started_immediately() {
+            // GIVEN
+            //      downloader has no running downloads
+
+            // WHEN
+            //      download is enqueued
+            val file = OCFile("/path")
+            val request = Request(user, file)
+            downloader.download(request)
+
+            // THEN
+            //      download is started immediately
+            val download = downloader.getDownload(request.uuid)
+            assertEquals(DownloadState.RUNNING, download?.state)
+        }
+
+        @Test
+        fun enqueued_downloads_are_pending_if_running_queue_is_full() {
+            // GIVEN
+            //      downloader is downloading max simultaneous files
+            for (i in 0 until MAX_DOWNLOAD_THREADS) {
+                val file = OCFile("/running/download/path/$i")
+                val request = Request(user, file)
+                downloader.download(request)
+                val runningDownload = downloader.getDownload(request.uuid)
+                assertEquals(runningDownload?.state, DownloadState.RUNNING)
+            }
+
+            // WHEN
+            //      another download is enqueued
+            val file = OCFile("/path")
+            val request = Request(user, file)
+            downloader.download(request)
+
+            // THEN
+            //      download is pending
+            val download = downloader.getDownload(request.uuid)
+            assertEquals(DownloadState.PENDING, download?.state)
+        }
+    }
+
+    class DownloadStatusUpdates : Base() {
+
+        @get:Rule
+        val rule = InstantTaskExecutorRule()
+
+        val file = OCFile("/path")
+
+        @Test
+        fun download_task_completes() {
+            // GIVEN
+            //      download is running
+            //      download is being observed
+            val downloadUpdates = mutableListOf<Download>()
+            downloader.registerDownloadListener { downloadUpdates.add(it) }
+            downloader.download(Request(user, file))
+
+            // WHEN
+            //      download task finishes successfully
+            runner.runOne()
+
+            // THEN
+            //      listener is notified about status change
+            assertEquals(DownloadState.RUNNING, downloadUpdates[0].state)
+            assertEquals(DownloadState.COMPLETED, downloadUpdates[1].state)
+        }
+
+        @Test
+        fun download_task_fails() {
+            // GIVEN
+            //      download is running
+            //      download is being observed
+            val downloadUpdates = mutableListOf<Download>()
+            downloader.registerDownloadListener { downloadUpdates.add(it) }
+            downloader.download(Request(user, file))
+
+            // WHEN
+            //      download task fails
+            downloadTaskResult = false
+            runner.runOne()
+
+            // THEN
+            //      listener is notified about status change
+            assertEquals(DownloadState.RUNNING, downloadUpdates[0].state)
+            assertEquals(DownloadState.FAILED, downloadUpdates[1].state)
+        }
+
+        @Test
+        fun download_progress_is_updated() {
+            // GIVEN
+            //      download is running
+            val downloadUpdates = mutableListOf<Download>()
+            downloader.registerDownloadListener { downloadUpdates.add(it) }
+            downloader.download(Request(user, file))
+
+            // WHEN
+            //      download progress updated 4 times before completion
+            taskProgress = listOf(25, 50, 75, 100)
+            runner.runOne()
+
+            // THEN
+            //      listener receives 6 status updates
+            //          transition to running
+            //          4 progress updates
+            //          completion
+            assertEquals(6, downloadUpdates.size)
+            if (downloadUpdates.size >= 6) {
+                assertEquals(DownloadState.RUNNING, downloadUpdates[0].state)
+                assertEquals(25, downloadUpdates[1].progress)
+                assertEquals(50, downloadUpdates[2].progress)
+                assertEquals(75, downloadUpdates[3].progress)
+                assertEquals(100, downloadUpdates[4].progress)
+                assertEquals(DownloadState.COMPLETED, downloadUpdates[5].state)
+            }
+        }
+
+        @Test
+        fun download_task_is_created_only_for_running_downloads() {
+            // WHEN
+            //      multiple downloads are enqueued
+            for (i in 0 until MAX_DOWNLOAD_THREADS * 2) {
+                downloader.download(Request(user, file))
+            }
+
+            // THEN
+            //      download task is created only for running downloads
+            assertEquals(MAX_DOWNLOAD_THREADS, downloadTaskMocks.size)
+        }
+    }
+
+    class RunningStatusUpdates : Base() {
+
+        @get:Rule
+        val rule = InstantTaskExecutorRule()
+
+        @Test
+        fun is_running_flag_on_enqueue() {
+            // WHEN
+            //      download is enqueued
+            val file = OCFile("/path/to/file")
+            val request = Request(user, file)
+            downloader.download(request)
+
+            // THEN
+            //      is running changes
+            assertTrue(downloader.isRunning)
+        }
+
+        @Test
+        fun is_running_flag_on_completion() {
+            // GIVEN
+            //      a download is in progress
+            val file = OCFile("/path/to/file")
+            val request = Request(user, file)
+            downloader.download(request)
+            assertTrue(downloader.isRunning)
+
+            // WHEN
+            //      download is processed
+            runner.runOne()
+
+            // THEN
+            //      downloader is not running
+            assertFalse(downloader.isRunning)
+        }
+    }
+}

+ 519 - 0
src/androidTest/java/com/nextcloud/client/files/downloader/RegistryTest.kt

@@ -0,0 +1,519 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+import com.nextcloud.client.account.User
+import com.owncloud.android.datamodel.OCFile
+import io.mockk.CapturingSlot
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.verify
+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.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Suite
+import java.util.UUID
+
+@RunWith(Suite::class)
+@Suite.SuiteClasses(
+    RegistryTest.Pending::class,
+    RegistryTest.Start::class,
+    RegistryTest.Progress::class,
+    RegistryTest.Complete::class,
+    RegistryTest.GetDownloads::class,
+    RegistryTest.IsRunning::class
+)
+class RegistryTest {
+
+    abstract class Base {
+        companion object {
+            const val MAX_DOWNLOAD_THREADS = 4
+            const val PROGRESS_FULL = 100
+            const val PROGRESS_HALF = 50
+        }
+
+        @MockK
+        lateinit var user: User
+
+        lateinit var file: OCFile
+
+        @MockK
+        lateinit var onDownloadStart: (UUID, Request) -> Unit
+
+        @MockK
+        lateinit var onDownloadChanged: (Download) -> Unit
+
+        internal lateinit var registry: Registry
+
+        @Before
+        fun setUpBase() {
+            MockKAnnotations.init(this, relaxed = true)
+            file = OCFile("/test/path")
+            registry = Registry(onDownloadStart, onDownloadChanged, MAX_DOWNLOAD_THREADS)
+            resetMocks()
+        }
+
+        fun resetMocks() {
+            clearAllMocks()
+            every { onDownloadStart(any(), any()) } answers {}
+            every { onDownloadChanged(any()) } answers {}
+        }
+    }
+
+    class Pending : Base() {
+
+        @Test
+        fun inserting_pending_download() {
+            // GIVEN
+            //      registry has no pending downloads
+            assertEquals(0, registry.pending.size)
+
+            // WHEN
+            //      new download requests added
+            val addedDownloadsCount = 10
+            for (i in 0 until addedDownloadsCount) {
+                val request = Request(user, file)
+                registry.add(request)
+            }
+
+            // THEN
+            //      download is added to the pending queue
+            assertEquals(addedDownloadsCount, registry.pending.size)
+        }
+    }
+
+    class Start : Base() {
+
+        companion object {
+            const val ENQUEUED_REQUESTS_COUNT = 10
+        }
+
+        @Before
+        fun setUp() {
+            for (i in 0 until ENQUEUED_REQUESTS_COUNT) {
+                registry.add(Request(user, file))
+            }
+            assertEquals(ENQUEUED_REQUESTS_COUNT, registry.pending.size)
+        }
+
+        @Test
+        fun starting_download() {
+            // WHEN
+            //      started
+            registry.startNext()
+
+            // THEN
+            //      up to max threads requests are started
+            //      start callback is triggered
+            //      update callback is triggered on download transition
+            //      started downloads are in running state
+            assertEquals(
+                "Downloads not moved to running queue",
+                MAX_DOWNLOAD_THREADS,
+                registry.running.size
+            )
+            assertEquals(
+                "Downloads not moved from pending queue",
+                ENQUEUED_REQUESTS_COUNT - MAX_DOWNLOAD_THREADS,
+                registry.pending.size
+            )
+            verify(exactly = MAX_DOWNLOAD_THREADS) { onDownloadStart(any(), any()) }
+            val startedDownloads = mutableListOf<Download>()
+            verify(exactly = MAX_DOWNLOAD_THREADS) { onDownloadChanged(capture(startedDownloads)) }
+            assertEquals(
+                "Callbacks not invoked for running downloads",
+                MAX_DOWNLOAD_THREADS,
+                startedDownloads.size
+            )
+            startedDownloads.forEach {
+                assertEquals("Download not placed into running state", DownloadState.RUNNING, it.state)
+            }
+        }
+
+        @Test
+        fun start_is_ignored_if_no_more_free_threads() {
+            // WHEN
+            //      max number of running downloads
+            registry.startNext()
+            assertEquals(MAX_DOWNLOAD_THREADS, registry.running.size)
+            clearAllMocks()
+
+            // WHEN
+            //      starting more downloads
+            registry.startNext()
+
+            // THEN
+            //      no more downloads can be started
+            assertEquals(MAX_DOWNLOAD_THREADS, registry.running.size)
+            verify(exactly = 0) { onDownloadStart(any(), any()) }
+        }
+    }
+
+    class Progress : Base() {
+
+        var uuid: UUID = UUID.randomUUID()
+
+        @Before
+        fun setUp() {
+            val request = Request(user, file)
+            uuid = registry.add(request)
+            registry.startNext()
+            assertEquals(uuid, request.uuid)
+            assertEquals(1, registry.running.size)
+            resetMocks()
+        }
+
+        @Test
+        fun download_progress_is_updated() {
+            // GIVEN
+            //      a download is running
+
+            // WHEN
+            //      download progress is updated
+            val progressHalf = 50
+            registry.progress(uuid, progressHalf)
+
+            // THEN
+            //      progress is updated
+            //      update callback is invoked
+            val download = mutableListOf<Download>()
+            verify { onDownloadChanged(capture(download)) }
+            assertEquals(1, download.size)
+            assertEquals(progressHalf, download.first().progress)
+        }
+
+        @Test
+        fun updates_for_non_running_downloads_are_ignored() {
+            // GIVEN
+            //      download is not running
+            registry.complete(uuid, true)
+            assertEquals(0, registry.running.size)
+            resetMocks()
+
+            // WHEN
+            //      progress for a non-running download is updated
+            registry.progress(uuid, PROGRESS_HALF)
+
+            // THEN
+            //      progress update is ignored
+            verify(exactly = 0) { onDownloadChanged(any()) }
+        }
+
+        @Test
+        fun updates_for_non_existing_downloads_are_ignored() {
+            // GIVEN
+            //      some download is running
+
+            // WHEN
+            //      progress is updated for non-existing download
+            val nonExistingDownloadId = UUID.randomUUID()
+            registry.progress(nonExistingDownloadId, PROGRESS_HALF)
+
+            // THEN
+            //      progress uppdate is ignored
+            verify(exactly = 0) { onDownloadChanged(any()) }
+        }
+    }
+
+    class Complete : Base() {
+
+        lateinit var uuid: UUID
+
+        @Before
+        fun setUp() {
+            uuid = registry.add(Request(user, file))
+            registry.startNext()
+            registry.progress(uuid, PROGRESS_FULL)
+            resetMocks()
+        }
+
+        @Test
+        fun complete_successful_download_with_updated_file() {
+            // GIVEN
+            //      a download is running
+
+            // WHEN
+            //      download is completed
+            //      file has been updated
+            val updatedFile = OCFile("/updated/file")
+            registry.complete(uuid, true, updatedFile)
+
+            // THEN
+            //      download is completed successfully
+            //      status carries updated file
+            val slot = CapturingSlot<Download>()
+            verify { onDownloadChanged(capture(slot)) }
+            assertEquals(DownloadState.COMPLETED, slot.captured.state)
+            assertSame(slot.captured.file, updatedFile)
+        }
+
+        @Test
+        fun complete_successful_download() {
+            // GIVEN
+            //      a download is running
+
+            // WHEN
+            //      download is completed
+            //      file is not updated
+            registry.complete(uuid = uuid, success = true, file = null)
+
+            // THEN
+            //      download is completed successfully
+            //      status carries previous file
+            val slot = CapturingSlot<Download>()
+            verify { onDownloadChanged(capture(slot)) }
+            assertEquals(DownloadState.COMPLETED, slot.captured.state)
+            assertSame(slot.captured.file, file)
+        }
+
+        @Test
+        fun complete_failed_download() {
+            // GIVEN
+            //      a download is running
+
+            // WHEN
+            //      download is failed
+            registry.complete(uuid, false)
+
+            // THEN
+            //      download is completed successfully
+            val slot = CapturingSlot<Download>()
+            verify { onDownloadChanged(capture(slot)) }
+            assertEquals(DownloadState.FAILED, slot.captured.state)
+        }
+    }
+
+    class GetDownloads : Base() {
+
+        val pendingDownloadFile = OCFile("/pending")
+        val runningDownloadFile = OCFile("/running")
+        val completedDownloadFile = OCFile("/completed")
+
+        lateinit var pendingDownloadId: UUID
+        lateinit var runningDownloadId: UUID
+        lateinit var completedDownloadId: UUID
+
+        @Before
+        fun setUp() {
+            completedDownloadId = registry.add(Request(user, completedDownloadFile))
+            registry.startNext()
+            registry.complete(completedDownloadId, true)
+
+            runningDownloadId = registry.add(Request(user, runningDownloadFile))
+            registry.startNext()
+
+            pendingDownloadId = registry.add(Request(user, pendingDownloadFile))
+            resetMocks()
+
+            assertEquals(1, registry.pending.size)
+            assertEquals(1, registry.running.size)
+            assertEquals(1, registry.completed.size)
+        }
+
+        @Test
+        fun get_by_path_searches_pending_queue() {
+            // GIVEN
+            //      file download is pending
+
+            // WHEN
+            //      download status is retrieved
+            val download = registry.getDownload(pendingDownloadFile)
+
+            // THEN
+            //      download from pending queue is returned
+            assertNotNull(download)
+            assertEquals(pendingDownloadId, download?.uuid)
+        }
+
+        @Test
+        fun get_by_id_searches_pending_queue() {
+            // GIVEN
+            //      file download is pending
+
+            // WHEN
+            //      download status is retrieved
+            val download = registry.getDownload(pendingDownloadId)
+
+            // THEN
+            //      download from pending queue is returned
+            assertNotNull(download)
+            assertEquals(pendingDownloadId, download?.uuid)
+        }
+
+        @Test
+        fun get_by_path_searches_running_queue() {
+            // GIVEN
+            //      file download is running
+
+            // WHEN
+            //      download status is retrieved
+            val download = registry.getDownload(runningDownloadFile)
+
+            // THEN
+            //      download from pending queue is returned
+            assertNotNull(download)
+            assertEquals(runningDownloadId, download?.uuid)
+        }
+
+        @Test
+        fun get_by_id_searches_running_queue() {
+            // GIVEN
+            //      file download is running
+
+            // WHEN
+            //      download status is retrieved
+            val download = registry.getDownload(runningDownloadId)
+
+            // THEN
+            //      download from pending queue is returned
+            assertNotNull(download)
+            assertEquals(runningDownloadId, download?.uuid)
+        }
+
+        @Test
+        fun get_by_path_searches_completed_queue() {
+            // GIVEN
+            //      file download is pending
+
+            // WHEN
+            //      download status is retrieved
+            val download = registry.getDownload(completedDownloadFile)
+
+            // THEN
+            //      download from pending queue is returned
+            assertNotNull(download)
+            assertEquals(completedDownloadId, download?.uuid)
+        }
+
+        @Test
+        fun get_by_id_searches_completed_queue() {
+            // GIVEN
+            //      file download is pending
+
+            // WHEN
+            //      download status is retrieved
+            val download = registry.getDownload(completedDownloadId)
+
+            // THEN
+            //      download from pending queue is returned
+            assertNotNull(download)
+            assertEquals(completedDownloadId, download?.uuid)
+        }
+
+        @Test
+        fun not_found_by_path() {
+            // GIVEN
+            //      no download for a file
+            val nonExistingDownloadFile = OCFile("/non-nexisting/download")
+
+            // WHEN
+            //      download status is retrieved for a file
+            val download = registry.getDownload(nonExistingDownloadFile)
+
+            // THEN
+            //      no download is found
+            assertNull(download)
+        }
+
+        @Test
+        fun not_found_by_id() {
+            // GIVEN
+            //      no download for an id
+            val nonExistingId = UUID.randomUUID()
+
+            // WHEN
+            //      download status is retrieved for a file
+            val download = registry.getDownload(nonExistingId)
+
+            // THEN
+            //      no download is found
+            assertNull(download)
+        }
+    }
+
+    class IsRunning : Base() {
+
+        @Test
+        fun no_requests() {
+            // WHEN
+            //      all queues empty
+            assertEquals(0, registry.pending.size)
+            assertEquals(0, registry.running.size)
+            assertEquals(0, registry.completed.size)
+
+            // THEN
+            //      not running
+            assertFalse(registry.isRunning)
+        }
+
+        @Test
+        fun request_pending() {
+            // WHEN
+            //      request is enqueued
+            registry.add(Request(user, OCFile("/path/alpha/1")))
+            assertEquals(1, registry.pending.size)
+            assertEquals(0, registry.running.size)
+            assertEquals(0, registry.completed.size)
+
+            // THEN
+            //      is running
+            assertTrue(registry.isRunning)
+        }
+
+        @Test
+        fun request_running() {
+            // WHEN
+            //      request is running
+            registry.add(Request(user, OCFile("/path/alpha/1")))
+            registry.startNext()
+            assertEquals(0, registry.pending.size)
+            assertEquals(1, registry.running.size)
+            assertEquals(0, registry.completed.size)
+
+            // THEN
+            //      is running
+            assertTrue(registry.isRunning)
+        }
+
+        @Test
+        fun request_completed() {
+            // WHEN
+            //      request is running
+            val id = registry.add(Request(user, OCFile("/path/alpha/1")))
+            registry.startNext()
+            registry.complete(id, true)
+            assertEquals(0, registry.pending.size)
+            assertEquals(0, registry.running.size)
+            assertEquals(1, registry.completed.size)
+
+            // THEN
+            //      is not running
+            assertFalse(registry.isRunning)
+        }
+    }
+}

+ 1 - 0
src/main/AndroidManifest.xml

@@ -325,6 +325,7 @@
 
         <service android:name=".services.OperationsService" />
         <service android:name=".files.services.FileDownloader" />
+        <service android:name="com.nextcloud.client.files.downloader.DownloaderService" />
         <service android:name=".files.services.FileUploader" />
         <service android:name="com.nextcloud.client.media.PlayerService"/>
 

+ 80 - 0
src/main/java/com/nextcloud/client/account/MockUser.kt

@@ -0,0 +1,80 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz <hello@ezaquarii.com>
+ * Copyright (C) 2020 Chris Narkiewicz
+ * Copyright (C) 2020 Nextcloud GmbH
+ *
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.account
+
+import android.accounts.Account
+import android.net.Uri
+import android.os.Parcel
+import android.os.Parcelable
+import com.owncloud.android.MainApp
+import com.owncloud.android.lib.common.OwnCloudAccount
+import com.owncloud.android.lib.common.OwnCloudBasicCredentials
+import java.net.URI
+
+/**
+ * This is a mock user object suitable for integration tests. Mocks obtained from code generators
+ * such as Mockito or MockK cannot be transported in Intent extras.
+ */
+data class MockUser(override val accountName: String, val accountType: String) : User, Parcelable {
+
+    constructor() : this(DEFAULT_MOCK_ACCOUNT_NAME, DEFAULT_MOCK_ACCOUNT_TYPE)
+
+    companion object {
+        @JvmField
+        val CREATOR: Parcelable.Creator<MockUser> = object : Parcelable.Creator<MockUser> {
+            override fun createFromParcel(source: Parcel): MockUser = MockUser(source)
+            override fun newArray(size: Int): Array<MockUser?> = arrayOfNulls(size)
+        }
+        const val DEFAULT_MOCK_ACCOUNT_NAME = "mock_account_name"
+        const val DEFAULT_MOCK_ACCOUNT_TYPE = "mock_account_type"
+    }
+
+    private constructor(source: Parcel) : this(
+        source.readString() as String,
+        source.readString() as String
+    )
+
+    override val server = Server(URI.create(""), MainApp.MINIMUM_SUPPORTED_SERVER_VERSION)
+    override val isAnonymous = false
+
+    override fun toPlatformAccount(): Account {
+        return Account(accountName, accountType)
+    }
+
+    override fun toOwnCloudAccount(): OwnCloudAccount {
+        return OwnCloudAccount(Uri.EMPTY, OwnCloudBasicCredentials("", ""))
+    }
+
+    override fun nameEquals(user: User?): Boolean {
+        return user?.accountName.equals(accountName, true)
+    }
+
+    override fun nameEquals(accountName: CharSequence?): Boolean {
+        return accountName?.toString().equals(this.accountType, true)
+    }
+
+    override fun describeContents() = 0
+
+    override fun writeToParcel(dest: Parcel, flags: Int) = with(dest) {
+        writeString(accountName)
+        writeString(accountType)
+    }
+}

+ 37 - 7
src/main/java/com/nextcloud/client/core/AsyncRunner.kt

@@ -19,23 +19,53 @@
  */
 package com.nextcloud.client.core
 
-typealias OnResultCallback<T> = (T) -> Unit
-typealias OnErrorCallback = (Throwable) -> Unit
+typealias OnResultCallback<T> = (result: T) -> Unit
+typealias OnErrorCallback = (error: Throwable) -> Unit
+typealias OnProgressCallback<P> = (progress: P) -> Unit
+typealias IsCancelled = () -> Boolean
+typealias TaskFunction<RESULT, PROGRESS> = (
+    onProgress: OnProgressCallback<PROGRESS>,
+    isCancelled: IsCancelled
+) -> RESULT
 
 /**
  * This interface allows to post background tasks that report results via callbacks invoked on main thread.
- *
  * It is provided as an alternative for heavy, platform specific and virtually untestable [android.os.AsyncTask]
+ *
+ * Please note that as of Android R, [android.os.AsyncTask] is deprecated and [java.util.concurrent] is a recommended
+ * alternative.
  */
 interface AsyncRunner {
 
+    /**
+     * Post a quick background task and return immediately returning task cancellation interface.
+     *
+     * Quick task is a short piece of code that does not support interruption nor progress monitoring.
+     *
+     * @param task Task function returning result T; error shall be signalled by throwing an exception.
+     * @param onResult Callback called when task function returns a result.
+     * @param onError Callback called when task function throws an exception.
+     * @return Cancellable interface, allowing cancellation of a running task.
+     */
+    fun <RESULT> postQuickTask(
+        task: () -> RESULT,
+        onResult: OnResultCallback<RESULT>? = null,
+        onError: OnErrorCallback? = null
+    ): Cancellable
+
     /**
      * Post a background task and return immediately returning task cancellation interface.
      *
      * @param task Task function returning result T; error shall be signalled by throwing an exception.
-     * @param onResult Callback called when task function returns a result
-     * @param onError Callback called when task function throws an exception
-     * @return Cancellable interface, allowing to cancel running task.
+     * @param onResult Callback called when task function returns a result,
+     * @param onError Callback called when task function throws an exception.
+     * @param onProgress Callback called when task function reports progress update.
+     * @return Cancellable interface, allowing cancellation of a running task.
      */
-    fun <T> post(task: () -> T, onResult: OnResultCallback<T>? = null, onError: OnErrorCallback? = null): Cancellable
+    fun <RESULT, PROGRESS> postTask(
+        task: TaskFunction<RESULT, PROGRESS>,
+        onResult: OnResultCallback<RESULT>? = null,
+        onError: OnErrorCallback? = null,
+        onProgress: OnProgressCallback<PROGRESS>? = null
+    ): Cancellable
 }

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

@@ -31,8 +31,8 @@ package com.nextcloud.client.core
 interface Cancellable {
 
     /**
-     * Cancel running task. Task termination is not guaranteed, but the result
-     * shall not be delivered.
+     * Cancel running task. Task termination is not guaranteed, as some
+     * tasks cannot be interrupted, but the result will not be delivered.
      */
     fun cancel()
 }

+ 28 - 0
src/main/java/com/nextcloud/client/core/LocalBinder.kt

@@ -0,0 +1,28 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.app.Service
+import android.os.Binder
+
+/**
+ * This is a generic binder that provides access to a locally bound service instance.
+ */
+abstract class LocalBinder<S : Service>(val service: S) : Binder()

+ 105 - 0
src/main/java/com/nextcloud/client/core/LocalConnection.kt

@@ -0,0 +1,105 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.app.Service
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+
+/**
+ * This is a local service connection providing a foundation for service
+ * communication logic.
+ *
+ * One can subclass it to create own service interaction API.
+ */
+abstract class LocalConnection<S : Service>(
+    protected val context: Context
+) : ServiceConnection {
+
+    private var serviceBinder: LocalBinder<S>? = null
+    val service: S? get() = serviceBinder?.service
+    val isConnected: Boolean get() {
+        return serviceBinder != null
+    }
+
+    /**
+     * Override this method to create custom binding intent.
+     * Default implementation returns null, which disables binding.
+     *
+     * @see [bind]
+     */
+    protected open fun createBindIntent(): Intent? {
+        return null
+    }
+
+    /**
+     * Bind local service. If [createBindIntent] returns null, it no-ops.
+     */
+    fun bind() {
+        createBindIntent()?.let {
+            context.bindService(it, this, Context.BIND_AUTO_CREATE)
+        }
+    }
+
+    /**
+     * Unbind service if it is bound.
+     * If service is not bound, it no-ops.
+     */
+    fun unbind() {
+        if (isConnected) {
+            onUnbind()
+            context.unbindService(this)
+            serviceBinder = null
+        }
+    }
+
+    /**
+     * Callback called when connection binds to a service.
+     * Any actions taken on service connection can be taken here.
+     */
+    protected open fun onBound(binder: IBinder) {
+        // default no-op
+    }
+
+    /**
+     * Callback called when service is about to be unbound.
+     * Binder is still valid at this stage and can be used to
+     * perform cleanups. After exiting this method, service will
+     * no longer be available.
+     */
+    protected open fun onUnbind() {
+        // default no-op
+    }
+
+    final override fun onServiceConnected(name: ComponentName, binder: IBinder) {
+        if (binder !is LocalBinder<*>) {
+            throw IllegalStateException("Binder is not extending ${LocalBinder::class.java.name}")
+        }
+        serviceBinder = binder as LocalBinder<S>
+        onBound(binder)
+    }
+
+    final override fun onServiceDisconnected(name: ComponentName) {
+        serviceBinder = null
+    }
+}

+ 25 - 4
src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt

@@ -27,14 +27,35 @@ import java.util.ArrayDeque
  */
 class ManualAsyncRunner : AsyncRunner {
 
-    private val queue: ArrayDeque<Task<*>> = ArrayDeque()
+    private val queue: ArrayDeque<Runnable> = ArrayDeque()
 
-    override fun <T> post(task: () -> T, onResult: OnResultCallback<T>?, onError: OnErrorCallback?): Cancellable {
+    override fun <T> postQuickTask(
+        task: () -> T,
+        onResult: OnResultCallback<T>?,
+        onError: OnErrorCallback?
+    ): Cancellable {
+        return postTask(
+            task = { _: OnProgressCallback<Any>, _: IsCancelled -> task.invoke() },
+            onResult = onResult,
+            onError = onError,
+            onProgress = null
+        )
+    }
+
+    override fun <T, P> postTask(
+        task: TaskFunction<T, P>,
+        onResult: OnResultCallback<T>?,
+        onError: OnErrorCallback?,
+        onProgress: OnProgressCallback<P>?
+    ): Cancellable {
+        val remove: Function1<Runnable, Boolean> = queue::remove
         val taskWrapper = Task(
-            postResult = { it.run() },
+            postResult = { it.run(); true },
+            removeFromQueue = remove,
             taskBody = task,
             onSuccess = onResult,
-            onError = onError
+            onError = onError,
+            onProgress = onProgress
         )
         queue.push(taskWrapper)
         return taskWrapper

+ 20 - 9
src/main/java/com/nextcloud/client/core/Task.kt

@@ -22,23 +22,32 @@ 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.
+ * This is a wrapper for a task function runing in background.
+ * It executes task function and handles result or error delivery.
  */
-internal class Task<T>(
-    private val postResult: (Runnable) -> Unit,
-    private val taskBody: () -> T,
+@Suppress("LongParameterList")
+internal class Task<T, P>(
+    private val postResult: (Runnable) -> Boolean,
+    private val removeFromQueue: (Runnable) -> Boolean,
+    private val taskBody: TaskFunction<T, P>,
     private val onSuccess: OnResultCallback<T>?,
-    private val onError: OnErrorCallback?
+    private val onError: OnErrorCallback?,
+    private val onProgress: OnProgressCallback<P>?
 ) : Runnable, Cancellable {
 
+    val isCancelled: Boolean
+        get() = cancelled.get()
+
     private val cancelled = AtomicBoolean(false)
 
+    private fun postProgress(p: P) {
+        postResult(Runnable { onProgress?.invoke(p) })
+    }
+
+    @Suppress("TooGenericExceptionCaught") // this is exactly what we want here
     override fun run() {
-        @Suppress("TooGenericExceptionCaught") // this is exactly what we want here
         try {
-            val result = taskBody.invoke()
+            val result = taskBody.invoke({ postProgress(it) }, this::isCancelled)
             if (!cancelled.get()) {
                 postResult.invoke(
                     Runnable {
@@ -51,9 +60,11 @@ internal class Task<T>(
                 postResult(Runnable { onError?.invoke(t) })
             }
         }
+        removeFromQueue(this)
     }
 
     override fun cancel() {
         cancelled.set(true)
+        removeFromQueue(this)
     }
 }

+ 34 - 7
src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt

@@ -28,17 +28,44 @@ import java.util.concurrent.ScheduledThreadPoolExecutor
  *
  * 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 {
+internal class ThreadPoolAsyncRunner(
+    private val uiThreadHandler: Handler,
+    corePoolSize: Int,
+    val tag: String = "default"
+) : 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
+    override fun <T> postQuickTask(
+        task: () -> T,
+        onResult: OnResultCallback<T>?,
+        onError: OnErrorCallback?
+    ): Cancellable {
+        val taskAdapter = { _: OnProgressCallback<Void>, _: IsCancelled -> task.invoke() }
+        return postTask(
+            taskAdapter,
+            onResult,
+            onError,
+            null
+        )
     }
 
-    private fun postResult(r: Runnable) {
-        uiThreadHandler.post(r)
+    override fun <T, P> postTask(
+        task: TaskFunction<T, P>,
+        onResult: OnResultCallback<T>?,
+        onError: OnErrorCallback?,
+        onProgress: OnProgressCallback<P>?
+    ): Cancellable {
+        val remove: Function1<Runnable, Boolean> = executor::remove
+        val taskWrapper = Task(
+            postResult = uiThreadHandler::post,
+            removeFromQueue = remove,
+            taskBody = task,
+            onSuccess = onResult,
+            onError = onError,
+            onProgress = onProgress
+        )
+        executor.execute(taskWrapper)
+        return taskWrapper
     }
 }

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

@@ -49,6 +49,8 @@ import com.nextcloud.client.migrations.MigrationsDb;
 import com.nextcloud.client.migrations.MigrationsManager;
 import com.nextcloud.client.migrations.MigrationsManagerImpl;
 import com.nextcloud.client.network.ClientFactory;
+import com.nextcloud.client.notifications.AppNotificationManager;
+import com.nextcloud.client.notifications.AppNotificationManagerImpl;
 import com.owncloud.android.datamodel.ArbitraryDataProvider;
 import com.owncloud.android.datamodel.UploadsStorageManager;
 import com.owncloud.android.ui.activities.data.activities.ActivitiesRepository;
@@ -63,6 +65,7 @@ import org.greenrobot.eventbus.EventBus;
 
 import java.io.File;
 
+import javax.inject.Named;
 import javax.inject.Singleton;
 
 import dagger.Module;
@@ -164,9 +167,17 @@ class AppModule {
 
     @Provides
     @Singleton
-    AsyncRunner asyncRunner() {
+    AsyncRunner uiAsyncRunner() {
         Handler uiHandler = new Handler();
-        return new ThreadPoolAsyncRunner(uiHandler, 4);
+        return new ThreadPoolAsyncRunner(uiHandler, 4, "ui");
+    }
+
+    @Provides
+    @Singleton
+    @Named("io")
+    AsyncRunner ioAsyncRunner() {
+        Handler uiHandler = new Handler();
+        return new ThreadPoolAsyncRunner(uiHandler, 8, "io");
     }
 
     @Provides
@@ -200,4 +211,10 @@ class AppModule {
                                         Migrations migrations) {
         return new MigrationsManagerImpl(appInfo, migrationsDb, asyncRunner, migrations.getSteps());
     }
+
+    @Provides
+    @Singleton
+    AppNotificationManager notificationsManager(Context context, NotificationManager platformNotificationsManager) {
+        return new AppNotificationManagerImpl(context, context.getResources(), platformNotificationsManager);
+    }
 }

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

@@ -22,6 +22,7 @@ package com.nextcloud.client.di;
 
 import com.nextcloud.client.etm.EtmActivity;
 import com.nextcloud.client.jobs.NotificationWork;
+import com.nextcloud.client.files.downloader.DownloaderService;
 import com.nextcloud.client.logger.ui.LogsActivity;
 import com.nextcloud.client.media.PlayerService;
 import com.nextcloud.client.onboarding.FirstRunActivity;
@@ -168,4 +169,5 @@ abstract class ComponentsModule {
     @ContributesAndroidInjector abstract AccountManagerService accountManagerService();
     @ContributesAndroidInjector abstract OperationsService operationsService();
     @ContributesAndroidInjector abstract PlayerService playerService();
+    @ContributesAndroidInjector abstract DownloaderService fileDownloaderService();
 }

+ 14 - 0
src/main/java/com/nextcloud/client/etm/EtmViewModel.kt

@@ -21,15 +21,20 @@ package com.nextcloud.client.etm
 
 import android.accounts.Account
 import android.accounts.AccountManager
+import android.content.Context
 import android.content.SharedPreferences
 import android.content.res.Resources
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
+import com.nextcloud.client.account.User
+import com.nextcloud.client.account.UserAccountManager
 import com.nextcloud.client.etm.pages.EtmAccountsFragment
 import com.nextcloud.client.etm.pages.EtmBackgroundJobsFragment
+import com.nextcloud.client.etm.pages.EtmDownloaderFragment
 import com.nextcloud.client.etm.pages.EtmMigrations
 import com.nextcloud.client.etm.pages.EtmPreferencesFragment
+import com.nextcloud.client.files.downloader.DownloaderConnection
 import com.nextcloud.client.jobs.BackgroundJobManager
 import com.nextcloud.client.jobs.JobInfo
 import com.nextcloud.client.migrations.MigrationInfo
@@ -41,8 +46,10 @@ import javax.inject.Inject
 
 @Suppress("LongParameterList") // Dependencies Injection
 class EtmViewModel @Inject constructor(
+    private val context: Context,
     private val defaultPreferences: SharedPreferences,
     private val platformAccountManager: AccountManager,
+    private val accountManager: UserAccountManager,
     private val resources: Resources,
     private val backgroundJobManager: BackgroundJobManager,
     private val migrationsManager: MigrationsManager,
@@ -71,6 +78,7 @@ class EtmViewModel @Inject constructor(
      */
     data class AccountData(val account: Account, val userData: Map<String, String?>)
 
+    val currentUser: User get() = accountManager.user
     val currentPage: LiveData<EtmMenuEntry?> = MutableLiveData()
     val pages: List<EtmMenuEntry> = listOf(
         EtmMenuEntry(
@@ -92,8 +100,14 @@ class EtmViewModel @Inject constructor(
             iconRes = R.drawable.ic_arrow_up,
             titleRes = R.string.etm_migrations,
             pageClass = EtmMigrations::class
+        ),
+        EtmMenuEntry(
+            iconRes = R.drawable.ic_download_grey600,
+            titleRes = R.string.etm_downloader,
+            pageClass = EtmDownloaderFragment::class
         )
     )
+    val downloaderConnection = DownloaderConnection(context, accountManager.user)
 
     val preferences: Map<String, String> get() {
         return defaultPreferences.all

+ 133 - 0
src/main/java/com/nextcloud/client/etm/pages/EtmDownloaderFragment.kt

@@ -0,0 +1,133 @@
+package com.nextcloud.client.etm.pages
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.nextcloud.client.etm.EtmBaseFragment
+import com.nextcloud.client.files.downloader.Download
+import com.nextcloud.client.files.downloader.Downloader
+import com.nextcloud.client.files.downloader.Request
+import com.owncloud.android.R
+import com.owncloud.android.datamodel.OCFile
+
+class EtmDownloaderFragment : EtmBaseFragment() {
+
+    companion object {
+        private const val TEST_DOWNLOAD_DUMMY_PATH = "/test/dummy_file.txt"
+    }
+
+    class Adapter(private val inflater: LayoutInflater) : RecyclerView.Adapter<Adapter.ViewHolder>() {
+
+        class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+            val uuid = view.findViewById<TextView>(R.id.etm_download_uuid)
+            val path = view.findViewById<TextView>(R.id.etm_download_path)
+            val user = view.findViewById<TextView>(R.id.etm_download_user)
+            val state = view.findViewById<TextView>(R.id.etm_download_state)
+            val progress = view.findViewById<TextView>(R.id.etm_download_progress)
+            private val progressRow = view.findViewById<View>(R.id.etm_download_progress_row)
+
+            var progressEnabled: Boolean = progressRow.visibility == View.VISIBLE
+                get() {
+                    return progressRow.visibility == View.VISIBLE
+                }
+                set(value) {
+                    field = value
+                    progressRow.visibility = if (value) {
+                        View.VISIBLE
+                    } else {
+                        View.GONE
+                    }
+                }
+        }
+
+        private var downloads = listOf<Download>()
+
+        fun setStatus(status: Downloader.Status) {
+            downloads = listOf(status.pending, status.running, status.completed).flatten().reversed()
+            notifyDataSetChanged()
+        }
+
+        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+            val view = inflater.inflate(R.layout.etm_download_list_item, parent, false)
+            return ViewHolder(view)
+        }
+
+        override fun getItemCount(): Int {
+            return downloads.size
+        }
+
+        override fun onBindViewHolder(vh: ViewHolder, position: Int) {
+            val download = downloads[position]
+            vh.uuid.text = download.uuid.toString()
+            vh.path.text = download.request.file.remotePath
+            vh.user.text = download.request.user.accountName
+            vh.state.text = download.state.toString()
+            if (download.progress >= 0) {
+                vh.progressEnabled = true
+                vh.progress.text = download.progress.toString()
+            } else {
+                vh.progressEnabled = false
+            }
+        }
+    }
+
+    private lateinit var adapter: Adapter
+    private lateinit var list: RecyclerView
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setHasOptionsMenu(true)
+    }
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+        val view = inflater.inflate(R.layout.fragment_etm_downloader, container, false)
+        adapter = Adapter(inflater)
+        list = view.findViewById(R.id.etm_download_list)
+        list.layoutManager = LinearLayoutManager(context)
+        list.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
+        list.adapter = adapter
+        return view
+    }
+
+    override fun onResume() {
+        super.onResume()
+        vm.downloaderConnection.bind()
+        vm.downloaderConnection.registerStatusListener(this::onDownloaderStatusChanged)
+    }
+
+    override fun onPause() {
+        super.onPause()
+        vm.downloaderConnection.unbind()
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+        super.onCreateOptionsMenu(menu, inflater)
+        inflater.inflate(R.menu.fragment_etm_downloader, menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        return when (item.itemId) {
+            R.id.etm_test_download -> {
+                scheduleTestDownload(); true
+            }
+            else -> super.onOptionsItemSelected(item)
+        }
+    }
+
+    private fun scheduleTestDownload() {
+        val request = Request(user = vm.currentUser, file = OCFile(TEST_DOWNLOAD_DUMMY_PATH), test = true)
+        vm.downloaderConnection.download(request)
+    }
+
+    private fun onDownloaderStatusChanged(status: Downloader.Status) {
+        adapter.setStatus(status)
+    }
+}

+ 49 - 0
src/main/java/com/nextcloud/client/files/downloader/Download.kt

@@ -0,0 +1,49 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+import com.owncloud.android.datamodel.OCFile
+import java.util.UUID
+
+/**
+ * This class represents current download process state.
+ * This object is immutable by design.
+ *
+ * NOTE: Although [OCFile] object is mutable, it is caused by shortcomings
+ * of legacy design; please behave like an adult and treat it as immutable value.
+ *
+ * @property uuid Unique download process id
+ * @property state current download state
+ * @property progress download progress, 0-100 percent
+ * @property file downloaded file, if download is in progress or failed, it is remote; if finished successfully - local
+ * @property request initial download request
+ */
+data class Download(
+    val uuid: UUID,
+    val state: DownloadState,
+    val progress: Int,
+    val file: OCFile,
+    val request: Request
+) {
+    /**
+     * True if download is no longer running, false if it is still being processed.
+     */
+    val isFinished: Boolean get() = state == DownloadState.COMPLETED || state == DownloadState.FAILED
+}

+ 27 - 0
src/main/java/com/nextcloud/client/files/downloader/DownloadState.kt

@@ -0,0 +1,27 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+enum class DownloadState {
+    PENDING,
+    RUNNING,
+    COMPLETED,
+    FAILED
+}

+ 100 - 0
src/main/java/com/nextcloud/client/files/downloader/DownloadTask.kt

@@ -0,0 +1,100 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+import android.content.ContentResolver
+import android.content.Context
+import com.nextcloud.client.core.IsCancelled
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.operations.DownloadFileOperation
+import com.owncloud.android.utils.MimeTypeUtil
+import java.io.File
+
+/**
+ * This runnable object encapsulates file download logic. It has been extracted to wrap
+ * network operation and storage manager interactions, as those pose testing challenges
+ * that cannot be addressed due to large number of dependencies.
+ *
+ * This design can be regarded as intermediary refactoring step.
+ */
+class DownloadTask(
+    val context: Context,
+    val contentResolver: ContentResolver,
+    val clientProvider: () -> OwnCloudClient
+) {
+
+    data class Result(val file: OCFile, val success: Boolean)
+
+    /**
+     * This class is a helper factory to to keep static dependencies
+     * injection out of the downloader instance.
+     *
+     * @param context Context
+     * @param clientProvider Provide client - this must be called on background thread
+     * @param contentResolver content resovler used to access file storage
+     */
+    class Factory(
+        private val context: Context,
+        private val clientProvider: () -> OwnCloudClient,
+        private val contentResolver: ContentResolver
+    ) {
+        fun create(): DownloadTask {
+            return DownloadTask(context, contentResolver, clientProvider)
+        }
+    }
+
+    fun download(request: Request, progress: (Int) -> Unit, isCancelled: IsCancelled): Result {
+        val op = DownloadFileOperation(request.user.toPlatformAccount(), request.file, context)
+        val client = clientProvider.invoke()
+        val result = op.execute(client)
+        if (result.isSuccess) {
+            val storageManager = FileDataStorageManager(
+                request.user.toPlatformAccount(),
+                contentResolver
+            )
+            val file = saveDownloadedFile(op, storageManager)
+            return Result(file, true)
+        } else {
+            return Result(request.file, false)
+        }
+    }
+
+    private fun saveDownloadedFile(op: DownloadFileOperation, storageManager: FileDataStorageManager): OCFile {
+        val file = storageManager.getFileById(op.getFile().getFileId()) as OCFile
+        val syncDate = System.currentTimeMillis()
+        file.lastSyncDateForProperties = syncDate
+        file.lastSyncDateForData = syncDate
+        file.isUpdateThumbnailNeeded = true
+        file.modificationTimestamp = op.getModificationTimestamp()
+        file.modificationTimestampAtLastSyncForData = op.getModificationTimestamp()
+        file.etag = op.getEtag()
+        file.mimeType = op.getMimeType()
+        file.storagePath = op.getSavePath()
+        file.fileLength = File(op.getSavePath()).length()
+        file.remoteId = op.getFile().getRemoteId()
+        storageManager.saveFile(file)
+        if (MimeTypeUtil.isMedia(op.getMimeType())) {
+            FileDataStorageManager.triggerMediaScan(file.storagePath)
+        }
+        return file
+    }
+}

+ 98 - 0
src/main/java/com/nextcloud/client/files/downloader/Downloader.kt

@@ -0,0 +1,98 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+import com.owncloud.android.datamodel.OCFile
+import java.util.UUID
+
+interface Downloader {
+
+    /**
+     * Snapshot of downloader status. All data is immutable and can be safely shared.
+     */
+    data class Status(
+        val pending: List<Download>,
+        val running: List<Download>,
+        val completed: List<Download>
+    ) {
+        companion object {
+            val EMPTY = Status(emptyList(), emptyList(), emptyList())
+        }
+    }
+
+    /**
+     * True if downloader has any pending or running downloads.
+     */
+    val isRunning: Boolean
+
+    /**
+     * Status snapshot of all downloads.
+     */
+    val status: Status
+
+    /**
+     * Register download progress listener. Registration is idempotent - listener can be registered only once.
+     */
+    fun registerDownloadListener(listener: (Download) -> Unit)
+
+    /**
+     * Removes registered listener if exists.
+     */
+    fun removeDownloadListener(listener: (Download) -> Unit)
+
+    /**
+     * Register downloader status listener. Registration is idempotent - listener can be registered only once.
+     */
+    fun registerStatusListener(listener: (Status) -> Unit)
+
+    /**
+     * Removes registered listener if exists.
+     */
+    fun removeStatusListener(listener: (Status) -> Unit)
+
+    /**
+     * Adds download request to pending queue and returns immediately.
+     *
+     * @param request Download request
+     */
+    fun download(request: Request)
+
+    /**
+     * Find download status by UUID.
+     *
+     * @param uuid Download process uuid
+     * @return download status or null if not found
+     */
+    fun getDownload(uuid: UUID): Download?
+
+    /**
+     * Query user's downloader for a download status. It performs linear search
+     * of all queues and returns first download matching [OCFile.remotePath].
+     *
+     * Since there can be multiple downloads with identical file in downloader's queues,
+     * order of search matters.
+     *
+     * It looks for pending downloads first, then running and completed queue last.
+     *
+     * @param file Downloaded file
+     * @return download status or null, if download does not exist
+     */
+    fun getDownload(file: OCFile): Download?
+}

+ 122 - 0
src/main/java/com/nextcloud/client/files/downloader/DownloaderConnection.kt

@@ -0,0 +1,122 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+import com.nextcloud.client.account.User
+import com.nextcloud.client.core.LocalConnection
+import com.owncloud.android.datamodel.OCFile
+import java.util.UUID
+
+class DownloaderConnection(context: Context, val user: User) : LocalConnection<DownloaderService>(context), Downloader {
+
+    private var downloadListeners: MutableSet<(Download) -> Unit> = mutableSetOf()
+    private var statusListeners: MutableSet<(Downloader.Status) -> Unit> = mutableSetOf()
+    private var binder: DownloaderService.Binder? = null
+    private val downloadsRequiringStatusRedelivery: MutableSet<UUID> = mutableSetOf()
+
+    override val isRunning: Boolean
+        get() = binder?.isRunning ?: false
+
+    override val status: Downloader.Status
+        get() = binder?.status ?: Downloader.Status.EMPTY
+
+    override fun getDownload(uuid: UUID): Download? = binder?.getDownload(uuid)
+
+    override fun getDownload(file: OCFile): Download? = binder?.getDownload(file)
+
+    override fun download(request: Request) {
+        val intent = DownloaderService.createDownloadIntent(context, request)
+        context.startService(intent)
+        if (!isConnected && downloadListeners.size > 0) {
+            downloadsRequiringStatusRedelivery.add(request.uuid)
+        }
+    }
+
+    override fun registerDownloadListener(listener: (Download) -> Unit) {
+        downloadListeners.add(listener)
+        binder?.registerDownloadListener(listener)
+    }
+
+    override fun removeDownloadListener(listener: (Download) -> Unit) {
+        downloadListeners.remove(listener)
+        binder?.removeDownloadListener(listener)
+    }
+
+    override fun registerStatusListener(listener: (Downloader.Status) -> Unit) {
+        statusListeners.add(listener)
+        binder?.registerStatusListener(listener)
+    }
+
+    override fun removeStatusListener(listener: (Downloader.Status) -> Unit) {
+        statusListeners.remove(listener)
+        binder?.removeStatusListener(listener)
+    }
+
+    override fun createBindIntent(): Intent {
+        return DownloaderService.createBindIntent(context, user)
+    }
+
+    override fun onBound(binder: IBinder) {
+        super.onBound(binder)
+        this.binder = binder as DownloaderService.Binder
+        downloadListeners.forEach { listener ->
+            binder.registerDownloadListener(listener)
+        }
+        statusListeners.forEach { listener ->
+            binder.registerStatusListener(listener)
+        }
+        deliverMissedUpdates()
+    }
+
+    /**
+     * Since binding and download start are both asynchronous and the order
+     * is not guaranteed, some downloads might already finish when service is bound,
+     * resulting in lost notifications.
+     *
+     * Deliver all updates for pending downloads that were scheduled
+     * before service has been bound.
+     */
+    private fun deliverMissedUpdates() {
+        val downloadUpdates = downloadsRequiringStatusRedelivery.mapNotNull { uuid ->
+            binder?.getDownload(uuid)
+        }
+        downloadListeners.forEach { listener ->
+            downloadUpdates.forEach { update ->
+                listener.invoke(update)
+            }
+        }
+        downloadsRequiringStatusRedelivery.clear()
+
+        if (statusListeners.isNotEmpty()) {
+            binder?.status?.let { status ->
+                statusListeners.forEach { it.invoke(status) }
+            }
+        }
+    }
+
+    override fun onUnbind() {
+        super.onUnbind()
+        downloadListeners.forEach { binder?.removeDownloadListener(it) }
+        statusListeners.forEach { binder?.removeStatusListener(it) }
+    }
+}

+ 144 - 0
src/main/java/com/nextcloud/client/files/downloader/DownloaderImpl.kt

@@ -0,0 +1,144 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+import com.nextcloud.client.core.AsyncRunner
+import com.nextcloud.client.core.IsCancelled
+import com.nextcloud.client.core.OnProgressCallback
+import com.nextcloud.client.core.TaskFunction
+import com.owncloud.android.datamodel.OCFile
+import java.util.UUID
+
+/**
+ * Per-user file downloader.
+ *
+ * All notifications are performed on main thread. All download processes are run
+ * in the background.
+ *
+ * @param runner Background task runner. It is important to provide runner that is not shared with UI code.
+ * @param taskFactory Download task factory
+ * @param threads maximum number of concurrent download processes
+ */
+@Suppress("LongParameterList") // download operations requires those resources
+class DownloaderImpl(
+    private val runner: AsyncRunner,
+    private val taskFactory: DownloadTask.Factory,
+    threads: Int = 1
+) : Downloader {
+
+    companion object {
+        const val PROGRESS_PERCENTAGE_MAX = 100
+        const val PROGRESS_PERCENTAGE_MIN = 0
+        const val TEST_DOWNLOAD_PROGRESS_UPDATE_PERIOD_MS = 200L
+    }
+
+    private val registry = Registry(
+        onStartDownload = this::onStartDownload,
+        onDownloadChanged = this::onDownloadUpdate,
+        maxRunning = threads
+    )
+    private val downloadListeners: MutableSet<(Download) -> Unit> = mutableSetOf()
+    private val statusListeners: MutableSet<(Downloader.Status) -> Unit> = mutableSetOf()
+
+    override val isRunning: Boolean get() = registry.isRunning
+
+    override val status: Downloader.Status
+        get() = Downloader.Status(
+            pending = registry.pending,
+            running = registry.running,
+            completed = registry.completed
+        )
+
+    override fun registerDownloadListener(listener: (Download) -> Unit) {
+        downloadListeners.add(listener)
+    }
+
+    override fun removeDownloadListener(listener: (Download) -> Unit) {
+        downloadListeners.remove(listener)
+    }
+
+    override fun registerStatusListener(listener: (Downloader.Status) -> Unit) {
+        statusListeners.add(listener)
+    }
+
+    override fun removeStatusListener(listener: (Downloader.Status) -> Unit) {
+        statusListeners.remove(listener)
+    }
+
+    override fun download(request: Request) {
+        registry.add(request)
+        registry.startNext()
+    }
+
+    override fun getDownload(uuid: UUID): Download? = registry.getDownload(uuid)
+
+    override fun getDownload(file: OCFile): Download? = registry.getDownload(file)
+
+    private fun onStartDownload(uuid: UUID, request: Request) {
+        val downloadTask = createDownloadTask(request)
+        runner.postTask(
+            task = downloadTask,
+            onProgress = { progress: Int -> registry.progress(uuid, progress) },
+            onResult = { result -> registry.complete(uuid, result.success, result.file); registry.startNext() },
+            onError = { registry.complete(uuid, false); registry.startNext() }
+        )
+    }
+
+    private fun createDownloadTask(request: Request): TaskFunction<DownloadTask.Result, Int> {
+        return if (request.test) {
+            { progress: OnProgressCallback<Int>, isCancelled: IsCancelled ->
+                testDownloadTask(request.file, progress, isCancelled)
+            }
+        } else {
+            val downloadTask = taskFactory.create()
+            val wrapper: TaskFunction<DownloadTask.Result, Int> = { progress: ((Int) -> Unit), isCancelled ->
+                downloadTask.download(request, progress, isCancelled)
+            }
+            wrapper
+        }
+    }
+
+    private fun onDownloadUpdate(download: Download) {
+        downloadListeners.forEach { it.invoke(download) }
+        if (statusListeners.isNotEmpty()) {
+            val status = this.status
+            statusListeners.forEach { it.invoke(status) }
+        }
+    }
+
+    /**
+     *  Test download task is used only to simulate download process without
+     *  any network traffic. It is used for development.
+     */
+    private fun testDownloadTask(
+        file: OCFile,
+        onProgress: OnProgressCallback<Int>,
+        isCancelled: IsCancelled
+    ): DownloadTask.Result {
+        for (i in PROGRESS_PERCENTAGE_MIN..PROGRESS_PERCENTAGE_MAX) {
+            onProgress(i)
+            if (isCancelled()) {
+                return DownloadTask.Result(file, false)
+            }
+            Thread.sleep(TEST_DOWNLOAD_PROGRESS_UPDATE_PERIOD_MS)
+        }
+        return DownloadTask.Result(file, true)
+    }
+}

+ 155 - 0
src/main/java/com/nextcloud/client/files/downloader/DownloaderService.kt

@@ -0,0 +1,155 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+import com.nextcloud.client.account.User
+import com.nextcloud.client.core.AsyncRunner
+import com.nextcloud.client.core.LocalBinder
+import com.nextcloud.client.logger.Logger
+import com.nextcloud.client.network.ClientFactory
+import com.nextcloud.client.notifications.AppNotificationManager
+import dagger.android.AndroidInjection
+import javax.inject.Inject
+import javax.inject.Named
+
+class DownloaderService : Service() {
+
+    companion object {
+        const val TAG = "DownloaderService"
+        const val ACTION_DOWNLOAD = "download"
+        const val EXTRA_REQUEST = "request"
+        const val EXTRA_USER = "user"
+
+        fun createBindIntent(context: Context, user: User): Intent {
+            return Intent(context, DownloaderService::class.java).apply {
+                putExtra(EXTRA_USER, user)
+            }
+        }
+
+        fun createDownloadIntent(context: Context, request: Request): Intent {
+            return Intent(context, DownloaderService::class.java).apply {
+                action = ACTION_DOWNLOAD
+                putExtra(EXTRA_REQUEST, request)
+            }
+        }
+    }
+
+    /**
+     * Binder forwards [Downloader] API calls to selected instance of downloader.
+     */
+    class Binder(
+        downloader: DownloaderImpl,
+        service: DownloaderService
+    ) : LocalBinder<DownloaderService>(service),
+        Downloader by downloader
+
+    @Inject
+    lateinit var notificationsManager: AppNotificationManager
+
+    @Inject
+    lateinit var clientFactory: ClientFactory
+
+    @Inject
+    @Named("io")
+    lateinit var runner: AsyncRunner
+
+    @Inject
+    lateinit var logger: Logger
+
+    val isRunning: Boolean get() = downloaders.any { it.value.isRunning }
+
+    private val downloaders: MutableMap<String, DownloaderImpl> = mutableMapOf()
+
+    override fun onCreate() {
+        AndroidInjection.inject(this)
+    }
+
+    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
+        if (intent.action != ACTION_DOWNLOAD) {
+            return START_NOT_STICKY
+        }
+
+        if (!isRunning) {
+            startForeground(
+                AppNotificationManager.DOWNLOAD_NOTIFICATION_ID,
+                notificationsManager.buildDownloadServiceForegroundNotification()
+            )
+        }
+
+        val request = intent.getParcelableExtra(EXTRA_REQUEST) as Request
+        val downloader = getDownloader(request.user)
+        downloader.download(request)
+
+        logger.d(TAG, "Enqueued new download: ${request.uuid} ${request.file.remotePath}")
+
+        return START_NOT_STICKY
+    }
+
+    override fun onBind(intent: Intent?): IBinder? {
+        val user = intent?.getParcelableExtra<User>(EXTRA_USER)
+        if (user != null) {
+            return Binder(getDownloader(user), this)
+        } else {
+            return null
+        }
+    }
+
+    private fun onDownloadUpdate(download: Download) {
+        if (!isRunning) {
+            logger.d(TAG, "All downloads completed")
+            notificationsManager.cancelDownloadProgress()
+            stopForeground(true)
+            stopSelf()
+        } else {
+            notificationsManager.postDownloadProgress(
+                fileOwner = download.request.user,
+                file = download.request.file,
+                progress = download.progress,
+                allowPreview = !download.request.test
+            )
+        }
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        logger.d(TAG, "Stopping downloader service")
+    }
+
+    private fun getDownloader(user: User): DownloaderImpl {
+        val existingDownloader = downloaders[user.accountName]
+        return if (existingDownloader != null) {
+            existingDownloader
+        } else {
+            val downloadTaskFactory = DownloadTask.Factory(
+                applicationContext,
+                { clientFactory.create(user) },
+                contentResolver
+            )
+            val newDownloader = DownloaderImpl(runner, downloadTaskFactory)
+            newDownloader.registerDownloadListener(this::onDownloadUpdate)
+            downloaders[user.accountName] = newDownloader
+            newDownloader
+        }
+    }
+}

+ 162 - 0
src/main/java/com/nextcloud/client/files/downloader/Registry.kt

@@ -0,0 +1,162 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+import com.owncloud.android.datamodel.OCFile
+import java.lang.IllegalStateException
+import java.util.TreeMap
+import java.util.UUID
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * This class tracks status of all downloads. It serves as a state
+ * machine and drives the download background task scheduler via callbacks.
+ * Download status updates are triggering change callbacks that should be used
+ * to notify listeners.
+ *
+ * No listener registration mechanism is provided at this level.
+ *
+ * This class is not thread-safe. All access from multiple threads shall
+ * be lock protected.
+ *
+ * @property onStartDownload callback triggered when download is switched into running state
+ * @property onDownloadChanged callback triggered whenever download status update
+ * @property maxRunning maximum number of allowed simultaneous downloads
+ */
+internal class Registry(
+    private val onStartDownload: (UUID, Request) -> Unit,
+    private val onDownloadChanged: (Download) -> Unit,
+    private val maxRunning: Int = 2
+) {
+    private val pendingQueue = TreeMap<UUID, Download>()
+    private val runningQueue = TreeMap<UUID, Download>()
+    private val completedQueue = TreeMap<UUID, Download>()
+
+    val isRunning: Boolean get() = pendingQueue.size > 0 || runningQueue.size > 0
+
+    val pending: List<Download> get() = pendingQueue.map { it.value }
+    val running: List<Download> get() = runningQueue.map { it.value }
+    val completed: List<Download> get() = completedQueue.map { it.value }
+
+    /**
+     * Insert new download into a pending queue.
+     *
+     * @return scheduled download id
+     */
+    fun add(request: Request): UUID {
+        val download = Download(
+            uuid = request.uuid,
+            state = DownloadState.PENDING,
+            progress = 0,
+            file = request.file,
+            request = request
+        )
+        pendingQueue[download.uuid] = download
+        return download.uuid
+    }
+
+    /**
+     * Move pending downloads into a running queue up
+     * to max allowed simultaneous downloads.
+     */
+    fun startNext() {
+        val freeThreads = max(0, maxRunning - runningQueue.size)
+        for (i in 0 until min(freeThreads, pendingQueue.size)) {
+            val key = pendingQueue.firstKey()
+            val pendingDownload = pendingQueue.remove(key) ?: throw IllegalStateException("Download $key not exist.")
+            val runningDownload = pendingDownload.copy(state = DownloadState.RUNNING)
+            runningQueue[key] = runningDownload
+            onStartDownload.invoke(key, runningDownload.request)
+            onDownloadChanged(runningDownload)
+        }
+    }
+
+    /**
+     * Update progress for a given download. If no download of a given id is currently running,
+     * update is ignored.
+     *
+     * @param uuid ID of the download to update
+     * @param progress progress 0-100%
+     */
+    fun progress(uuid: UUID, progress: Int) {
+        val download = runningQueue[uuid]
+        if (download != null) {
+            val runningDownload = download.copy(progress = progress)
+            runningQueue[uuid] = runningDownload
+            onDownloadChanged(runningDownload)
+        }
+    }
+
+    /**
+     * Complete currently running download. If no download of a given id is currently running,
+     * update is ignored.
+     *
+     * @param uuid of the download to complete
+     * @param success if true, download will be marked as completed; if false - as failed
+     * @param file if provided, update file in download status; if null, existing value is retained
+     */
+    fun complete(uuid: UUID, success: Boolean, file: OCFile? = null) {
+        val download = runningQueue.remove(uuid)
+        if (download != null) {
+            val status = if (success) {
+                DownloadState.COMPLETED
+            } else {
+                DownloadState.FAILED
+            }
+            val completedDownload = download.copy(state = status, file = file ?: download.file)
+            completedQueue[uuid] = completedDownload
+            onDownloadChanged(completedDownload)
+        }
+    }
+
+    /**
+     * Search for a download by file path. It traverses
+     * through all queues in order of pending, running and completed
+     * downloads and returns first download status matching
+     * file path.
+     *
+     * @param file Search for a file download
+     * @return download status if found, null otherwise
+     */
+    fun getDownload(file: OCFile): Download? {
+        arrayOf(pendingQueue, runningQueue, completedQueue).forEach { queue ->
+            queue.forEach { entry ->
+                if (entry.value.request.file.remotePath == file.remotePath) {
+                    return entry.value
+                }
+            }
+        }
+        return null
+    }
+
+    /**
+     * Get download status by id. It traverses
+     * through all queues in order of pending, running and completed
+     * downloads and returns first download status matching
+     * file path.
+     *
+     * @param id download id
+     * @return download status if found, null otherwise
+     */
+    fun getDownload(uuid: UUID): Download? {
+        return pendingQueue[uuid] ?: runningQueue[uuid] ?: completedQueue[uuid]
+    }
+}

+ 79 - 0
src/main/java/com/nextcloud/client/files/downloader/Request.kt

@@ -0,0 +1,79 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 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.files.downloader
+
+import android.os.Parcel
+import android.os.Parcelable
+import com.nextcloud.client.account.User
+import com.owncloud.android.datamodel.OCFile
+import java.util.UUID
+
+/**
+ * Download request. This class should collect all information
+ * required to trigger download operation.
+ *
+ * Class is immutable by design, although [OCFile] or other
+ * types might not be immutable. Clients should no modify
+ * contents of this object.
+ *
+ * @property user Download will be triggered for a given user
+ * @property file Remote file to download
+ * @property uuid Unique request identifier; this identifier can be set in [Download]
+ * @property dummy if true, this requests a dummy test download; no real file transfer will occur
+ */
+class Request internal constructor(
+    val user: User,
+    val file: OCFile,
+    val uuid: UUID,
+    val test: Boolean = false
+) : Parcelable {
+
+    constructor(user: User, file: OCFile) : this(user, file, UUID.randomUUID())
+
+    constructor(user: User, file: OCFile, test: Boolean) : this(user, file, UUID.randomUUID(), test)
+
+    constructor(parcel: Parcel) : this(
+        user = parcel.readParcelable<User>(User::class.java.classLoader) as User,
+        file = parcel.readParcelable<OCFile>(OCFile::class.java.classLoader) as OCFile,
+        uuid = parcel.readSerializable() as UUID,
+        test = parcel.readInt() != 0
+    )
+
+    override fun writeToParcel(parcel: Parcel, flags: Int) {
+        parcel.writeParcelable(user, flags)
+        parcel.writeParcelable(file, flags)
+        parcel.writeSerializable(uuid)
+        parcel.writeInt(if (test) 1 else 0)
+    }
+
+    override fun describeContents(): Int {
+        return 0
+    }
+
+    companion object CREATOR : Parcelable.Creator<Request> {
+        override fun createFromParcel(parcel: Parcel): Request {
+            return Request(parcel)
+        }
+
+        override fun newArray(size: Int): Array<Request?> {
+            return arrayOfNulls(size)
+        }
+    }
+}

+ 2 - 4
src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt

@@ -62,10 +62,8 @@ class AsyncFilter(private val asyncRunner: AsyncRunner, private val time: () ->
 
     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) }
-            },
+        filterTask = asyncRunner.postQuickTask(
+            task = { collection.filter { predicate.invoke(it) } },
             onResult = { filtered: List<T> ->
                 onFilterCompleted(filtered, onResult)
             }

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

@@ -65,7 +65,7 @@ class LogsEmailSender(private val context: Context, private val clock: Clock, pr
     fun send(logs: List<LogEntry>) {
         if (task == null) {
             val outFile = File(context.cacheDir, "attachments/logs.txt")
-            task = runner.post(Task(context, logs, outFile, clock.tz), onResult = { task = null; send(it) })
+            task = runner.postQuickTask(Task(context, logs, outFile, clock.tz), onResult = { task = null; send(it) })
         }
     }
 

+ 1 - 1
src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt

@@ -61,7 +61,7 @@ internal class MigrationsManagerImpl(
             return 0
         }
         (status as MutableLiveData<Status>).value = Status.RUNNING
-        asyncRunner.post(
+        asyncRunner.postQuickTask(
             task = { asyncApplyMigrations(toApply) },
             onResult = { onMigrationSuccess() },
             onError = { onMigrationFailed(it) }

+ 38 - 0
src/main/java/com/nextcloud/client/notifications/AppNotificationManager.kt

@@ -0,0 +1,38 @@
+package com.nextcloud.client.notifications
+
+import android.app.Notification
+import com.nextcloud.client.account.User
+import com.owncloud.android.datamodel.OCFile
+
+/**
+ * Application-specific notification manager interface.
+ * Contrary to the platform [android.app.NotificationManager],
+ * it offer high-level, use-case oriented API.
+ */
+interface AppNotificationManager {
+
+    companion object {
+        const val DOWNLOAD_NOTIFICATION_ID = 1_000_000
+    }
+
+    /**
+     * Builds notification to be set when downloader starts in foreground.
+     *
+     * @return foreground downloader service notification
+     */
+    fun buildDownloadServiceForegroundNotification(): Notification
+
+    /**
+     * Post download progress notification.
+     * @param fileOwner User owning the downloaded file
+     * @param file File being downloaded
+     * @param progress Progress as percentage (0-100)
+     * @param allowPreview if true, pending intent with preview action is added to the notification
+     */
+    fun postDownloadProgress(fileOwner: User, file: OCFile, progress: Int, allowPreview: Boolean = true)
+
+    /**
+     * Removes download progress notification.
+     */
+    fun cancelDownloadProgress()
+}

+ 88 - 0
src/main/java/com/nextcloud/client/notifications/AppNotificationManagerImpl.kt

@@ -0,0 +1,88 @@
+package com.nextcloud.client.notifications
+
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.res.Resources
+import android.graphics.BitmapFactory
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import com.nextcloud.client.account.User
+import com.owncloud.android.R
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.ui.activity.FileDisplayActivity
+import com.owncloud.android.ui.notifications.NotificationUtils
+import com.owncloud.android.ui.preview.PreviewImageActivity
+import com.owncloud.android.ui.preview.PreviewImageFragment
+import com.owncloud.android.utils.ThemeUtils
+import javax.inject.Inject
+
+class AppNotificationManagerImpl @Inject constructor(
+    private val context: Context,
+    private val resources: Resources,
+    private val platformNotificationsManager: NotificationManager
+) : AppNotificationManager {
+
+    companion object {
+        const val PROGRESS_PERCENTAGE_MAX = 100
+        const val PROGRESS_PERCENTAGE_MIN = 0
+    }
+
+    private fun builder(channelId: String): NotificationCompat.Builder {
+        val color = ThemeUtils.primaryColor(context, true)
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            NotificationCompat.Builder(context, channelId).setColor(color)
+        } else {
+            NotificationCompat.Builder(context).setColor(color)
+        }
+    }
+
+    override fun buildDownloadServiceForegroundNotification(): Notification {
+        val icon = BitmapFactory.decodeResource(resources, R.drawable.notification_icon)
+        return builder(NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD)
+            .setContentTitle(resources.getString(R.string.app_name))
+            .setContentText(resources.getString(R.string.foreground_service_download))
+            .setSmallIcon(R.drawable.notification_icon)
+            .setLargeIcon(icon)
+            .build()
+    }
+
+    override fun postDownloadProgress(fileOwner: User, file: OCFile, progress: Int, allowPreview: Boolean) {
+        val builder = builder(NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD)
+        val content = resources.getString(
+            R.string.downloader_download_in_progress_content,
+            progress,
+            file.fileName
+        )
+        builder
+            .setSmallIcon(R.drawable.notification_icon)
+            .setTicker(resources.getString(R.string.downloader_download_in_progress_ticker))
+            .setContentTitle(resources.getString(R.string.downloader_download_in_progress_ticker))
+            .setOngoing(true)
+            .setProgress(PROGRESS_PERCENTAGE_MAX, progress, progress <= PROGRESS_PERCENTAGE_MIN)
+            .setContentText(content)
+
+        if (allowPreview) {
+            val openFileIntent = if (PreviewImageFragment.canBePreviewed(file)) {
+                PreviewImageActivity.previewFileIntent(context, fileOwner, file)
+            } else {
+                FileDisplayActivity.openFileIntent(context, fileOwner, file)
+            }
+            openFileIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
+            val pendingOpenFileIntent = PendingIntent.getActivity(
+                context,
+                System.currentTimeMillis().toInt(),
+                openFileIntent,
+                0
+            )
+            builder.setContentIntent(pendingOpenFileIntent)
+        }
+        platformNotificationsManager.notify(AppNotificationManager.DOWNLOAD_NOTIFICATION_ID, builder.build())
+    }
+
+    override fun cancelDownloadProgress() {
+        platformNotificationsManager.cancel(AppNotificationManager.DOWNLOAD_NOTIFICATION_ID)
+    }
+}

+ 4 - 0
src/main/java/com/owncloud/android/operations/DownloadFileOperation.java

@@ -85,6 +85,10 @@ public class DownloadFileOperation extends RemoteOperation {
         this.context = context;
     }
 
+    public DownloadFileOperation(Account account, OCFile file, Context context) {
+        this(account, file, null, null, null, context);
+    }
+
     public String getSavePath() {
         if (file.getStoragePath() != null) {
             File parentFile = new File(file.getStoragePath()).getParentFile();

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

@@ -78,7 +78,6 @@ public class ContactsPreferenceActivity extends FileActivity implements FileFrag
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
-
         setContentView(R.layout.contacts_preference);
 
         // setup toolbar

+ 7 - 0
src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java

@@ -218,6 +218,13 @@ public class FileDisplayActivity extends FileActivity
     @Inject
     ConnectivityService connectivityService;
 
+    public static Intent openFileIntent(Context context, User user, OCFile file) {
+        final Intent intent = new Intent(context, PreviewImageActivity.class);
+        intent.putExtra(FileActivity.EXTRA_FILE, file);
+        intent.putExtra(FileActivity.EXTRA_ACCOUNT, user.toPlatformAccount());
+        return intent;
+    }
+
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         Log_OC.v(TAG, "onCreate() start");

+ 27 - 27
src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListFragment.java

@@ -4,6 +4,7 @@
  * @author Tobias Kaminsky
  * Copyright (C) 2017 Tobias Kaminsky
  * Copyright (C) 2017 Nextcloud GmbH.
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
  * <p>
  * 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
@@ -22,11 +23,9 @@
 package com.owncloud.android.ui.fragment.contactsbackup;
 
 import android.Manifest;
-import android.content.BroadcastReceiver;
+import android.app.Activity;
 import android.content.Context;
 import android.content.DialogInterface;
-import android.content.Intent;
-import android.content.IntentFilter;
 import android.database.Cursor;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
@@ -58,12 +57,15 @@ import com.google.android.material.snackbar.Snackbar;
 import com.nextcloud.client.account.User;
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.di.Injectable;
+import com.nextcloud.client.files.downloader.Download;
+import com.nextcloud.client.files.downloader.DownloadState;
+import com.nextcloud.client.files.downloader.DownloaderConnection;
+import com.nextcloud.client.files.downloader.Request;
 import com.nextcloud.client.jobs.BackgroundJobManager;
 import com.nextcloud.client.network.ClientFactory;
 import com.owncloud.android.R;
 import com.owncloud.android.datamodel.FileDataStorageManager;
 import com.owncloud.android.datamodel.OCFile;
-import com.owncloud.android.files.services.FileDownloader;
 import com.owncloud.android.lib.common.utils.Log_OC;
 import com.owncloud.android.ui.TextDrawable;
 import com.owncloud.android.ui.activity.ContactsPreferenceActivity;
@@ -101,6 +103,7 @@ import butterknife.ButterKnife;
 import ezvcard.Ezvcard;
 import ezvcard.VCard;
 import ezvcard.property.Photo;
+import kotlin.Unit;
 
 import static com.owncloud.android.ui.fragment.contactsbackup.ContactListFragment.getDisplayName;
 
@@ -148,6 +151,7 @@ public class ContactListFragment extends FileFragment implements Injectable {
     @Inject UserAccountManager accountManager;
     @Inject ClientFactory clientFactory;
     @Inject BackgroundJobManager backgroundJobManager;
+    private DownloaderConnection fileDownloader;
 
     public static ContactListFragment newInstance(OCFile file, User user) {
         ContactListFragment frag = new ContactListFragment();
@@ -209,18 +213,12 @@ public class ContactListFragment extends FileFragment implements Injectable {
         ocFile = getArguments().getParcelable(FILE_NAME);
         setFile(ocFile);
         user = getArguments().getParcelable(USER);
-
+        fileDownloader = new DownloaderConnection(getActivity(), user);
+        fileDownloader.registerDownloadListener(this::onDownloadUpdate);
+        fileDownloader.bind();
         if (!ocFile.isDown()) {
-            Intent i = new Intent(getContext(), FileDownloader.class);
-            i.putExtra(FileDownloader.EXTRA_USER, user);
-            i.putExtra(FileDownloader.EXTRA_FILE, ocFile);
-            getContext().startService(i);
-
-            // Listen for download messages
-            IntentFilter downloadIntentFilter = new IntentFilter(FileDownloader.getDownloadAddedMessage());
-            downloadIntentFilter.addAction(FileDownloader.getDownloadFinishMessage());
-            DownloadFinishReceiver mDownloadFinishReceiver = new DownloadFinishReceiver();
-            getContext().registerReceiver(mDownloadFinishReceiver, downloadIntentFilter);
+            Request request = new Request(user, ocFile);
+            fileDownloader.download(request);
         } else {
             loadContactsTask.execute();
         }
@@ -240,6 +238,14 @@ public class ContactListFragment extends FileFragment implements Injectable {
         return view;
     }
 
+    @Override
+    public void onDetach() {
+        super.onDetach();
+        if (fileDownloader != null) {
+            fileDownloader.unbind();
+        }
+    }
+
     @Override
     public void onSaveInstanceState(@NonNull Bundle outState) {
         super.onSaveInstanceState(outState);
@@ -497,19 +503,13 @@ public class ContactListFragment extends FileFragment implements Injectable {
         }
     }
 
-    private class DownloadFinishReceiver extends BroadcastReceiver {
-
-        @Override
-        public void onReceive(Context context, Intent intent) {
-            if (FileDownloader.getDownloadFinishMessage().equalsIgnoreCase(intent.getAction())) {
-                String downloadedRemotePath = intent.getStringExtra(FileDownloader.EXTRA_REMOTE_PATH);
-
-                FileDataStorageManager storageManager = new FileDataStorageManager(user.toPlatformAccount(),
-                                                                                   context.getContentResolver());
-                ocFile = storageManager.getFileByPath(downloadedRemotePath);
-                loadContactsTask.execute();
-            }
+    private Unit onDownloadUpdate(Download download) {
+        final Activity activity = getActivity();
+        if (download.getState() == DownloadState.COMPLETED && activity != null) {
+            ocFile = download.getFile();
+            loadContactsTask.execute();
         }
+        return Unit.INSTANCE;
     }
 
     public static class VCardComparator implements Comparator<VCard> {

+ 7 - 2
src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.java

@@ -80,12 +80,17 @@ public class PreviewImageActivity extends FileActivity implements
         Injectable {
 
     public static final String TAG = PreviewImageActivity.class.getSimpleName();
-
     private static final String KEY_WAITING_FOR_BINDER = "WAITING_FOR_BINDER";
     private static final String KEY_SYSTEM_VISIBLE = "TRUE";
-
     public static final String EXTRA_VIRTUAL_TYPE = "EXTRA_VIRTUAL_TYPE";
 
+    public static Intent previewFileIntent(Context context, User user, OCFile file) {
+        final Intent intent = new Intent(context, PreviewImageActivity.class);
+        intent.putExtra(FileActivity.EXTRA_FILE, file);
+        intent.putExtra(FileActivity.EXTRA_ACCOUNT, user.toPlatformAccount());
+        return intent;
+    }
+
     private ViewPager mViewPager;
     private PreviewImagePagerAdapter mPreviewImagePagerAdapter;
     private int mSavedPosition;

+ 119 - 0
src/main/res/layout/etm_download_list_item.xml

@@ -0,0 +1,119 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Nextcloud Android client application
+
+    @author Chris Narkiewicz
+    Copyright (C) 2020 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/>.
+-->
+<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:padding="8dp"
+    android:stretchColumns="1">
+
+    <TableRow
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="20dp"
+            android:text="@string/etm_download_uuid" />
+
+        <TextView
+            android:id="@+id/etm_download_uuid"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            tools:text="d7edb387-0b61-4e4e-a728-ffab3055d700" />
+    </TableRow>
+
+    <TableRow
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="20dp"
+            android:text="@string/etm_download_path" />
+
+        <TextView
+            android:id="@+id/etm_download_path"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            tools:text="file path" />
+
+    </TableRow>
+
+    <TableRow
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="20dp"
+            android:text="@string/etm_download_user" />
+
+        <TextView
+            android:id="@+id/etm_download_user"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            tools:text="user@nextcloud.com" />
+
+    </TableRow>
+
+    <TableRow
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="20dp"
+            android:text="@string/etm_download_state" />
+
+        <TextView
+            android:id="@+id/etm_download_state"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            tools:text="PENDING" />
+
+    </TableRow>
+
+    <TableRow
+        android:id="@+id/etm_download_progress_row"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="20dp"
+            android:text="@string/etm_download_progress" />
+
+        <TextView
+            android:id="@+id/etm_download_progress"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            tools:text="50%" />
+
+    </TableRow>
+
+</TableLayout>

+ 31 - 0
src/main/res/layout/fragment_etm_downloader.xml

@@ -0,0 +1,31 @@
+<!--
+    Nextcloud Android client application
+
+    @author Chris Narkiewicz
+    Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program. If not, see <http://www.gnu.org/licenses/>.
+-->
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="com.nextcloud.client.etm.pages.EtmDownloaderFragment">
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/etm_download_list"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"/>
+
+</FrameLayout>

+ 33 - 0
src/main/res/menu/fragment_etm_downloader.xml

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:ignore="AppCompatResource">
+
+    <item
+        android:id="@+id/etm_test_download"
+        android:title="@string/etm_download_enqueue_test_download"
+        app:showAsAction="ifRoom"
+        android:showAsAction="ifRoom"
+        android:icon="@drawable/ic_plus" />
+
+</menu>

+ 7 - 0
src/main/res/values/strings.xml

@@ -877,6 +877,13 @@
     <string name="etm_background_job_started">Started</string>
     <string name="etm_background_job_progress">Progress</string>
     <string name="etm_migrations">Migrations (app upgrade)</string>
+    <string name="etm_downloader">Downloader</string>
+    <string name="etm_download_uuid">@string/etm_background_job_uuid</string>
+    <string name="etm_download_user">@string/etm_background_job_user</string>
+    <string name="etm_download_path">Remote path</string>
+    <string name="etm_download_state">@string/etm_background_job_state</string>
+    <string name="etm_download_progress">@string/etm_background_job_progress</string>
+    <string name="etm_download_enqueue_test_download">Enqueue test download</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>

+ 132 - 0
src/test/java/com/nextcloud/client/core/LocalConnectionTest.kt

@@ -0,0 +1,132 @@
+package com.nextcloud.client.core
+
+import android.app.Service
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.justRun
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class LocalConnectionTest {
+
+    lateinit var context: Context
+    lateinit var connection: LocalConnection<Service>
+    var mockIntent: Intent? = null
+
+    @MockK
+    lateinit var componentName: ComponentName
+
+    @MockK
+    lateinit var binder: LocalBinder<Service>
+
+    @MockK
+    lateinit var mockOnBound: (IBinder) -> Unit
+
+    @MockK
+    lateinit var mockOnUnbound: () -> Unit
+
+    @Before
+    fun setUp() {
+        MockKAnnotations.init(this, relaxed = true)
+        context = mockk()
+        connection = object : LocalConnection<Service>(context) {
+            override fun createBindIntent(): Intent? {
+                return mockIntent
+            }
+
+            override fun onBound(binder: IBinder) {
+                mockOnBound.invoke(binder)
+            }
+
+            override fun onUnbind() {
+                mockOnUnbound.invoke()
+            }
+        }
+    }
+
+    @Test
+    fun binding_disabled() {
+        // GIVEN
+        //      no binding intent is provided
+        mockIntent = null
+
+        // WHEN
+        //      bind requested
+        connection.bind()
+
+        // THEN
+        //      no binding is performed
+        verify(exactly = 0) { context.bindService(any(), any(), any()) }
+    }
+
+    @Test
+    fun bind_service() {
+        // GIVEN
+        //      binding intent is provided
+        mockIntent = mockk()
+
+        // WHEN
+        //      bind requested
+        every { context.bindService(mockIntent, any(), any()) } returns true
+        connection.bind()
+
+        // THEN
+        //      service bound
+        verify { context.bindService(mockIntent, any(), any()) }
+    }
+
+    @Test
+    fun service_connected() {
+        // GIVEN
+        //      service is not bound
+
+        // WHEN
+        //      service is connected
+        connection.onServiceConnected(componentName, binder)
+
+        // THEN
+        //      onBound callback called with binder instance
+        verify { mockOnBound(binder) }
+        assertTrue(connection.isConnected)
+    }
+
+    @Test
+    fun unbind_service() {
+        // GIVEN
+        //      servic is bound
+        connection.onServiceConnected(componentName, binder)
+
+        // WHEN
+        //      service is unbound multiple imes
+        justRun { context.unbindService(connection) }
+        connection.unbind()
+        connection.unbind()
+
+        // THEN
+        //      service is unbound only when it's bound
+        //      later unbind invocations no-ops
+        verify(exactly = 1) { mockOnUnbound.invoke() }
+        verify(exactly = 1) { context.unbindService(connection) }
+        assertFalse(connection.isConnected)
+    }
+
+    @Test(expected = IllegalStateException::class)
+    fun binder_must_implement_local_binder() {
+        // WHEN
+        //      service connected using non-compliant binder
+        val badBinder: IBinder = mockk()
+        connection.onServiceConnected(componentName, badBinder)
+
+        // THEN
+        //      throws
+    }
+}

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

@@ -66,17 +66,17 @@ class ManualAsyncRunnerTest {
     @Test
     fun `tasks are queued`() {
         assertEquals(EMPTY, runner.size)
-        runner.post(task, onResult, onError)
-        runner.post(task, onResult, onError)
-        runner.post(task, onResult, onError)
+        runner.postQuickTask(task, onResult, onError)
+        runner.postQuickTask(task, onResult, onError)
+        runner.postQuickTask(task, onResult, onError)
         assertEquals("Expected 3 tasks to be enqueued", THREE_TASKS, runner.size)
     }
 
     @Test
     fun `run one enqueued task`() {
-        runner.post(task, onResult, onError)
-        runner.post(task, onResult, onError)
-        runner.post(task, onResult, onError)
+        runner.postQuickTask(task, onResult, onError)
+        runner.postQuickTask(task, onResult, onError)
+        runner.postQuickTask(task, onResult, onError)
 
         assertEquals("Queue should contain all enqueued tasks", THREE_TASKS, runner.size)
         val run = runner.runOne()
@@ -88,8 +88,8 @@ class ManualAsyncRunnerTest {
 
     @Test
     fun `run all enqueued tasks`() {
-        runner.post(task, onResult, onError)
-        runner.post(task, onResult, onError)
+        runner.postQuickTask(task, onResult, onError)
+        runner.postQuickTask(task, onResult, onError)
 
         assertEquals("Queue should contain all enqueued tasks", TWO_TASKS, runner.size)
         val count = runner.runAll()
@@ -115,13 +115,13 @@ class ManualAsyncRunnerTest {
         // WHEN
         //      one task is scheduled
         //      task callback schedules another task
-        runner.post(
+        runner.postQuickTask(
             task,
             {
-                runner.post(
+                runner.postQuickTask(
                     task,
                     {
-                        runner.post(task)
+                        runner.postQuickTask(task)
                     }
                 )
             }
@@ -141,7 +141,7 @@ class ManualAsyncRunnerTest {
     fun `runner detects infinite loops caused by scheduling tasks recusively`() {
         val recursiveTask: () -> String = object : Function0<String> {
             override fun invoke(): String {
-                runner.post(this)
+                runner.postQuickTask(this)
                 return "result"
             }
         }
@@ -149,7 +149,7 @@ class ManualAsyncRunnerTest {
         // WHEN
         //      one task is scheduled
         //      task will schedule itself again, causing infinite loop
-        runner.post(recursiveTask)
+        runner.postQuickTask(recursiveTask)
 
         // WHEN
         //      runs all

+ 17 - 8
src/test/java/com/nextcloud/client/core/TaskTest.kt

@@ -33,24 +33,33 @@ import org.mockito.MockitoAnnotations
 class TaskTest {
 
     @Mock
-    private lateinit var taskBody: () -> String
+    private lateinit var taskBody: TaskFunction<String, Int>
+    @Mock
+    private lateinit var removeFromQueue: (Runnable) -> Boolean
     @Mock
     private lateinit var onResult: OnResultCallback<String>
     @Mock
     private lateinit var onError: OnErrorCallback
+    @Mock
+    private lateinit var onProgress: OnProgressCallback<Int>
 
-    private lateinit var task: Task<String>
+    private lateinit var task: Task<String, Int>
+
+    fun post(r: Runnable): Boolean {
+        r.run()
+        return true
+    }
 
     @Before
     fun setUp() {
         MockitoAnnotations.initMocks(this)
-        val postResult = { r: Runnable -> r.run() }
-        task = Task(postResult, taskBody, onResult, onError)
+        val postResult = { r: Runnable -> r.run(); true }
+        task = Task(this::post, removeFromQueue, taskBody, onResult, onError, onProgress)
     }
 
     @Test
     fun `task result is posted`() {
-        whenever(taskBody.invoke()).thenReturn("result")
+        whenever(taskBody.invoke(any(), any())).thenReturn("result")
         task.run()
         verify(onResult).invoke(eq("result"))
         verify(onError, never()).invoke(any())
@@ -58,7 +67,7 @@ class TaskTest {
 
     @Test
     fun `task result is not posted when cancelled`() {
-        whenever(taskBody.invoke()).thenReturn("result")
+        whenever(taskBody.invoke(any(), any())).thenReturn("result")
         task.cancel()
         task.run()
         verify(onResult, never()).invoke(any())
@@ -68,7 +77,7 @@ class TaskTest {
     @Test
     fun `task error is posted`() {
         val exception = RuntimeException("")
-        whenever(taskBody.invoke()).thenThrow(exception)
+        whenever(taskBody.invoke(any(), any())).thenThrow(exception)
         task.run()
         verify(onResult, never()).invoke(any())
         verify(onError).invoke(same(exception))
@@ -77,7 +86,7 @@ class TaskTest {
     @Test
     fun `task error is not posted when cancelled`() {
         val exception = RuntimeException("")
-        whenever(taskBody.invoke()).thenThrow(exception)
+        whenever(taskBody.invoke(any(), any())).thenThrow(exception)
         task.cancel()
         task.run()
         verify(onResult, never()).invoke(any())

+ 11 - 8
src/test/java/com/nextcloud/client/core/ThreadPoolAsyncRunnerTest.kt

@@ -62,7 +62,7 @@ class ThreadPoolAsyncRunnerTest {
         val latch = CountDownLatch(1)
         val callerThread = Thread.currentThread()
         var taskThread: Thread? = null
-        r.post({
+        r.postQuickTask({
             taskThread = Thread.currentThread()
             latch.countDown()
         })
@@ -79,7 +79,7 @@ class ThreadPoolAsyncRunnerTest {
         }.whenever(handler).post(any())
 
         val onResult: OnResultCallback<String> = mock()
-        r.post(
+        r.postQuickTask(
             {
                 "result"
             },
@@ -99,11 +99,12 @@ class ThreadPoolAsyncRunnerTest {
 
         val onResult: OnResultCallback<String> = mock()
         val onError: OnErrorCallback = mock()
-        r.post(
+        r.postQuickTask(
             {
                 throw IllegalArgumentException("whatever")
             },
-            onResult = onResult, onError = onError
+            onResult = onResult,
+            onError = onError
         )
         assertAwait(afterPostLatch)
         verify(onResult, never()).invoke(any())
@@ -114,13 +115,14 @@ class ThreadPoolAsyncRunnerTest {
     fun `cancelled task does not return result`() {
         val taskIsCancelled = CountDownLatch(INIT_COUNT)
         val taskIsRunning = CountDownLatch(INIT_COUNT)
-        val t = r.post(
+        val t = r.postQuickTask(
             {
                 taskIsRunning.countDown()
                 taskIsCancelled.await()
                 "result"
             },
-            onResult = {}, onError = {}
+            onResult = {},
+            onError = {}
         )
         assertAwait(taskIsRunning)
         t.cancel()
@@ -133,13 +135,14 @@ class ThreadPoolAsyncRunnerTest {
     fun `cancelled task does not return error`() {
         val taskIsCancelled = CountDownLatch(INIT_COUNT)
         val taskIsRunning = CountDownLatch(INIT_COUNT)
-        val t = r.post(
+        val t = r.postQuickTask(
             {
                 taskIsRunning.countDown()
                 taskIsCancelled.await()
                 throw IllegalStateException("whatever")
             },
-            onResult = {}, onError = {}
+            onResult = {},
+            onError = {}
         )
         assertAwait(taskIsRunning)
         t.cancel()

+ 10 - 0
src/test/java/com/nextcloud/client/etm/TestEtmViewModel.kt

@@ -20,11 +20,14 @@
 package com.nextcloud.client.etm
 
 import android.accounts.AccountManager
+import android.content.Context
 import android.content.SharedPreferences
 import android.content.res.Resources
 import androidx.arch.core.executor.testing.InstantTaskExecutorRule
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.Observer
+import com.nextcloud.client.account.MockUser
+import com.nextcloud.client.account.UserAccountManager
 import com.nextcloud.client.etm.pages.EtmBackgroundJobsFragment
 import com.nextcloud.client.jobs.BackgroundJobManager
 import com.nextcloud.client.jobs.JobInfo
@@ -66,7 +69,9 @@ class TestEtmViewModel {
         @get:Rule
         val rule = InstantTaskExecutorRule()
 
+        protected lateinit var context: Context
         protected lateinit var platformAccountManager: AccountManager
+        protected lateinit var accountManager: UserAccountManager
         protected lateinit var sharedPreferences: SharedPreferences
         protected lateinit var vm: EtmViewModel
         protected lateinit var resources: Resources
@@ -76,16 +81,21 @@ class TestEtmViewModel {
 
         @Before
         fun setUpBase() {
+            context = mock()
             sharedPreferences = mock()
             platformAccountManager = mock()
+            accountManager = mock()
             resources = mock()
             backgroundJobManager = mock()
             migrationsManager = mock()
             migrationsDb = mock()
             whenever(resources.getString(any())).thenReturn("mock-account-type")
+            whenever(accountManager.user).thenReturn(MockUser())
             vm = EtmViewModel(
+                context,
                 sharedPreferences,
                 platformAccountManager,
+                accountManager,
                 resources,
                 backgroundJobManager,
                 migrationsManager,