From 75b9fa855146d0731a5ee4ae7f7e722887cc2013 Mon Sep 17 00:00:00 2001 From: Chris Narkiewicz Date: Wed, 13 May 2020 01:09:21 +0100 Subject: [PATCH] New download manager Signed-off-by: Chris Narkiewicz --- CONTRIBUTING.md | 1 + build.gradle | 19 +- detekt.yml | 12 +- .../client/ScreenshotTestRunner.java | 19 + .../nextcloud/client/account/MockUserTest.kt | 71 +++ .../downloader/DownloaderConnectionTest.kt | 240 ++++++++ .../files/downloader/DownloaderServiceTest.kt | 58 ++ .../client/files/downloader/DownloaderTest.kt | 281 ++++++++++ .../client/files/downloader/RegistryTest.kt | 519 ++++++++++++++++++ src/main/AndroidManifest.xml | 1 + .../com/nextcloud/client/account/MockUser.kt | 80 +++ .../com/nextcloud/client/core/AsyncRunner.kt | 44 +- .../com/nextcloud/client/core/Cancellable.kt | 4 +- .../com/nextcloud/client/core/LocalBinder.kt | 28 + .../nextcloud/client/core/LocalConnection.kt | 105 ++++ .../client/core/ManualAsyncRunner.kt | 29 +- .../java/com/nextcloud/client/core/Task.kt | 29 +- .../client/core/ThreadPoolAsyncRunner.kt | 41 +- .../com/nextcloud/client/di/AppModule.java | 21 +- .../nextcloud/client/di/ComponentsModule.java | 2 + .../com/nextcloud/client/etm/EtmViewModel.kt | 14 + .../client/etm/pages/EtmDownloaderFragment.kt | 133 +++++ .../client/files/downloader/Download.kt | 49 ++ .../client/files/downloader/DownloadState.kt | 27 + .../client/files/downloader/DownloadTask.kt | 100 ++++ .../client/files/downloader/Downloader.kt | 98 ++++ .../files/downloader/DownloaderConnection.kt | 122 ++++ .../client/files/downloader/DownloaderImpl.kt | 144 +++++ .../files/downloader/DownloaderService.kt | 155 ++++++ .../client/files/downloader/Registry.kt | 162 ++++++ .../client/files/downloader/Request.kt | 79 +++ .../nextcloud/client/logger/ui/AsyncFilter.kt | 6 +- .../client/logger/ui/LogsEmailSender.kt | 2 +- .../migrations/MigrationsManagerImpl.kt | 2 +- .../notifications/AppNotificationManager.kt | 38 ++ .../AppNotificationManagerImpl.kt | 88 +++ .../operations/DownloadFileOperation.java | 4 + .../activity/ContactsPreferenceActivity.java | 1 - .../ui/activity/FileDisplayActivity.java | 7 + .../contactsbackup/ContactListFragment.java | 54 +- .../ui/preview/PreviewImageActivity.java | 9 +- .../res/layout/etm_download_list_item.xml | 119 ++++ .../res/layout/fragment_etm_downloader.xml | 31 ++ src/main/res/menu/fragment_etm_downloader.xml | 33 ++ src/main/res/values/strings.xml | 7 + .../client/core/LocalConnectionTest.kt | 132 +++++ .../client/core/ManualAsyncRunnerTest.kt | 26 +- .../com/nextcloud/client/core/TaskTest.kt | 25 +- .../client/core/ThreadPoolAsyncRunnerTest.kt | 19 +- .../nextcloud/client/etm/TestEtmViewModel.kt | 10 + 50 files changed, 3194 insertions(+), 106 deletions(-) create mode 100644 src/androidTest/java/com/nextcloud/client/account/MockUserTest.kt create mode 100644 src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderConnectionTest.kt create mode 100644 src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderServiceTest.kt create mode 100644 src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderTest.kt create mode 100644 src/androidTest/java/com/nextcloud/client/files/downloader/RegistryTest.kt create mode 100644 src/main/java/com/nextcloud/client/account/MockUser.kt create mode 100644 src/main/java/com/nextcloud/client/core/LocalBinder.kt create mode 100644 src/main/java/com/nextcloud/client/core/LocalConnection.kt create mode 100644 src/main/java/com/nextcloud/client/etm/pages/EtmDownloaderFragment.kt create mode 100644 src/main/java/com/nextcloud/client/files/downloader/Download.kt create mode 100644 src/main/java/com/nextcloud/client/files/downloader/DownloadState.kt create mode 100644 src/main/java/com/nextcloud/client/files/downloader/DownloadTask.kt create mode 100644 src/main/java/com/nextcloud/client/files/downloader/Downloader.kt create mode 100644 src/main/java/com/nextcloud/client/files/downloader/DownloaderConnection.kt create mode 100644 src/main/java/com/nextcloud/client/files/downloader/DownloaderImpl.kt create mode 100644 src/main/java/com/nextcloud/client/files/downloader/DownloaderService.kt create mode 100644 src/main/java/com/nextcloud/client/files/downloader/Registry.kt create mode 100644 src/main/java/com/nextcloud/client/files/downloader/Request.kt create mode 100644 src/main/java/com/nextcloud/client/notifications/AppNotificationManager.kt create mode 100644 src/main/java/com/nextcloud/client/notifications/AppNotificationManagerImpl.kt create mode 100644 src/main/res/layout/etm_download_list_item.xml create mode 100644 src/main/res/layout/fragment_etm_downloader.xml create mode 100644 src/main/res/menu/fragment_etm_downloader.xml create mode 100644 src/test/java/com/nextcloud/client/core/LocalConnectionTest.kt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b5c90dbd1..dfd3cfc0c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -379,6 +379,7 @@ Generally, whenever you need: * media playback * networking * logging +* notifications management we have something more suitable. diff --git a/build.gradle b/build.gradle index d398dda84b..8b3342a637 100644 --- a/build.gradle +++ b/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 { diff --git a/detekt.yml b/detekt.yml index 16a16e86f1..ab3f6d9843 100644 --- a/detekt.yml +++ b/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 diff --git a/src/androidTest/java/com/nextcloud/client/ScreenshotTestRunner.java b/src/androidTest/java/com/nextcloud/client/ScreenshotTestRunner.java index 1b88f899e1..41935274c0 100644 --- a/src/androidTest/java/com/nextcloud/client/ScreenshotTestRunner.java +++ b/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); diff --git a/src/androidTest/java/com/nextcloud/client/account/MockUserTest.kt b/src/androidTest/java/com/nextcloud/client/account/MockUserTest.kt new file mode 100644 index 0000000000..e4098ceba0 --- /dev/null +++ b/src/androidTest/java/com/nextcloud/client/account/MockUserTest.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * 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 . + */ +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::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) + } +} diff --git a/src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderConnectionTest.kt b/src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderConnectionTest.kt new file mode 100644 index 0000000000..6173304d26 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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() + verify { firstDownloadListener(capture(firstListenerNotifications)) } + assertEquals(listOf(download1, download2), firstListenerNotifications) + + val secondListenerNotifications = mutableListOf() + 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()) } + } +} diff --git a/src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderServiceTest.kt b/src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderServiceTest.kt new file mode 100644 index 0000000000..08d6045442 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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) + } +} diff --git a/src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderTest.kt b/src/androidTest/java/com/nextcloud/client/files/downloader/DownloaderTest.kt new file mode 100644 index 0000000000..d91d680497 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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 + 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() + + @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() + every { task.download(any(), any(), any()) } answers { + taskProgress.forEach { + arg>(1).invoke(it) + } + val request = arg(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() + 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() + 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() + 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) + } + } +} diff --git a/src/androidTest/java/com/nextcloud/client/files/downloader/RegistryTest.kt b/src/androidTest/java/com/nextcloud/client/files/downloader/RegistryTest.kt new file mode 100644 index 0000000000..35f12e2d8e --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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() + 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() + 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() + 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() + 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() + 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) + } + } +} diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index f038e9970f..92563f9174 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -325,6 +325,7 @@ + diff --git a/src/main/java/com/nextcloud/client/account/MockUser.kt b/src/main/java/com/nextcloud/client/account/MockUser.kt new file mode 100644 index 0000000000..b5f73559cf --- /dev/null +++ b/src/main/java/com/nextcloud/client/account/MockUser.kt @@ -0,0 +1,80 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * 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 . + */ +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 = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): MockUser = MockUser(source) + override fun newArray(size: Int): Array = 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) + } +} diff --git a/src/main/java/com/nextcloud/client/core/AsyncRunner.kt b/src/main/java/com/nextcloud/client/core/AsyncRunner.kt index 31e2cefe33..98afb3e898 100644 --- a/src/main/java/com/nextcloud/client/core/AsyncRunner.kt +++ b/src/main/java/com/nextcloud/client/core/AsyncRunner.kt @@ -19,23 +19,53 @@ */ package com.nextcloud.client.core -typealias OnResultCallback = (T) -> Unit -typealias OnErrorCallback = (Throwable) -> Unit +typealias OnResultCallback = (result: T) -> Unit +typealias OnErrorCallback = (error: Throwable) -> Unit +typealias OnProgressCallback

= (progress: P) -> Unit +typealias IsCancelled = () -> Boolean +typealias TaskFunction = ( + onProgress: OnProgressCallback, + 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 postQuickTask( + task: () -> RESULT, + onResult: OnResultCallback? = 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 post(task: () -> T, onResult: OnResultCallback? = null, onError: OnErrorCallback? = null): Cancellable + fun postTask( + task: TaskFunction, + onResult: OnResultCallback? = null, + onError: OnErrorCallback? = null, + onProgress: OnProgressCallback? = null + ): Cancellable } diff --git a/src/main/java/com/nextcloud/client/core/Cancellable.kt b/src/main/java/com/nextcloud/client/core/Cancellable.kt index 49c9598f3a..5bed4592a4 100644 --- a/src/main/java/com/nextcloud/client/core/Cancellable.kt +++ b/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() } diff --git a/src/main/java/com/nextcloud/client/core/LocalBinder.kt b/src/main/java/com/nextcloud/client/core/LocalBinder.kt new file mode 100644 index 0000000000..404c55f8d4 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.core + +import android.app.Service +import android.os.Binder + +/** + * This is a generic binder that provides access to a locally bound service instance. + */ +abstract class LocalBinder(val service: S) : Binder() diff --git a/src/main/java/com/nextcloud/client/core/LocalConnection.kt b/src/main/java/com/nextcloud/client/core/LocalConnection.kt new file mode 100644 index 0000000000..7708e04c01 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.core + +import android.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( + protected val context: Context +) : ServiceConnection { + + private var serviceBinder: LocalBinder? = 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 + onBound(binder) + } + + final override fun onServiceDisconnected(name: ComponentName) { + serviceBinder = null + } +} diff --git a/src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt b/src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt index 6130aa57a4..dc800ce60f 100644 --- a/src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt +++ b/src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt @@ -27,14 +27,35 @@ import java.util.ArrayDeque */ class ManualAsyncRunner : AsyncRunner { - private val queue: ArrayDeque> = ArrayDeque() + private val queue: ArrayDeque = ArrayDeque() - override fun post(task: () -> T, onResult: OnResultCallback?, onError: OnErrorCallback?): Cancellable { + override fun postQuickTask( + task: () -> T, + onResult: OnResultCallback?, + onError: OnErrorCallback? + ): Cancellable { + return postTask( + task = { _: OnProgressCallback, _: IsCancelled -> task.invoke() }, + onResult = onResult, + onError = onError, + onProgress = null + ) + } + + override fun postTask( + task: TaskFunction, + onResult: OnResultCallback?, + onError: OnErrorCallback?, + onProgress: OnProgressCallback

? + ): Cancellable { + val remove: Function1 = 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 diff --git a/src/main/java/com/nextcloud/client/core/Task.kt b/src/main/java/com/nextcloud/client/core/Task.kt index 85f64b18ec..6f407048d9 100644 --- a/src/main/java/com/nextcloud/client/core/Task.kt +++ b/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( - private val postResult: (Runnable) -> Unit, - private val taskBody: () -> T, +@Suppress("LongParameterList") +internal class Task( + private val postResult: (Runnable) -> Boolean, + private val removeFromQueue: (Runnable) -> Boolean, + private val taskBody: TaskFunction, private val onSuccess: OnResultCallback?, - private val onError: OnErrorCallback? + private val onError: OnErrorCallback?, + private val onProgress: OnProgressCallback

? ) : 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( postResult(Runnable { onError?.invoke(t) }) } } + removeFromQueue(this) } override fun cancel() { cancelled.set(true) + removeFromQueue(this) } } diff --git a/src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt b/src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt index 096e0cafaf..992e98f21e 100644 --- a/src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt +++ b/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 post(task: () -> T, onResult: OnResultCallback?, onError: OnErrorCallback?): Cancellable { - val taskWrapper = Task(this::postResult, task, onResult, onError) + override fun postQuickTask( + task: () -> T, + onResult: OnResultCallback?, + onError: OnErrorCallback? + ): Cancellable { + val taskAdapter = { _: OnProgressCallback, _: IsCancelled -> task.invoke() } + return postTask( + taskAdapter, + onResult, + onError, + null + ) + } + + override fun postTask( + task: TaskFunction, + onResult: OnResultCallback?, + onError: OnErrorCallback?, + onProgress: OnProgressCallback

? + ): Cancellable { + val remove: Function1 = executor::remove + val taskWrapper = Task( + postResult = uiThreadHandler::post, + removeFromQueue = remove, + taskBody = task, + onSuccess = onResult, + onError = onError, + onProgress = onProgress + ) executor.execute(taskWrapper) return taskWrapper } - - private fun postResult(r: Runnable) { - uiThreadHandler.post(r) - } } diff --git a/src/main/java/com/nextcloud/client/di/AppModule.java b/src/main/java/com/nextcloud/client/di/AppModule.java index f0248995b7..2280e890c5 100644 --- a/src/main/java/com/nextcloud/client/di/AppModule.java +++ b/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); + } } diff --git a/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/src/main/java/com/nextcloud/client/di/ComponentsModule.java index c6b4c88326..969453ff95 100644 --- a/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/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(); } diff --git a/src/main/java/com/nextcloud/client/etm/EtmViewModel.kt b/src/main/java/com/nextcloud/client/etm/EtmViewModel.kt index 8ebaff1bb3..3184cd1662 100644 --- a/src/main/java/com/nextcloud/client/etm/EtmViewModel.kt +++ b/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) + val currentUser: User get() = accountManager.user val currentPage: LiveData = MutableLiveData() val pages: List = 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 get() { return defaultPreferences.all diff --git a/src/main/java/com/nextcloud/client/etm/pages/EtmDownloaderFragment.kt b/src/main/java/com/nextcloud/client/etm/pages/EtmDownloaderFragment.kt new file mode 100644 index 0000000000..9667cef562 --- /dev/null +++ b/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() { + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val uuid = view.findViewById(R.id.etm_download_uuid) + val path = view.findViewById(R.id.etm_download_path) + val user = view.findViewById(R.id.etm_download_user) + val state = view.findViewById(R.id.etm_download_state) + val progress = view.findViewById(R.id.etm_download_progress) + private val progressRow = view.findViewById(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() + + 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) + } +} diff --git a/src/main/java/com/nextcloud/client/files/downloader/Download.kt b/src/main/java/com/nextcloud/client/files/downloader/Download.kt new file mode 100644 index 0000000000..b68bb0321f --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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 +} diff --git a/src/main/java/com/nextcloud/client/files/downloader/DownloadState.kt b/src/main/java/com/nextcloud/client/files/downloader/DownloadState.kt new file mode 100644 index 0000000000..9c189aa375 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.files.downloader + +enum class DownloadState { + PENDING, + RUNNING, + COMPLETED, + FAILED +} diff --git a/src/main/java/com/nextcloud/client/files/downloader/DownloadTask.kt b/src/main/java/com/nextcloud/client/files/downloader/DownloadTask.kt new file mode 100644 index 0000000000..1698785dfe --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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 + } +} diff --git a/src/main/java/com/nextcloud/client/files/downloader/Downloader.kt b/src/main/java/com/nextcloud/client/files/downloader/Downloader.kt new file mode 100644 index 0000000000..82ccfbc50e --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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, + val running: List, + val completed: List + ) { + 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? +} diff --git a/src/main/java/com/nextcloud/client/files/downloader/DownloaderConnection.kt b/src/main/java/com/nextcloud/client/files/downloader/DownloaderConnection.kt new file mode 100644 index 0000000000..5387f9b32b --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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(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 = 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) } + } +} diff --git a/src/main/java/com/nextcloud/client/files/downloader/DownloaderImpl.kt b/src/main/java/com/nextcloud/client/files/downloader/DownloaderImpl.kt new file mode 100644 index 0000000000..c92ff39021 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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 { + return if (request.test) { + { progress: OnProgressCallback, isCancelled: IsCancelled -> + testDownloadTask(request.file, progress, isCancelled) + } + } else { + val downloadTask = taskFactory.create() + val wrapper: TaskFunction = { 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, + 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) + } +} diff --git a/src/main/java/com/nextcloud/client/files/downloader/DownloaderService.kt b/src/main/java/com/nextcloud/client/files/downloader/DownloaderService.kt new file mode 100644 index 0000000000..b33188de09 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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(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 = 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(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 + } + } +} diff --git a/src/main/java/com/nextcloud/client/files/downloader/Registry.kt b/src/main/java/com/nextcloud/client/files/downloader/Registry.kt new file mode 100644 index 0000000000..0332c2effb --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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() + private val runningQueue = TreeMap() + private val completedQueue = TreeMap() + + val isRunning: Boolean get() = pendingQueue.size > 0 || runningQueue.size > 0 + + val pending: List get() = pendingQueue.map { it.value } + val running: List get() = runningQueue.map { it.value } + val completed: List 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] + } +} diff --git a/src/main/java/com/nextcloud/client/files/downloader/Request.kt b/src/main/java/com/nextcloud/client/files/downloader/Request.kt new file mode 100644 index 0000000000..cfb4fbb37e --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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::class.java.classLoader) as User, + file = parcel.readParcelable(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 { + override fun createFromParcel(parcel: Parcel): Request { + return Request(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} diff --git a/src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt b/src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt index 7846469f97..3e60056385 100644 --- a/src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt +++ b/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 filterAsync(collection: Iterable, predicate: (T) -> Boolean, onResult: (List, 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 -> onFilterCompleted(filtered, onResult) } diff --git a/src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt b/src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt index 7eadc9dc20..4f33635ac8 100644 --- a/src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt +++ b/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) { 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) }) } } diff --git a/src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt b/src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt index 1c817924bf..688a5f81f6 100644 --- a/src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt +++ b/src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt @@ -61,7 +61,7 @@ internal class MigrationsManagerImpl( return 0 } (status as MutableLiveData).value = Status.RUNNING - asyncRunner.post( + asyncRunner.postQuickTask( task = { asyncApplyMigrations(toApply) }, onResult = { onMigrationSuccess() }, onError = { onMigrationFailed(it) } diff --git a/src/main/java/com/nextcloud/client/notifications/AppNotificationManager.kt b/src/main/java/com/nextcloud/client/notifications/AppNotificationManager.kt new file mode 100644 index 0000000000..badac38aa1 --- /dev/null +++ b/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() +} diff --git a/src/main/java/com/nextcloud/client/notifications/AppNotificationManagerImpl.kt b/src/main/java/com/nextcloud/client/notifications/AppNotificationManagerImpl.kt new file mode 100644 index 0000000000..e35b9591ac --- /dev/null +++ b/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) + } +} diff --git a/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java b/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java index e9e70b1f12..1e927a92c4 100644 --- a/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java +++ b/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(); diff --git a/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java b/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java index d69c521f9e..6371f435aa 100644 --- a/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java +++ b/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 diff --git a/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java b/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java index 6e6425c18d..8001b1290a 100644 --- a/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java +++ b/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"); diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListFragment.java b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListFragment.java index c48381dcf3..0c0436ff4f 100644 --- a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListFragment.java +++ b/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 *

* 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 { diff --git a/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.java b/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.java index 3ed01e8ba9..f76a8d9f8c 100644 --- a/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.java +++ b/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; diff --git a/src/main/res/layout/etm_download_list_item.xml b/src/main/res/layout/etm_download_list_item.xml new file mode 100644 index 0000000000..a5a641a3a7 --- /dev/null +++ b/src/main/res/layout/etm_download_list_item.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/fragment_etm_downloader.xml b/src/main/res/layout/fragment_etm_downloader.xml new file mode 100644 index 0000000000..0a0ca0e9c0 --- /dev/null +++ b/src/main/res/layout/fragment_etm_downloader.xml @@ -0,0 +1,31 @@ + + + + + + diff --git a/src/main/res/menu/fragment_etm_downloader.xml b/src/main/res/menu/fragment_etm_downloader.xml new file mode 100644 index 0000000000..423e15f2e4 --- /dev/null +++ b/src/main/res/menu/fragment_etm_downloader.xml @@ -0,0 +1,33 @@ + + +

+ + + + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index fb30bd585f..111b73d7b2 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -877,6 +877,13 @@ Started Progress Migrations (app upgrade) + Downloader + @string/etm_background_job_uuid + @string/etm_background_job_user + Remote path + @string/etm_background_job_state + @string/etm_background_job_progress + Enqueue test download Loading… Logs: %1$d kB, query matched %2$d / %3$d in %4$d ms diff --git a/src/test/java/com/nextcloud/client/core/LocalConnectionTest.kt b/src/test/java/com/nextcloud/client/core/LocalConnectionTest.kt new file mode 100644 index 0000000000..5a9380573e --- /dev/null +++ b/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 + var mockIntent: Intent? = null + + @MockK + lateinit var componentName: ComponentName + + @MockK + lateinit var binder: LocalBinder + + @MockK + lateinit var mockOnBound: (IBinder) -> Unit + + @MockK + lateinit var mockOnUnbound: () -> Unit + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + context = mockk() + connection = object : LocalConnection(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 + } +} diff --git a/src/test/java/com/nextcloud/client/core/ManualAsyncRunnerTest.kt b/src/test/java/com/nextcloud/client/core/ManualAsyncRunnerTest.kt index b5cd4d658f..096c42d4fb 100644 --- a/src/test/java/com/nextcloud/client/core/ManualAsyncRunnerTest.kt +++ b/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 { 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 diff --git a/src/test/java/com/nextcloud/client/core/TaskTest.kt b/src/test/java/com/nextcloud/client/core/TaskTest.kt index 968fc73cba..f4b4f5513e 100644 --- a/src/test/java/com/nextcloud/client/core/TaskTest.kt +++ b/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 + @Mock + private lateinit var removeFromQueue: (Runnable) -> Boolean @Mock private lateinit var onResult: OnResultCallback @Mock private lateinit var onError: OnErrorCallback + @Mock + private lateinit var onProgress: OnProgressCallback - private lateinit var task: Task + private lateinit var task: Task + + 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()) diff --git a/src/test/java/com/nextcloud/client/core/ThreadPoolAsyncRunnerTest.kt b/src/test/java/com/nextcloud/client/core/ThreadPoolAsyncRunnerTest.kt index a39800c92d..7b326368ad 100644 --- a/src/test/java/com/nextcloud/client/core/ThreadPoolAsyncRunnerTest.kt +++ b/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 = mock() - r.post( + r.postQuickTask( { "result" }, @@ -99,11 +99,12 @@ class ThreadPoolAsyncRunnerTest { val onResult: OnResultCallback = 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() diff --git a/src/test/java/com/nextcloud/client/etm/TestEtmViewModel.kt b/src/test/java/com/nextcloud/client/etm/TestEtmViewModel.kt index 4231f15f3a..002864c93c 100644 --- a/src/test/java/com/nextcloud/client/etm/TestEtmViewModel.kt +++ b/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,