New download manager
Signed-off-by: Chris Narkiewicz <hello@ezaquarii.com>
This commit is contained in:
parent
51f48549c3
commit
75b9fa8551
|
@ -379,6 +379,7 @@ Generally, whenever you need:
|
|||
* media playback
|
||||
* networking
|
||||
* logging
|
||||
* notifications management
|
||||
|
||||
we have something more suitable.
|
||||
|
||||
|
|
19
build.gradle
19
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 {
|
||||
|
|
12
detekt.yml
12
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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz <hello@ezaquarii.com>
|
||||
* Copyright (C) 2020 Chris Narkiewicz
|
||||
* Copyright (C) 2020 Nextcloud GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.account
|
||||
|
||||
import android.os.Parcel
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MockUserTest {
|
||||
|
||||
private companion object {
|
||||
const val ACCOUNT_NAME = "test_account_name"
|
||||
const val ACCOUNT_TYPE = "test_account_type"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mock_user_is_parcelable() {
|
||||
// GIVEN
|
||||
// mock user instance
|
||||
val original = MockUser(ACCOUNT_NAME, ACCOUNT_TYPE)
|
||||
|
||||
// WHEN
|
||||
// instance is serialized into Parcel
|
||||
// instance is retrieved from Parcel
|
||||
val parcel = Parcel.obtain()
|
||||
parcel.setDataPosition(0)
|
||||
parcel.writeParcelable(original, 0)
|
||||
parcel.setDataPosition(0)
|
||||
val retrieved = parcel.readParcelable<User>(User::class.java.classLoader)
|
||||
|
||||
// THEN
|
||||
// retrieved instance in distinct
|
||||
// instances are equal
|
||||
assertNotSame(original, retrieved)
|
||||
assertTrue(retrieved is MockUser)
|
||||
assertEquals(original, retrieved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mock_user_has_platform_account() {
|
||||
// GIVEN
|
||||
// mock user instance
|
||||
val mock = MockUser(ACCOUNT_NAME, ACCOUNT_TYPE)
|
||||
|
||||
// THEN
|
||||
// can convert to platform account
|
||||
val account = mock.toPlatformAccount()
|
||||
assertEquals(ACCOUNT_NAME, account.name)
|
||||
assertEquals(ACCOUNT_TYPE, account.type)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,240 @@
|
|||
/**
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import com.nextcloud.client.account.MockUser
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class DownloaderConnectionTest {
|
||||
|
||||
lateinit var connection: DownloaderConnection
|
||||
|
||||
@MockK
|
||||
lateinit var context: Context
|
||||
|
||||
@MockK
|
||||
lateinit var firstDownloadListener: (Download) -> Unit
|
||||
|
||||
@MockK
|
||||
lateinit var secondDownloadListener: (Download) -> Unit
|
||||
|
||||
@MockK
|
||||
lateinit var firstStatusListener: (Downloader.Status) -> Unit
|
||||
|
||||
@MockK
|
||||
lateinit var secondStatusListener: (Downloader.Status) -> Unit
|
||||
|
||||
@MockK
|
||||
lateinit var binder: DownloaderService.Binder
|
||||
|
||||
val file get() = OCFile("/path")
|
||||
val componentName = ComponentName("", DownloaderService::class.java.simpleName)
|
||||
val user = MockUser()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
MockKAnnotations.init(this, relaxed = true)
|
||||
connection = DownloaderConnection(context, user)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listeners_are_set_after_connection() {
|
||||
// GIVEN
|
||||
// not connected
|
||||
// listener is added
|
||||
connection.registerDownloadListener(firstDownloadListener)
|
||||
connection.registerDownloadListener(secondDownloadListener)
|
||||
|
||||
// WHEN
|
||||
// service is bound
|
||||
connection.onServiceConnected(componentName, binder)
|
||||
|
||||
// THEN
|
||||
// all listeners are passed to the service
|
||||
val listeners = mutableListOf<(Download) -> Unit>()
|
||||
verify { binder.registerDownloadListener(capture(listeners)) }
|
||||
assertEquals(listOf(firstDownloadListener, secondDownloadListener), listeners)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listeners_are_set_immediately_when_connected() {
|
||||
// GIVEN
|
||||
// service is bound
|
||||
connection.onServiceConnected(componentName, binder)
|
||||
|
||||
// WHEN
|
||||
// listeners are added
|
||||
connection.registerDownloadListener(firstDownloadListener)
|
||||
|
||||
// THEN
|
||||
// listener is forwarded to service
|
||||
verify { binder.registerDownloadListener(firstDownloadListener) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listeners_are_removed_when_unbinding() {
|
||||
// GIVEN
|
||||
// service is bound
|
||||
// service has some listeners
|
||||
connection.onServiceConnected(componentName, binder)
|
||||
connection.registerDownloadListener(firstDownloadListener)
|
||||
connection.registerDownloadListener(secondDownloadListener)
|
||||
|
||||
// WHEN
|
||||
// service unbound
|
||||
connection.unbind()
|
||||
|
||||
// THEN
|
||||
// listeners removed from service
|
||||
verify { binder.removeDownloadListener(firstDownloadListener) }
|
||||
verify { binder.removeDownloadListener(secondDownloadListener) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missed_updates_are_delivered_on_connection() {
|
||||
// GIVEN
|
||||
// not bound
|
||||
// has listeners
|
||||
// download is scheduled and is progressing
|
||||
connection.registerDownloadListener(firstDownloadListener)
|
||||
connection.registerDownloadListener(secondDownloadListener)
|
||||
|
||||
val request1 = Request(user, file)
|
||||
connection.download(request1)
|
||||
val download1 = Download(request1.uuid, DownloadState.RUNNING, 50, request1.file, request1)
|
||||
|
||||
val request2 = Request(user, file)
|
||||
connection.download(request2)
|
||||
val download2 = Download(request2.uuid, DownloadState.RUNNING, 50, request2.file, request1)
|
||||
|
||||
every { binder.getDownload(request1.uuid) } returns download1
|
||||
every { binder.getDownload(request2.uuid) } returns download2
|
||||
|
||||
// WHEN
|
||||
// service is bound
|
||||
connection.onServiceConnected(componentName, binder)
|
||||
|
||||
// THEN
|
||||
// listeners receive current download state for pending downloads
|
||||
val firstListenerNotifications = mutableListOf<Download>()
|
||||
verify { firstDownloadListener(capture(firstListenerNotifications)) }
|
||||
assertEquals(listOf(download1, download2), firstListenerNotifications)
|
||||
|
||||
val secondListenerNotifications = mutableListOf<Download>()
|
||||
verify { secondDownloadListener(capture(secondListenerNotifications)) }
|
||||
assertEquals(listOf(download1, download2), secondListenerNotifications)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloader_status_updates_are_delivered_on_connection() {
|
||||
// GIVEN
|
||||
// not bound
|
||||
// has status listeners
|
||||
val mockStatus: Downloader.Status = mockk()
|
||||
every { binder.status } returns mockStatus
|
||||
connection.registerStatusListener(firstStatusListener)
|
||||
connection.registerStatusListener(secondStatusListener)
|
||||
|
||||
// WHEN
|
||||
// service is bound
|
||||
connection.onServiceConnected(componentName, binder)
|
||||
|
||||
// THEN
|
||||
// downloader status is delivered
|
||||
verify { firstStatusListener(mockStatus) }
|
||||
verify { secondStatusListener(mockStatus) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloader_status_not_requested_if_no_listeners() {
|
||||
// GIVEN
|
||||
// not bound
|
||||
// no status listeners
|
||||
|
||||
// WHEN
|
||||
// service is bound
|
||||
connection.onServiceConnected(componentName, binder)
|
||||
|
||||
// THEN
|
||||
// downloader status is not requested
|
||||
verify(exactly = 0) { binder.status }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun not_running_if_not_connected() {
|
||||
// GIVEN
|
||||
// downloader is running
|
||||
// connection not bound
|
||||
every { binder.isRunning } returns true
|
||||
|
||||
// THEN
|
||||
// not running
|
||||
assertFalse(connection.isRunning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun is_running_from_binder_if_connected() {
|
||||
// GIVEN
|
||||
// service bound
|
||||
every { binder.isRunning } returns true
|
||||
connection.onServiceConnected(componentName, binder)
|
||||
|
||||
// WHEN
|
||||
// is runnign flag accessed
|
||||
val isRunning = connection.isRunning
|
||||
|
||||
// THEN
|
||||
// call delegated to binder
|
||||
assertTrue(isRunning)
|
||||
verify(exactly = 1) { binder.isRunning }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missed_updates_not_tracked_before_listeners_registered() {
|
||||
// GIVEN
|
||||
// not bound
|
||||
// some downloads requested without listener
|
||||
val request = Request(user, file)
|
||||
connection.download(request)
|
||||
val download = Download(request.uuid, DownloadState.RUNNING, 50, request.file, request)
|
||||
connection.registerDownloadListener(firstDownloadListener)
|
||||
every { binder.getDownload(request.uuid) } returns download
|
||||
|
||||
// WHEN
|
||||
// service is bound
|
||||
connection.onServiceConnected(componentName, binder)
|
||||
|
||||
// THEN
|
||||
// missed updates not redelivered
|
||||
verify(exactly = 0) { firstDownloadListener(any()) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider.getApplicationContext
|
||||
import androidx.test.rule.ServiceTestRule
|
||||
import com.nextcloud.client.account.MockUser
|
||||
import io.mockk.MockKAnnotations
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
class DownloaderServiceTest {
|
||||
|
||||
@get:Rule
|
||||
val service = ServiceTestRule.withTimeout(3, TimeUnit.SECONDS)
|
||||
|
||||
val user = MockUser()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
MockKAnnotations.init(this, relaxed = true)
|
||||
}
|
||||
|
||||
@Test(expected = TimeoutException::class)
|
||||
fun cannot_bind_to_service_without_user() {
|
||||
val intent = DownloaderService.createBindIntent(getApplicationContext(), user)
|
||||
intent.removeExtra(DownloaderService.EXTRA_USER)
|
||||
service.bindService(intent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bind_with_user() {
|
||||
val intent = DownloaderService.createBindIntent(getApplicationContext(), user)
|
||||
val binder = service.bindService(intent)
|
||||
assertTrue(binder is DownloaderService.Binder)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import com.nextcloud.client.account.User
|
||||
import com.nextcloud.client.core.ManualAsyncRunner
|
||||
import com.nextcloud.client.core.OnProgressCallback
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import com.owncloud.android.lib.common.OwnCloudClient
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Suite
|
||||
import org.mockito.MockitoAnnotations
|
||||
|
||||
@RunWith(Suite::class)
|
||||
@Suite.SuiteClasses(
|
||||
DownloaderTest.Enqueue::class,
|
||||
DownloaderTest.DownloadStatusUpdates::class
|
||||
)
|
||||
class DownloaderTest {
|
||||
|
||||
abstract class Base {
|
||||
|
||||
companion object {
|
||||
const val MAX_DOWNLOAD_THREADS = 4
|
||||
}
|
||||
|
||||
@MockK
|
||||
lateinit var user: User
|
||||
|
||||
@MockK
|
||||
lateinit var client: OwnCloudClient
|
||||
|
||||
@MockK
|
||||
lateinit var mockTaskFactory: DownloadTask.Factory
|
||||
|
||||
/**
|
||||
* All task mock functions created during test run are
|
||||
* stored here.
|
||||
*/
|
||||
lateinit var downloadTaskMocks: MutableList<DownloadTask>
|
||||
lateinit var runner: ManualAsyncRunner
|
||||
lateinit var downloader: DownloaderImpl
|
||||
|
||||
/**
|
||||
* Response value for all download tasks
|
||||
*/
|
||||
var downloadTaskResult: Boolean = true
|
||||
|
||||
/**
|
||||
* Progress values posted by all download task mocks before
|
||||
* returning result value
|
||||
*/
|
||||
var taskProgress = listOf<Int>()
|
||||
|
||||
@Before
|
||||
fun setUpBase() {
|
||||
MockKAnnotations.init(this, relaxed = true)
|
||||
MockitoAnnotations.initMocks(this)
|
||||
downloadTaskMocks = mutableListOf()
|
||||
runner = ManualAsyncRunner()
|
||||
downloader = DownloaderImpl(
|
||||
runner = runner,
|
||||
taskFactory = mockTaskFactory,
|
||||
threads = MAX_DOWNLOAD_THREADS
|
||||
)
|
||||
downloadTaskResult = true
|
||||
every { mockTaskFactory.create() } answers { createMockTask() }
|
||||
}
|
||||
|
||||
private fun createMockTask(): DownloadTask {
|
||||
val task = mockk<DownloadTask>()
|
||||
every { task.download(any(), any(), any()) } answers {
|
||||
taskProgress.forEach {
|
||||
arg<OnProgressCallback<Int>>(1).invoke(it)
|
||||
}
|
||||
val request = arg<Request>(0)
|
||||
DownloadTask.Result(request.file, downloadTaskResult)
|
||||
}
|
||||
downloadTaskMocks.add(task)
|
||||
return task
|
||||
}
|
||||
}
|
||||
|
||||
class Enqueue : Base() {
|
||||
|
||||
@Test
|
||||
fun enqueued_download_is_started_immediately() {
|
||||
// GIVEN
|
||||
// downloader has no running downloads
|
||||
|
||||
// WHEN
|
||||
// download is enqueued
|
||||
val file = OCFile("/path")
|
||||
val request = Request(user, file)
|
||||
downloader.download(request)
|
||||
|
||||
// THEN
|
||||
// download is started immediately
|
||||
val download = downloader.getDownload(request.uuid)
|
||||
assertEquals(DownloadState.RUNNING, download?.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enqueued_downloads_are_pending_if_running_queue_is_full() {
|
||||
// GIVEN
|
||||
// downloader is downloading max simultaneous files
|
||||
for (i in 0 until MAX_DOWNLOAD_THREADS) {
|
||||
val file = OCFile("/running/download/path/$i")
|
||||
val request = Request(user, file)
|
||||
downloader.download(request)
|
||||
val runningDownload = downloader.getDownload(request.uuid)
|
||||
assertEquals(runningDownload?.state, DownloadState.RUNNING)
|
||||
}
|
||||
|
||||
// WHEN
|
||||
// another download is enqueued
|
||||
val file = OCFile("/path")
|
||||
val request = Request(user, file)
|
||||
downloader.download(request)
|
||||
|
||||
// THEN
|
||||
// download is pending
|
||||
val download = downloader.getDownload(request.uuid)
|
||||
assertEquals(DownloadState.PENDING, download?.state)
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadStatusUpdates : Base() {
|
||||
|
||||
@get:Rule
|
||||
val rule = InstantTaskExecutorRule()
|
||||
|
||||
val file = OCFile("/path")
|
||||
|
||||
@Test
|
||||
fun download_task_completes() {
|
||||
// GIVEN
|
||||
// download is running
|
||||
// download is being observed
|
||||
val downloadUpdates = mutableListOf<Download>()
|
||||
downloader.registerDownloadListener { downloadUpdates.add(it) }
|
||||
downloader.download(Request(user, file))
|
||||
|
||||
// WHEN
|
||||
// download task finishes successfully
|
||||
runner.runOne()
|
||||
|
||||
// THEN
|
||||
// listener is notified about status change
|
||||
assertEquals(DownloadState.RUNNING, downloadUpdates[0].state)
|
||||
assertEquals(DownloadState.COMPLETED, downloadUpdates[1].state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun download_task_fails() {
|
||||
// GIVEN
|
||||
// download is running
|
||||
// download is being observed
|
||||
val downloadUpdates = mutableListOf<Download>()
|
||||
downloader.registerDownloadListener { downloadUpdates.add(it) }
|
||||
downloader.download(Request(user, file))
|
||||
|
||||
// WHEN
|
||||
// download task fails
|
||||
downloadTaskResult = false
|
||||
runner.runOne()
|
||||
|
||||
// THEN
|
||||
// listener is notified about status change
|
||||
assertEquals(DownloadState.RUNNING, downloadUpdates[0].state)
|
||||
assertEquals(DownloadState.FAILED, downloadUpdates[1].state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun download_progress_is_updated() {
|
||||
// GIVEN
|
||||
// download is running
|
||||
val downloadUpdates = mutableListOf<Download>()
|
||||
downloader.registerDownloadListener { downloadUpdates.add(it) }
|
||||
downloader.download(Request(user, file))
|
||||
|
||||
// WHEN
|
||||
// download progress updated 4 times before completion
|
||||
taskProgress = listOf(25, 50, 75, 100)
|
||||
runner.runOne()
|
||||
|
||||
// THEN
|
||||
// listener receives 6 status updates
|
||||
// transition to running
|
||||
// 4 progress updates
|
||||
// completion
|
||||
assertEquals(6, downloadUpdates.size)
|
||||
if (downloadUpdates.size >= 6) {
|
||||
assertEquals(DownloadState.RUNNING, downloadUpdates[0].state)
|
||||
assertEquals(25, downloadUpdates[1].progress)
|
||||
assertEquals(50, downloadUpdates[2].progress)
|
||||
assertEquals(75, downloadUpdates[3].progress)
|
||||
assertEquals(100, downloadUpdates[4].progress)
|
||||
assertEquals(DownloadState.COMPLETED, downloadUpdates[5].state)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun download_task_is_created_only_for_running_downloads() {
|
||||
// WHEN
|
||||
// multiple downloads are enqueued
|
||||
for (i in 0 until MAX_DOWNLOAD_THREADS * 2) {
|
||||
downloader.download(Request(user, file))
|
||||
}
|
||||
|
||||
// THEN
|
||||
// download task is created only for running downloads
|
||||
assertEquals(MAX_DOWNLOAD_THREADS, downloadTaskMocks.size)
|
||||
}
|
||||
}
|
||||
|
||||
class RunningStatusUpdates : Base() {
|
||||
|
||||
@get:Rule
|
||||
val rule = InstantTaskExecutorRule()
|
||||
|
||||
@Test
|
||||
fun is_running_flag_on_enqueue() {
|
||||
// WHEN
|
||||
// download is enqueued
|
||||
val file = OCFile("/path/to/file")
|
||||
val request = Request(user, file)
|
||||
downloader.download(request)
|
||||
|
||||
// THEN
|
||||
// is running changes
|
||||
assertTrue(downloader.isRunning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun is_running_flag_on_completion() {
|
||||
// GIVEN
|
||||
// a download is in progress
|
||||
val file = OCFile("/path/to/file")
|
||||
val request = Request(user, file)
|
||||
downloader.download(request)
|
||||
assertTrue(downloader.isRunning)
|
||||
|
||||
// WHEN
|
||||
// download is processed
|
||||
runner.runOne()
|
||||
|
||||
// THEN
|
||||
// downloader is not running
|
||||
assertFalse(downloader.isRunning)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,519 @@
|
|||
/*
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
import com.nextcloud.client.account.User
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import io.mockk.CapturingSlot
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.clearAllMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.verify
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Suite
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(Suite::class)
|
||||
@Suite.SuiteClasses(
|
||||
RegistryTest.Pending::class,
|
||||
RegistryTest.Start::class,
|
||||
RegistryTest.Progress::class,
|
||||
RegistryTest.Complete::class,
|
||||
RegistryTest.GetDownloads::class,
|
||||
RegistryTest.IsRunning::class
|
||||
)
|
||||
class RegistryTest {
|
||||
|
||||
abstract class Base {
|
||||
companion object {
|
||||
const val MAX_DOWNLOAD_THREADS = 4
|
||||
const val PROGRESS_FULL = 100
|
||||
const val PROGRESS_HALF = 50
|
||||
}
|
||||
|
||||
@MockK
|
||||
lateinit var user: User
|
||||
|
||||
lateinit var file: OCFile
|
||||
|
||||
@MockK
|
||||
lateinit var onDownloadStart: (UUID, Request) -> Unit
|
||||
|
||||
@MockK
|
||||
lateinit var onDownloadChanged: (Download) -> Unit
|
||||
|
||||
internal lateinit var registry: Registry
|
||||
|
||||
@Before
|
||||
fun setUpBase() {
|
||||
MockKAnnotations.init(this, relaxed = true)
|
||||
file = OCFile("/test/path")
|
||||
registry = Registry(onDownloadStart, onDownloadChanged, MAX_DOWNLOAD_THREADS)
|
||||
resetMocks()
|
||||
}
|
||||
|
||||
fun resetMocks() {
|
||||
clearAllMocks()
|
||||
every { onDownloadStart(any(), any()) } answers {}
|
||||
every { onDownloadChanged(any()) } answers {}
|
||||
}
|
||||
}
|
||||
|
||||
class Pending : Base() {
|
||||
|
||||
@Test
|
||||
fun inserting_pending_download() {
|
||||
// GIVEN
|
||||
// registry has no pending downloads
|
||||
assertEquals(0, registry.pending.size)
|
||||
|
||||
// WHEN
|
||||
// new download requests added
|
||||
val addedDownloadsCount = 10
|
||||
for (i in 0 until addedDownloadsCount) {
|
||||
val request = Request(user, file)
|
||||
registry.add(request)
|
||||
}
|
||||
|
||||
// THEN
|
||||
// download is added to the pending queue
|
||||
assertEquals(addedDownloadsCount, registry.pending.size)
|
||||
}
|
||||
}
|
||||
|
||||
class Start : Base() {
|
||||
|
||||
companion object {
|
||||
const val ENQUEUED_REQUESTS_COUNT = 10
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
for (i in 0 until ENQUEUED_REQUESTS_COUNT) {
|
||||
registry.add(Request(user, file))
|
||||
}
|
||||
assertEquals(ENQUEUED_REQUESTS_COUNT, registry.pending.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun starting_download() {
|
||||
// WHEN
|
||||
// started
|
||||
registry.startNext()
|
||||
|
||||
// THEN
|
||||
// up to max threads requests are started
|
||||
// start callback is triggered
|
||||
// update callback is triggered on download transition
|
||||
// started downloads are in running state
|
||||
assertEquals(
|
||||
"Downloads not moved to running queue",
|
||||
MAX_DOWNLOAD_THREADS,
|
||||
registry.running.size
|
||||
)
|
||||
assertEquals(
|
||||
"Downloads not moved from pending queue",
|
||||
ENQUEUED_REQUESTS_COUNT - MAX_DOWNLOAD_THREADS,
|
||||
registry.pending.size
|
||||
)
|
||||
verify(exactly = MAX_DOWNLOAD_THREADS) { onDownloadStart(any(), any()) }
|
||||
val startedDownloads = mutableListOf<Download>()
|
||||
verify(exactly = MAX_DOWNLOAD_THREADS) { onDownloadChanged(capture(startedDownloads)) }
|
||||
assertEquals(
|
||||
"Callbacks not invoked for running downloads",
|
||||
MAX_DOWNLOAD_THREADS,
|
||||
startedDownloads.size
|
||||
)
|
||||
startedDownloads.forEach {
|
||||
assertEquals("Download not placed into running state", DownloadState.RUNNING, it.state)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun start_is_ignored_if_no_more_free_threads() {
|
||||
// WHEN
|
||||
// max number of running downloads
|
||||
registry.startNext()
|
||||
assertEquals(MAX_DOWNLOAD_THREADS, registry.running.size)
|
||||
clearAllMocks()
|
||||
|
||||
// WHEN
|
||||
// starting more downloads
|
||||
registry.startNext()
|
||||
|
||||
// THEN
|
||||
// no more downloads can be started
|
||||
assertEquals(MAX_DOWNLOAD_THREADS, registry.running.size)
|
||||
verify(exactly = 0) { onDownloadStart(any(), any()) }
|
||||
}
|
||||
}
|
||||
|
||||
class Progress : Base() {
|
||||
|
||||
var uuid: UUID = UUID.randomUUID()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val request = Request(user, file)
|
||||
uuid = registry.add(request)
|
||||
registry.startNext()
|
||||
assertEquals(uuid, request.uuid)
|
||||
assertEquals(1, registry.running.size)
|
||||
resetMocks()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun download_progress_is_updated() {
|
||||
// GIVEN
|
||||
// a download is running
|
||||
|
||||
// WHEN
|
||||
// download progress is updated
|
||||
val progressHalf = 50
|
||||
registry.progress(uuid, progressHalf)
|
||||
|
||||
// THEN
|
||||
// progress is updated
|
||||
// update callback is invoked
|
||||
val download = mutableListOf<Download>()
|
||||
verify { onDownloadChanged(capture(download)) }
|
||||
assertEquals(1, download.size)
|
||||
assertEquals(progressHalf, download.first().progress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updates_for_non_running_downloads_are_ignored() {
|
||||
// GIVEN
|
||||
// download is not running
|
||||
registry.complete(uuid, true)
|
||||
assertEquals(0, registry.running.size)
|
||||
resetMocks()
|
||||
|
||||
// WHEN
|
||||
// progress for a non-running download is updated
|
||||
registry.progress(uuid, PROGRESS_HALF)
|
||||
|
||||
// THEN
|
||||
// progress update is ignored
|
||||
verify(exactly = 0) { onDownloadChanged(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updates_for_non_existing_downloads_are_ignored() {
|
||||
// GIVEN
|
||||
// some download is running
|
||||
|
||||
// WHEN
|
||||
// progress is updated for non-existing download
|
||||
val nonExistingDownloadId = UUID.randomUUID()
|
||||
registry.progress(nonExistingDownloadId, PROGRESS_HALF)
|
||||
|
||||
// THEN
|
||||
// progress uppdate is ignored
|
||||
verify(exactly = 0) { onDownloadChanged(any()) }
|
||||
}
|
||||
}
|
||||
|
||||
class Complete : Base() {
|
||||
|
||||
lateinit var uuid: UUID
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
uuid = registry.add(Request(user, file))
|
||||
registry.startNext()
|
||||
registry.progress(uuid, PROGRESS_FULL)
|
||||
resetMocks()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun complete_successful_download_with_updated_file() {
|
||||
// GIVEN
|
||||
// a download is running
|
||||
|
||||
// WHEN
|
||||
// download is completed
|
||||
// file has been updated
|
||||
val updatedFile = OCFile("/updated/file")
|
||||
registry.complete(uuid, true, updatedFile)
|
||||
|
||||
// THEN
|
||||
// download is completed successfully
|
||||
// status carries updated file
|
||||
val slot = CapturingSlot<Download>()
|
||||
verify { onDownloadChanged(capture(slot)) }
|
||||
assertEquals(DownloadState.COMPLETED, slot.captured.state)
|
||||
assertSame(slot.captured.file, updatedFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun complete_successful_download() {
|
||||
// GIVEN
|
||||
// a download is running
|
||||
|
||||
// WHEN
|
||||
// download is completed
|
||||
// file is not updated
|
||||
registry.complete(uuid = uuid, success = true, file = null)
|
||||
|
||||
// THEN
|
||||
// download is completed successfully
|
||||
// status carries previous file
|
||||
val slot = CapturingSlot<Download>()
|
||||
verify { onDownloadChanged(capture(slot)) }
|
||||
assertEquals(DownloadState.COMPLETED, slot.captured.state)
|
||||
assertSame(slot.captured.file, file)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun complete_failed_download() {
|
||||
// GIVEN
|
||||
// a download is running
|
||||
|
||||
// WHEN
|
||||
// download is failed
|
||||
registry.complete(uuid, false)
|
||||
|
||||
// THEN
|
||||
// download is completed successfully
|
||||
val slot = CapturingSlot<Download>()
|
||||
verify { onDownloadChanged(capture(slot)) }
|
||||
assertEquals(DownloadState.FAILED, slot.captured.state)
|
||||
}
|
||||
}
|
||||
|
||||
class GetDownloads : Base() {
|
||||
|
||||
val pendingDownloadFile = OCFile("/pending")
|
||||
val runningDownloadFile = OCFile("/running")
|
||||
val completedDownloadFile = OCFile("/completed")
|
||||
|
||||
lateinit var pendingDownloadId: UUID
|
||||
lateinit var runningDownloadId: UUID
|
||||
lateinit var completedDownloadId: UUID
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
completedDownloadId = registry.add(Request(user, completedDownloadFile))
|
||||
registry.startNext()
|
||||
registry.complete(completedDownloadId, true)
|
||||
|
||||
runningDownloadId = registry.add(Request(user, runningDownloadFile))
|
||||
registry.startNext()
|
||||
|
||||
pendingDownloadId = registry.add(Request(user, pendingDownloadFile))
|
||||
resetMocks()
|
||||
|
||||
assertEquals(1, registry.pending.size)
|
||||
assertEquals(1, registry.running.size)
|
||||
assertEquals(1, registry.completed.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun get_by_path_searches_pending_queue() {
|
||||
// GIVEN
|
||||
// file download is pending
|
||||
|
||||
// WHEN
|
||||
// download status is retrieved
|
||||
val download = registry.getDownload(pendingDownloadFile)
|
||||
|
||||
// THEN
|
||||
// download from pending queue is returned
|
||||
assertNotNull(download)
|
||||
assertEquals(pendingDownloadId, download?.uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun get_by_id_searches_pending_queue() {
|
||||
// GIVEN
|
||||
// file download is pending
|
||||
|
||||
// WHEN
|
||||
// download status is retrieved
|
||||
val download = registry.getDownload(pendingDownloadId)
|
||||
|
||||
// THEN
|
||||
// download from pending queue is returned
|
||||
assertNotNull(download)
|
||||
assertEquals(pendingDownloadId, download?.uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun get_by_path_searches_running_queue() {
|
||||
// GIVEN
|
||||
// file download is running
|
||||
|
||||
// WHEN
|
||||
// download status is retrieved
|
||||
val download = registry.getDownload(runningDownloadFile)
|
||||
|
||||
// THEN
|
||||
// download from pending queue is returned
|
||||
assertNotNull(download)
|
||||
assertEquals(runningDownloadId, download?.uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun get_by_id_searches_running_queue() {
|
||||
// GIVEN
|
||||
// file download is running
|
||||
|
||||
// WHEN
|
||||
// download status is retrieved
|
||||
val download = registry.getDownload(runningDownloadId)
|
||||
|
||||
// THEN
|
||||
// download from pending queue is returned
|
||||
assertNotNull(download)
|
||||
assertEquals(runningDownloadId, download?.uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun get_by_path_searches_completed_queue() {
|
||||
// GIVEN
|
||||
// file download is pending
|
||||
|
||||
// WHEN
|
||||
// download status is retrieved
|
||||
val download = registry.getDownload(completedDownloadFile)
|
||||
|
||||
// THEN
|
||||
// download from pending queue is returned
|
||||
assertNotNull(download)
|
||||
assertEquals(completedDownloadId, download?.uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun get_by_id_searches_completed_queue() {
|
||||
// GIVEN
|
||||
// file download is pending
|
||||
|
||||
// WHEN
|
||||
// download status is retrieved
|
||||
val download = registry.getDownload(completedDownloadId)
|
||||
|
||||
// THEN
|
||||
// download from pending queue is returned
|
||||
assertNotNull(download)
|
||||
assertEquals(completedDownloadId, download?.uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun not_found_by_path() {
|
||||
// GIVEN
|
||||
// no download for a file
|
||||
val nonExistingDownloadFile = OCFile("/non-nexisting/download")
|
||||
|
||||
// WHEN
|
||||
// download status is retrieved for a file
|
||||
val download = registry.getDownload(nonExistingDownloadFile)
|
||||
|
||||
// THEN
|
||||
// no download is found
|
||||
assertNull(download)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun not_found_by_id() {
|
||||
// GIVEN
|
||||
// no download for an id
|
||||
val nonExistingId = UUID.randomUUID()
|
||||
|
||||
// WHEN
|
||||
// download status is retrieved for a file
|
||||
val download = registry.getDownload(nonExistingId)
|
||||
|
||||
// THEN
|
||||
// no download is found
|
||||
assertNull(download)
|
||||
}
|
||||
}
|
||||
|
||||
class IsRunning : Base() {
|
||||
|
||||
@Test
|
||||
fun no_requests() {
|
||||
// WHEN
|
||||
// all queues empty
|
||||
assertEquals(0, registry.pending.size)
|
||||
assertEquals(0, registry.running.size)
|
||||
assertEquals(0, registry.completed.size)
|
||||
|
||||
// THEN
|
||||
// not running
|
||||
assertFalse(registry.isRunning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun request_pending() {
|
||||
// WHEN
|
||||
// request is enqueued
|
||||
registry.add(Request(user, OCFile("/path/alpha/1")))
|
||||
assertEquals(1, registry.pending.size)
|
||||
assertEquals(0, registry.running.size)
|
||||
assertEquals(0, registry.completed.size)
|
||||
|
||||
// THEN
|
||||
// is running
|
||||
assertTrue(registry.isRunning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun request_running() {
|
||||
// WHEN
|
||||
// request is running
|
||||
registry.add(Request(user, OCFile("/path/alpha/1")))
|
||||
registry.startNext()
|
||||
assertEquals(0, registry.pending.size)
|
||||
assertEquals(1, registry.running.size)
|
||||
assertEquals(0, registry.completed.size)
|
||||
|
||||
// THEN
|
||||
// is running
|
||||
assertTrue(registry.isRunning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun request_completed() {
|
||||
// WHEN
|
||||
// request is running
|
||||
val id = registry.add(Request(user, OCFile("/path/alpha/1")))
|
||||
registry.startNext()
|
||||
registry.complete(id, true)
|
||||
assertEquals(0, registry.pending.size)
|
||||
assertEquals(0, registry.running.size)
|
||||
assertEquals(1, registry.completed.size)
|
||||
|
||||
// THEN
|
||||
// is not running
|
||||
assertFalse(registry.isRunning)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -325,6 +325,7 @@
|
|||
|
||||
<service android:name=".services.OperationsService" />
|
||||
<service android:name=".files.services.FileDownloader" />
|
||||
<service android:name="com.nextcloud.client.files.downloader.DownloaderService" />
|
||||
<service android:name=".files.services.FileUploader" />
|
||||
<service android:name="com.nextcloud.client.media.PlayerService"/>
|
||||
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz <hello@ezaquarii.com>
|
||||
* Copyright (C) 2020 Chris Narkiewicz
|
||||
* Copyright (C) 2020 Nextcloud GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.owncloud.android.MainApp
|
||||
import com.owncloud.android.lib.common.OwnCloudAccount
|
||||
import com.owncloud.android.lib.common.OwnCloudBasicCredentials
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* This is a mock user object suitable for integration tests. Mocks obtained from code generators
|
||||
* such as Mockito or MockK cannot be transported in Intent extras.
|
||||
*/
|
||||
data class MockUser(override val accountName: String, val accountType: String) : User, Parcelable {
|
||||
|
||||
constructor() : this(DEFAULT_MOCK_ACCOUNT_NAME, DEFAULT_MOCK_ACCOUNT_TYPE)
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val CREATOR: Parcelable.Creator<MockUser> = object : Parcelable.Creator<MockUser> {
|
||||
override fun createFromParcel(source: Parcel): MockUser = MockUser(source)
|
||||
override fun newArray(size: Int): Array<MockUser?> = arrayOfNulls(size)
|
||||
}
|
||||
const val DEFAULT_MOCK_ACCOUNT_NAME = "mock_account_name"
|
||||
const val DEFAULT_MOCK_ACCOUNT_TYPE = "mock_account_type"
|
||||
}
|
||||
|
||||
private constructor(source: Parcel) : this(
|
||||
source.readString() as String,
|
||||
source.readString() as String
|
||||
)
|
||||
|
||||
override val server = Server(URI.create(""), MainApp.MINIMUM_SUPPORTED_SERVER_VERSION)
|
||||
override val isAnonymous = false
|
||||
|
||||
override fun toPlatformAccount(): Account {
|
||||
return Account(accountName, accountType)
|
||||
}
|
||||
|
||||
override fun toOwnCloudAccount(): OwnCloudAccount {
|
||||
return OwnCloudAccount(Uri.EMPTY, OwnCloudBasicCredentials("", ""))
|
||||
}
|
||||
|
||||
override fun nameEquals(user: User?): Boolean {
|
||||
return user?.accountName.equals(accountName, true)
|
||||
}
|
||||
|
||||
override fun nameEquals(accountName: CharSequence?): Boolean {
|
||||
return accountName?.toString().equals(this.accountType, true)
|
||||
}
|
||||
|
||||
override fun describeContents() = 0
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) = with(dest) {
|
||||
writeString(accountName)
|
||||
writeString(accountType)
|
||||
}
|
||||
}
|
|
@ -19,23 +19,53 @@
|
|||
*/
|
||||
package com.nextcloud.client.core
|
||||
|
||||
typealias OnResultCallback<T> = (T) -> Unit
|
||||
typealias OnErrorCallback = (Throwable) -> Unit
|
||||
typealias OnResultCallback<T> = (result: T) -> Unit
|
||||
typealias OnErrorCallback = (error: Throwable) -> Unit
|
||||
typealias OnProgressCallback<P> = (progress: P) -> Unit
|
||||
typealias IsCancelled = () -> Boolean
|
||||
typealias TaskFunction<RESULT, PROGRESS> = (
|
||||
onProgress: OnProgressCallback<PROGRESS>,
|
||||
isCancelled: IsCancelled
|
||||
) -> RESULT
|
||||
|
||||
/**
|
||||
* This interface allows to post background tasks that report results via callbacks invoked on main thread.
|
||||
*
|
||||
* It is provided as an alternative for heavy, platform specific and virtually untestable [android.os.AsyncTask]
|
||||
*
|
||||
* Please note that as of Android R, [android.os.AsyncTask] is deprecated and [java.util.concurrent] is a recommended
|
||||
* alternative.
|
||||
*/
|
||||
interface AsyncRunner {
|
||||
|
||||
/**
|
||||
* Post a quick background task and return immediately returning task cancellation interface.
|
||||
*
|
||||
* Quick task is a short piece of code that does not support interruption nor progress monitoring.
|
||||
*
|
||||
* @param task Task function returning result T; error shall be signalled by throwing an exception.
|
||||
* @param onResult Callback called when task function returns a result.
|
||||
* @param onError Callback called when task function throws an exception.
|
||||
* @return Cancellable interface, allowing cancellation of a running task.
|
||||
*/
|
||||
fun <RESULT> postQuickTask(
|
||||
task: () -> RESULT,
|
||||
onResult: OnResultCallback<RESULT>? = null,
|
||||
onError: OnErrorCallback? = null
|
||||
): Cancellable
|
||||
|
||||
/**
|
||||
* Post a background task and return immediately returning task cancellation interface.
|
||||
*
|
||||
* @param task Task function returning result T; error shall be signalled by throwing an exception.
|
||||
* @param onResult Callback called when task function returns a result
|
||||
* @param onError Callback called when task function throws an exception
|
||||
* @return Cancellable interface, allowing to cancel running task.
|
||||
* @param onResult Callback called when task function returns a result,
|
||||
* @param onError Callback called when task function throws an exception.
|
||||
* @param onProgress Callback called when task function reports progress update.
|
||||
* @return Cancellable interface, allowing cancellation of a running task.
|
||||
*/
|
||||
fun <T> post(task: () -> T, onResult: OnResultCallback<T>? = null, onError: OnErrorCallback? = null): Cancellable
|
||||
fun <RESULT, PROGRESS> postTask(
|
||||
task: TaskFunction<RESULT, PROGRESS>,
|
||||
onResult: OnResultCallback<RESULT>? = null,
|
||||
onError: OnErrorCallback? = null,
|
||||
onProgress: OnProgressCallback<PROGRESS>? = null
|
||||
): Cancellable
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.core
|
||||
|
||||
import android.app.Service
|
||||
import android.os.Binder
|
||||
|
||||
/**
|
||||
* This is a generic binder that provides access to a locally bound service instance.
|
||||
*/
|
||||
abstract class LocalBinder<S : Service>(val service: S) : Binder()
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.core
|
||||
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
|
||||
/**
|
||||
* This is a local service connection providing a foundation for service
|
||||
* communication logic.
|
||||
*
|
||||
* One can subclass it to create own service interaction API.
|
||||
*/
|
||||
abstract class LocalConnection<S : Service>(
|
||||
protected val context: Context
|
||||
) : ServiceConnection {
|
||||
|
||||
private var serviceBinder: LocalBinder<S>? = null
|
||||
val service: S? get() = serviceBinder?.service
|
||||
val isConnected: Boolean get() {
|
||||
return serviceBinder != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to create custom binding intent.
|
||||
* Default implementation returns null, which disables binding.
|
||||
*
|
||||
* @see [bind]
|
||||
*/
|
||||
protected open fun createBindIntent(): Intent? {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind local service. If [createBindIntent] returns null, it no-ops.
|
||||
*/
|
||||
fun bind() {
|
||||
createBindIntent()?.let {
|
||||
context.bindService(it, this, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbind service if it is bound.
|
||||
* If service is not bound, it no-ops.
|
||||
*/
|
||||
fun unbind() {
|
||||
if (isConnected) {
|
||||
onUnbind()
|
||||
context.unbindService(this)
|
||||
serviceBinder = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback called when connection binds to a service.
|
||||
* Any actions taken on service connection can be taken here.
|
||||
*/
|
||||
protected open fun onBound(binder: IBinder) {
|
||||
// default no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback called when service is about to be unbound.
|
||||
* Binder is still valid at this stage and can be used to
|
||||
* perform cleanups. After exiting this method, service will
|
||||
* no longer be available.
|
||||
*/
|
||||
protected open fun onUnbind() {
|
||||
// default no-op
|
||||
}
|
||||
|
||||
final override fun onServiceConnected(name: ComponentName, binder: IBinder) {
|
||||
if (binder !is LocalBinder<*>) {
|
||||
throw IllegalStateException("Binder is not extending ${LocalBinder::class.java.name}")
|
||||
}
|
||||
serviceBinder = binder as LocalBinder<S>
|
||||
onBound(binder)
|
||||
}
|
||||
|
||||
final override fun onServiceDisconnected(name: ComponentName) {
|
||||
serviceBinder = null
|
||||
}
|
||||
}
|
|
@ -27,14 +27,35 @@ import java.util.ArrayDeque
|
|||
*/
|
||||
class ManualAsyncRunner : AsyncRunner {
|
||||
|
||||
private val queue: ArrayDeque<Task<*>> = ArrayDeque()
|
||||
private val queue: ArrayDeque<Runnable> = ArrayDeque()
|
||||
|
||||
override fun <T> post(task: () -> T, onResult: OnResultCallback<T>?, onError: OnErrorCallback?): Cancellable {
|
||||
override fun <T> postQuickTask(
|
||||
task: () -> T,
|
||||
onResult: OnResultCallback<T>?,
|
||||
onError: OnErrorCallback?
|
||||
): Cancellable {
|
||||
return postTask(
|
||||
task = { _: OnProgressCallback<Any>, _: IsCancelled -> task.invoke() },
|
||||
onResult = onResult,
|
||||
onError = onError,
|
||||
onProgress = null
|
||||
)
|
||||
}
|
||||
|
||||
override fun <T, P> postTask(
|
||||
task: TaskFunction<T, P>,
|
||||
onResult: OnResultCallback<T>?,
|
||||
onError: OnErrorCallback?,
|
||||
onProgress: OnProgressCallback<P>?
|
||||
): Cancellable {
|
||||
val remove: Function1<Runnable, Boolean> = queue::remove
|
||||
val taskWrapper = Task(
|
||||
postResult = { it.run() },
|
||||
postResult = { it.run(); true },
|
||||
removeFromQueue = remove,
|
||||
taskBody = task,
|
||||
onSuccess = onResult,
|
||||
onError = onError
|
||||
onError = onError,
|
||||
onProgress = onProgress
|
||||
)
|
||||
queue.push(taskWrapper)
|
||||
return taskWrapper
|
||||
|
|
|
@ -22,23 +22,32 @@ package com.nextcloud.client.core
|
|||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* This is a wrapper for a function run in background.
|
||||
*
|
||||
* Runs task function and posts result if task is not cancelled.
|
||||
* This is a wrapper for a task function runing in background.
|
||||
* It executes task function and handles result or error delivery.
|
||||
*/
|
||||
internal class Task<T>(
|
||||
private val postResult: (Runnable) -> Unit,
|
||||
private val taskBody: () -> T,
|
||||
@Suppress("LongParameterList")
|
||||
internal class Task<T, P>(
|
||||
private val postResult: (Runnable) -> Boolean,
|
||||
private val removeFromQueue: (Runnable) -> Boolean,
|
||||
private val taskBody: TaskFunction<T, P>,
|
||||
private val onSuccess: OnResultCallback<T>?,
|
||||
private val onError: OnErrorCallback?
|
||||
private val onError: OnErrorCallback?,
|
||||
private val onProgress: OnProgressCallback<P>?
|
||||
) : Runnable, Cancellable {
|
||||
|
||||
val isCancelled: Boolean
|
||||
get() = cancelled.get()
|
||||
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
|
||||
private fun postProgress(p: P) {
|
||||
postResult(Runnable { onProgress?.invoke(p) })
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught") // this is exactly what we want here
|
||||
override fun run() {
|
||||
@Suppress("TooGenericExceptionCaught") // this is exactly what we want here
|
||||
try {
|
||||
val result = taskBody.invoke()
|
||||
val result = taskBody.invoke({ postProgress(it) }, this::isCancelled)
|
||||
if (!cancelled.get()) {
|
||||
postResult.invoke(
|
||||
Runnable {
|
||||
|
@ -51,9 +60,11 @@ internal class Task<T>(
|
|||
postResult(Runnable { onError?.invoke(t) })
|
||||
}
|
||||
}
|
||||
removeFromQueue(this)
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
cancelled.set(true)
|
||||
removeFromQueue(this)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,17 +28,44 @@ import java.util.concurrent.ScheduledThreadPoolExecutor
|
|||
*
|
||||
* Tasks are run on multi-threaded pool. If serialized execution is desired, set [corePoolSize] to 1.
|
||||
*/
|
||||
internal class ThreadPoolAsyncRunner(private val uiThreadHandler: Handler, corePoolSize: Int) : AsyncRunner {
|
||||
internal class ThreadPoolAsyncRunner(
|
||||
private val uiThreadHandler: Handler,
|
||||
corePoolSize: Int,
|
||||
val tag: String = "default"
|
||||
) : AsyncRunner {
|
||||
|
||||
private val executor = ScheduledThreadPoolExecutor(corePoolSize)
|
||||
|
||||
override fun <T> post(task: () -> T, onResult: OnResultCallback<T>?, onError: OnErrorCallback?): Cancellable {
|
||||
val taskWrapper = Task(this::postResult, task, onResult, onError)
|
||||
override fun <T> postQuickTask(
|
||||
task: () -> T,
|
||||
onResult: OnResultCallback<T>?,
|
||||
onError: OnErrorCallback?
|
||||
): Cancellable {
|
||||
val taskAdapter = { _: OnProgressCallback<Void>, _: IsCancelled -> task.invoke() }
|
||||
return postTask(
|
||||
taskAdapter,
|
||||
onResult,
|
||||
onError,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
override fun <T, P> postTask(
|
||||
task: TaskFunction<T, P>,
|
||||
onResult: OnResultCallback<T>?,
|
||||
onError: OnErrorCallback?,
|
||||
onProgress: OnProgressCallback<P>?
|
||||
): Cancellable {
|
||||
val remove: Function1<Runnable, Boolean> = executor::remove
|
||||
val taskWrapper = Task(
|
||||
postResult = uiThreadHandler::post,
|
||||
removeFromQueue = remove,
|
||||
taskBody = task,
|
||||
onSuccess = onResult,
|
||||
onError = onError,
|
||||
onProgress = onProgress
|
||||
)
|
||||
executor.execute(taskWrapper)
|
||||
return taskWrapper
|
||||
}
|
||||
|
||||
private fun postResult(r: Runnable) {
|
||||
uiThreadHandler.post(r)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -21,15 +21,20 @@ package com.nextcloud.client.etm
|
|||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Resources
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.nextcloud.client.account.User
|
||||
import com.nextcloud.client.account.UserAccountManager
|
||||
import com.nextcloud.client.etm.pages.EtmAccountsFragment
|
||||
import com.nextcloud.client.etm.pages.EtmBackgroundJobsFragment
|
||||
import com.nextcloud.client.etm.pages.EtmDownloaderFragment
|
||||
import com.nextcloud.client.etm.pages.EtmMigrations
|
||||
import com.nextcloud.client.etm.pages.EtmPreferencesFragment
|
||||
import com.nextcloud.client.files.downloader.DownloaderConnection
|
||||
import com.nextcloud.client.jobs.BackgroundJobManager
|
||||
import com.nextcloud.client.jobs.JobInfo
|
||||
import com.nextcloud.client.migrations.MigrationInfo
|
||||
|
@ -41,8 +46,10 @@ import javax.inject.Inject
|
|||
|
||||
@Suppress("LongParameterList") // Dependencies Injection
|
||||
class EtmViewModel @Inject constructor(
|
||||
private val context: Context,
|
||||
private val defaultPreferences: SharedPreferences,
|
||||
private val platformAccountManager: AccountManager,
|
||||
private val accountManager: UserAccountManager,
|
||||
private val resources: Resources,
|
||||
private val backgroundJobManager: BackgroundJobManager,
|
||||
private val migrationsManager: MigrationsManager,
|
||||
|
@ -71,6 +78,7 @@ class EtmViewModel @Inject constructor(
|
|||
*/
|
||||
data class AccountData(val account: Account, val userData: Map<String, String?>)
|
||||
|
||||
val currentUser: User get() = accountManager.user
|
||||
val currentPage: LiveData<EtmMenuEntry?> = MutableLiveData()
|
||||
val pages: List<EtmMenuEntry> = listOf(
|
||||
EtmMenuEntry(
|
||||
|
@ -92,8 +100,14 @@ class EtmViewModel @Inject constructor(
|
|||
iconRes = R.drawable.ic_arrow_up,
|
||||
titleRes = R.string.etm_migrations,
|
||||
pageClass = EtmMigrations::class
|
||||
),
|
||||
EtmMenuEntry(
|
||||
iconRes = R.drawable.ic_download_grey600,
|
||||
titleRes = R.string.etm_downloader,
|
||||
pageClass = EtmDownloaderFragment::class
|
||||
)
|
||||
)
|
||||
val downloaderConnection = DownloaderConnection(context, accountManager.user)
|
||||
|
||||
val preferences: Map<String, String> get() {
|
||||
return defaultPreferences.all
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
package com.nextcloud.client.etm.pages
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.nextcloud.client.etm.EtmBaseFragment
|
||||
import com.nextcloud.client.files.downloader.Download
|
||||
import com.nextcloud.client.files.downloader.Downloader
|
||||
import com.nextcloud.client.files.downloader.Request
|
||||
import com.owncloud.android.R
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
|
||||
class EtmDownloaderFragment : EtmBaseFragment() {
|
||||
|
||||
companion object {
|
||||
private const val TEST_DOWNLOAD_DUMMY_PATH = "/test/dummy_file.txt"
|
||||
}
|
||||
|
||||
class Adapter(private val inflater: LayoutInflater) : RecyclerView.Adapter<Adapter.ViewHolder>() {
|
||||
|
||||
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val uuid = view.findViewById<TextView>(R.id.etm_download_uuid)
|
||||
val path = view.findViewById<TextView>(R.id.etm_download_path)
|
||||
val user = view.findViewById<TextView>(R.id.etm_download_user)
|
||||
val state = view.findViewById<TextView>(R.id.etm_download_state)
|
||||
val progress = view.findViewById<TextView>(R.id.etm_download_progress)
|
||||
private val progressRow = view.findViewById<View>(R.id.etm_download_progress_row)
|
||||
|
||||
var progressEnabled: Boolean = progressRow.visibility == View.VISIBLE
|
||||
get() {
|
||||
return progressRow.visibility == View.VISIBLE
|
||||
}
|
||||
set(value) {
|
||||
field = value
|
||||
progressRow.visibility = if (value) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var downloads = listOf<Download>()
|
||||
|
||||
fun setStatus(status: Downloader.Status) {
|
||||
downloads = listOf(status.pending, status.running, status.completed).flatten().reversed()
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = inflater.inflate(R.layout.etm_download_list_item, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return downloads.size
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(vh: ViewHolder, position: Int) {
|
||||
val download = downloads[position]
|
||||
vh.uuid.text = download.uuid.toString()
|
||||
vh.path.text = download.request.file.remotePath
|
||||
vh.user.text = download.request.user.accountName
|
||||
vh.state.text = download.state.toString()
|
||||
if (download.progress >= 0) {
|
||||
vh.progressEnabled = true
|
||||
vh.progress.text = download.progress.toString()
|
||||
} else {
|
||||
vh.progressEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var adapter: Adapter
|
||||
private lateinit var list: RecyclerView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_etm_downloader, container, false)
|
||||
adapter = Adapter(inflater)
|
||||
list = view.findViewById(R.id.etm_download_list)
|
||||
list.layoutManager = LinearLayoutManager(context)
|
||||
list.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
|
||||
list.adapter = adapter
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
vm.downloaderConnection.bind()
|
||||
vm.downloaderConnection.registerStatusListener(this::onDownloaderStatusChanged)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
vm.downloaderConnection.unbind()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
inflater.inflate(R.menu.fragment_etm_downloader, menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.etm_test_download -> {
|
||||
scheduleTestDownload(); true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleTestDownload() {
|
||||
val request = Request(user = vm.currentUser, file = OCFile(TEST_DOWNLOAD_DUMMY_PATH), test = true)
|
||||
vm.downloaderConnection.download(request)
|
||||
}
|
||||
|
||||
private fun onDownloaderStatusChanged(status: Downloader.Status) {
|
||||
adapter.setStatus(status)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* This class represents current download process state.
|
||||
* This object is immutable by design.
|
||||
*
|
||||
* NOTE: Although [OCFile] object is mutable, it is caused by shortcomings
|
||||
* of legacy design; please behave like an adult and treat it as immutable value.
|
||||
*
|
||||
* @property uuid Unique download process id
|
||||
* @property state current download state
|
||||
* @property progress download progress, 0-100 percent
|
||||
* @property file downloaded file, if download is in progress or failed, it is remote; if finished successfully - local
|
||||
* @property request initial download request
|
||||
*/
|
||||
data class Download(
|
||||
val uuid: UUID,
|
||||
val state: DownloadState,
|
||||
val progress: Int,
|
||||
val file: OCFile,
|
||||
val request: Request
|
||||
) {
|
||||
/**
|
||||
* True if download is no longer running, false if it is still being processed.
|
||||
*/
|
||||
val isFinished: Boolean get() = state == DownloadState.COMPLETED || state == DownloadState.FAILED
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
enum class DownloadState {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
COMPLETED,
|
||||
FAILED
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import com.nextcloud.client.core.IsCancelled
|
||||
import com.owncloud.android.datamodel.FileDataStorageManager
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import com.owncloud.android.lib.common.OwnCloudClient
|
||||
import com.owncloud.android.operations.DownloadFileOperation
|
||||
import com.owncloud.android.utils.MimeTypeUtil
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* This runnable object encapsulates file download logic. It has been extracted to wrap
|
||||
* network operation and storage manager interactions, as those pose testing challenges
|
||||
* that cannot be addressed due to large number of dependencies.
|
||||
*
|
||||
* This design can be regarded as intermediary refactoring step.
|
||||
*/
|
||||
class DownloadTask(
|
||||
val context: Context,
|
||||
val contentResolver: ContentResolver,
|
||||
val clientProvider: () -> OwnCloudClient
|
||||
) {
|
||||
|
||||
data class Result(val file: OCFile, val success: Boolean)
|
||||
|
||||
/**
|
||||
* This class is a helper factory to to keep static dependencies
|
||||
* injection out of the downloader instance.
|
||||
*
|
||||
* @param context Context
|
||||
* @param clientProvider Provide client - this must be called on background thread
|
||||
* @param contentResolver content resovler used to access file storage
|
||||
*/
|
||||
class Factory(
|
||||
private val context: Context,
|
||||
private val clientProvider: () -> OwnCloudClient,
|
||||
private val contentResolver: ContentResolver
|
||||
) {
|
||||
fun create(): DownloadTask {
|
||||
return DownloadTask(context, contentResolver, clientProvider)
|
||||
}
|
||||
}
|
||||
|
||||
fun download(request: Request, progress: (Int) -> Unit, isCancelled: IsCancelled): Result {
|
||||
val op = DownloadFileOperation(request.user.toPlatformAccount(), request.file, context)
|
||||
val client = clientProvider.invoke()
|
||||
val result = op.execute(client)
|
||||
if (result.isSuccess) {
|
||||
val storageManager = FileDataStorageManager(
|
||||
request.user.toPlatformAccount(),
|
||||
contentResolver
|
||||
)
|
||||
val file = saveDownloadedFile(op, storageManager)
|
||||
return Result(file, true)
|
||||
} else {
|
||||
return Result(request.file, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveDownloadedFile(op: DownloadFileOperation, storageManager: FileDataStorageManager): OCFile {
|
||||
val file = storageManager.getFileById(op.getFile().getFileId()) as OCFile
|
||||
val syncDate = System.currentTimeMillis()
|
||||
file.lastSyncDateForProperties = syncDate
|
||||
file.lastSyncDateForData = syncDate
|
||||
file.isUpdateThumbnailNeeded = true
|
||||
file.modificationTimestamp = op.getModificationTimestamp()
|
||||
file.modificationTimestampAtLastSyncForData = op.getModificationTimestamp()
|
||||
file.etag = op.getEtag()
|
||||
file.mimeType = op.getMimeType()
|
||||
file.storagePath = op.getSavePath()
|
||||
file.fileLength = File(op.getSavePath()).length()
|
||||
file.remoteId = op.getFile().getRemoteId()
|
||||
storageManager.saveFile(file)
|
||||
if (MimeTypeUtil.isMedia(op.getMimeType())) {
|
||||
FileDataStorageManager.triggerMediaScan(file.storagePath)
|
||||
}
|
||||
return file
|
||||
}
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import java.util.UUID
|
||||
|
||||
interface Downloader {
|
||||
|
||||
/**
|
||||
* Snapshot of downloader status. All data is immutable and can be safely shared.
|
||||
*/
|
||||
data class Status(
|
||||
val pending: List<Download>,
|
||||
val running: List<Download>,
|
||||
val completed: List<Download>
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = Status(emptyList(), emptyList(), emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if downloader has any pending or running downloads.
|
||||
*/
|
||||
val isRunning: Boolean
|
||||
|
||||
/**
|
||||
* Status snapshot of all downloads.
|
||||
*/
|
||||
val status: Status
|
||||
|
||||
/**
|
||||
* Register download progress listener. Registration is idempotent - listener can be registered only once.
|
||||
*/
|
||||
fun registerDownloadListener(listener: (Download) -> Unit)
|
||||
|
||||
/**
|
||||
* Removes registered listener if exists.
|
||||
*/
|
||||
fun removeDownloadListener(listener: (Download) -> Unit)
|
||||
|
||||
/**
|
||||
* Register downloader status listener. Registration is idempotent - listener can be registered only once.
|
||||
*/
|
||||
fun registerStatusListener(listener: (Status) -> Unit)
|
||||
|
||||
/**
|
||||
* Removes registered listener if exists.
|
||||
*/
|
||||
fun removeStatusListener(listener: (Status) -> Unit)
|
||||
|
||||
/**
|
||||
* Adds download request to pending queue and returns immediately.
|
||||
*
|
||||
* @param request Download request
|
||||
*/
|
||||
fun download(request: Request)
|
||||
|
||||
/**
|
||||
* Find download status by UUID.
|
||||
*
|
||||
* @param uuid Download process uuid
|
||||
* @return download status or null if not found
|
||||
*/
|
||||
fun getDownload(uuid: UUID): Download?
|
||||
|
||||
/**
|
||||
* Query user's downloader for a download status. It performs linear search
|
||||
* of all queues and returns first download matching [OCFile.remotePath].
|
||||
*
|
||||
* Since there can be multiple downloads with identical file in downloader's queues,
|
||||
* order of search matters.
|
||||
*
|
||||
* It looks for pending downloads first, then running and completed queue last.
|
||||
*
|
||||
* @param file Downloaded file
|
||||
* @return download status or null, if download does not exist
|
||||
*/
|
||||
fun getDownload(file: OCFile): Download?
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import com.nextcloud.client.account.User
|
||||
import com.nextcloud.client.core.LocalConnection
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import java.util.UUID
|
||||
|
||||
class DownloaderConnection(context: Context, val user: User) : LocalConnection<DownloaderService>(context), Downloader {
|
||||
|
||||
private var downloadListeners: MutableSet<(Download) -> Unit> = mutableSetOf()
|
||||
private var statusListeners: MutableSet<(Downloader.Status) -> Unit> = mutableSetOf()
|
||||
private var binder: DownloaderService.Binder? = null
|
||||
private val downloadsRequiringStatusRedelivery: MutableSet<UUID> = mutableSetOf()
|
||||
|
||||
override val isRunning: Boolean
|
||||
get() = binder?.isRunning ?: false
|
||||
|
||||
override val status: Downloader.Status
|
||||
get() = binder?.status ?: Downloader.Status.EMPTY
|
||||
|
||||
override fun getDownload(uuid: UUID): Download? = binder?.getDownload(uuid)
|
||||
|
||||
override fun getDownload(file: OCFile): Download? = binder?.getDownload(file)
|
||||
|
||||
override fun download(request: Request) {
|
||||
val intent = DownloaderService.createDownloadIntent(context, request)
|
||||
context.startService(intent)
|
||||
if (!isConnected && downloadListeners.size > 0) {
|
||||
downloadsRequiringStatusRedelivery.add(request.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun registerDownloadListener(listener: (Download) -> Unit) {
|
||||
downloadListeners.add(listener)
|
||||
binder?.registerDownloadListener(listener)
|
||||
}
|
||||
|
||||
override fun removeDownloadListener(listener: (Download) -> Unit) {
|
||||
downloadListeners.remove(listener)
|
||||
binder?.removeDownloadListener(listener)
|
||||
}
|
||||
|
||||
override fun registerStatusListener(listener: (Downloader.Status) -> Unit) {
|
||||
statusListeners.add(listener)
|
||||
binder?.registerStatusListener(listener)
|
||||
}
|
||||
|
||||
override fun removeStatusListener(listener: (Downloader.Status) -> Unit) {
|
||||
statusListeners.remove(listener)
|
||||
binder?.removeStatusListener(listener)
|
||||
}
|
||||
|
||||
override fun createBindIntent(): Intent {
|
||||
return DownloaderService.createBindIntent(context, user)
|
||||
}
|
||||
|
||||
override fun onBound(binder: IBinder) {
|
||||
super.onBound(binder)
|
||||
this.binder = binder as DownloaderService.Binder
|
||||
downloadListeners.forEach { listener ->
|
||||
binder.registerDownloadListener(listener)
|
||||
}
|
||||
statusListeners.forEach { listener ->
|
||||
binder.registerStatusListener(listener)
|
||||
}
|
||||
deliverMissedUpdates()
|
||||
}
|
||||
|
||||
/**
|
||||
* Since binding and download start are both asynchronous and the order
|
||||
* is not guaranteed, some downloads might already finish when service is bound,
|
||||
* resulting in lost notifications.
|
||||
*
|
||||
* Deliver all updates for pending downloads that were scheduled
|
||||
* before service has been bound.
|
||||
*/
|
||||
private fun deliverMissedUpdates() {
|
||||
val downloadUpdates = downloadsRequiringStatusRedelivery.mapNotNull { uuid ->
|
||||
binder?.getDownload(uuid)
|
||||
}
|
||||
downloadListeners.forEach { listener ->
|
||||
downloadUpdates.forEach { update ->
|
||||
listener.invoke(update)
|
||||
}
|
||||
}
|
||||
downloadsRequiringStatusRedelivery.clear()
|
||||
|
||||
if (statusListeners.isNotEmpty()) {
|
||||
binder?.status?.let { status ->
|
||||
statusListeners.forEach { it.invoke(status) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUnbind() {
|
||||
super.onUnbind()
|
||||
downloadListeners.forEach { binder?.removeDownloadListener(it) }
|
||||
statusListeners.forEach { binder?.removeStatusListener(it) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
import com.nextcloud.client.core.AsyncRunner
|
||||
import com.nextcloud.client.core.IsCancelled
|
||||
import com.nextcloud.client.core.OnProgressCallback
|
||||
import com.nextcloud.client.core.TaskFunction
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Per-user file downloader.
|
||||
*
|
||||
* All notifications are performed on main thread. All download processes are run
|
||||
* in the background.
|
||||
*
|
||||
* @param runner Background task runner. It is important to provide runner that is not shared with UI code.
|
||||
* @param taskFactory Download task factory
|
||||
* @param threads maximum number of concurrent download processes
|
||||
*/
|
||||
@Suppress("LongParameterList") // download operations requires those resources
|
||||
class DownloaderImpl(
|
||||
private val runner: AsyncRunner,
|
||||
private val taskFactory: DownloadTask.Factory,
|
||||
threads: Int = 1
|
||||
) : Downloader {
|
||||
|
||||
companion object {
|
||||
const val PROGRESS_PERCENTAGE_MAX = 100
|
||||
const val PROGRESS_PERCENTAGE_MIN = 0
|
||||
const val TEST_DOWNLOAD_PROGRESS_UPDATE_PERIOD_MS = 200L
|
||||
}
|
||||
|
||||
private val registry = Registry(
|
||||
onStartDownload = this::onStartDownload,
|
||||
onDownloadChanged = this::onDownloadUpdate,
|
||||
maxRunning = threads
|
||||
)
|
||||
private val downloadListeners: MutableSet<(Download) -> Unit> = mutableSetOf()
|
||||
private val statusListeners: MutableSet<(Downloader.Status) -> Unit> = mutableSetOf()
|
||||
|
||||
override val isRunning: Boolean get() = registry.isRunning
|
||||
|
||||
override val status: Downloader.Status
|
||||
get() = Downloader.Status(
|
||||
pending = registry.pending,
|
||||
running = registry.running,
|
||||
completed = registry.completed
|
||||
)
|
||||
|
||||
override fun registerDownloadListener(listener: (Download) -> Unit) {
|
||||
downloadListeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeDownloadListener(listener: (Download) -> Unit) {
|
||||
downloadListeners.remove(listener)
|
||||
}
|
||||
|
||||
override fun registerStatusListener(listener: (Downloader.Status) -> Unit) {
|
||||
statusListeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeStatusListener(listener: (Downloader.Status) -> Unit) {
|
||||
statusListeners.remove(listener)
|
||||
}
|
||||
|
||||
override fun download(request: Request) {
|
||||
registry.add(request)
|
||||
registry.startNext()
|
||||
}
|
||||
|
||||
override fun getDownload(uuid: UUID): Download? = registry.getDownload(uuid)
|
||||
|
||||
override fun getDownload(file: OCFile): Download? = registry.getDownload(file)
|
||||
|
||||
private fun onStartDownload(uuid: UUID, request: Request) {
|
||||
val downloadTask = createDownloadTask(request)
|
||||
runner.postTask(
|
||||
task = downloadTask,
|
||||
onProgress = { progress: Int -> registry.progress(uuid, progress) },
|
||||
onResult = { result -> registry.complete(uuid, result.success, result.file); registry.startNext() },
|
||||
onError = { registry.complete(uuid, false); registry.startNext() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun createDownloadTask(request: Request): TaskFunction<DownloadTask.Result, Int> {
|
||||
return if (request.test) {
|
||||
{ progress: OnProgressCallback<Int>, isCancelled: IsCancelled ->
|
||||
testDownloadTask(request.file, progress, isCancelled)
|
||||
}
|
||||
} else {
|
||||
val downloadTask = taskFactory.create()
|
||||
val wrapper: TaskFunction<DownloadTask.Result, Int> = { progress: ((Int) -> Unit), isCancelled ->
|
||||
downloadTask.download(request, progress, isCancelled)
|
||||
}
|
||||
wrapper
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDownloadUpdate(download: Download) {
|
||||
downloadListeners.forEach { it.invoke(download) }
|
||||
if (statusListeners.isNotEmpty()) {
|
||||
val status = this.status
|
||||
statusListeners.forEach { it.invoke(status) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test download task is used only to simulate download process without
|
||||
* any network traffic. It is used for development.
|
||||
*/
|
||||
private fun testDownloadTask(
|
||||
file: OCFile,
|
||||
onProgress: OnProgressCallback<Int>,
|
||||
isCancelled: IsCancelled
|
||||
): DownloadTask.Result {
|
||||
for (i in PROGRESS_PERCENTAGE_MIN..PROGRESS_PERCENTAGE_MAX) {
|
||||
onProgress(i)
|
||||
if (isCancelled()) {
|
||||
return DownloadTask.Result(file, false)
|
||||
}
|
||||
Thread.sleep(TEST_DOWNLOAD_PROGRESS_UPDATE_PERIOD_MS)
|
||||
}
|
||||
return DownloadTask.Result(file, true)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import com.nextcloud.client.account.User
|
||||
import com.nextcloud.client.core.AsyncRunner
|
||||
import com.nextcloud.client.core.LocalBinder
|
||||
import com.nextcloud.client.logger.Logger
|
||||
import com.nextcloud.client.network.ClientFactory
|
||||
import com.nextcloud.client.notifications.AppNotificationManager
|
||||
import dagger.android.AndroidInjection
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
|
||||
class DownloaderService : Service() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "DownloaderService"
|
||||
const val ACTION_DOWNLOAD = "download"
|
||||
const val EXTRA_REQUEST = "request"
|
||||
const val EXTRA_USER = "user"
|
||||
|
||||
fun createBindIntent(context: Context, user: User): Intent {
|
||||
return Intent(context, DownloaderService::class.java).apply {
|
||||
putExtra(EXTRA_USER, user)
|
||||
}
|
||||
}
|
||||
|
||||
fun createDownloadIntent(context: Context, request: Request): Intent {
|
||||
return Intent(context, DownloaderService::class.java).apply {
|
||||
action = ACTION_DOWNLOAD
|
||||
putExtra(EXTRA_REQUEST, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binder forwards [Downloader] API calls to selected instance of downloader.
|
||||
*/
|
||||
class Binder(
|
||||
downloader: DownloaderImpl,
|
||||
service: DownloaderService
|
||||
) : LocalBinder<DownloaderService>(service),
|
||||
Downloader by downloader
|
||||
|
||||
@Inject
|
||||
lateinit var notificationsManager: AppNotificationManager
|
||||
|
||||
@Inject
|
||||
lateinit var clientFactory: ClientFactory
|
||||
|
||||
@Inject
|
||||
@Named("io")
|
||||
lateinit var runner: AsyncRunner
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
val isRunning: Boolean get() = downloaders.any { it.value.isRunning }
|
||||
|
||||
private val downloaders: MutableMap<String, DownloaderImpl> = mutableMapOf()
|
||||
|
||||
override fun onCreate() {
|
||||
AndroidInjection.inject(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
if (intent.action != ACTION_DOWNLOAD) {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (!isRunning) {
|
||||
startForeground(
|
||||
AppNotificationManager.DOWNLOAD_NOTIFICATION_ID,
|
||||
notificationsManager.buildDownloadServiceForegroundNotification()
|
||||
)
|
||||
}
|
||||
|
||||
val request = intent.getParcelableExtra(EXTRA_REQUEST) as Request
|
||||
val downloader = getDownloader(request.user)
|
||||
downloader.download(request)
|
||||
|
||||
logger.d(TAG, "Enqueued new download: ${request.uuid} ${request.file.remotePath}")
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
val user = intent?.getParcelableExtra<User>(EXTRA_USER)
|
||||
if (user != null) {
|
||||
return Binder(getDownloader(user), this)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDownloadUpdate(download: Download) {
|
||||
if (!isRunning) {
|
||||
logger.d(TAG, "All downloads completed")
|
||||
notificationsManager.cancelDownloadProgress()
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
} else {
|
||||
notificationsManager.postDownloadProgress(
|
||||
fileOwner = download.request.user,
|
||||
file = download.request.file,
|
||||
progress = download.progress,
|
||||
allowPreview = !download.request.test
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
logger.d(TAG, "Stopping downloader service")
|
||||
}
|
||||
|
||||
private fun getDownloader(user: User): DownloaderImpl {
|
||||
val existingDownloader = downloaders[user.accountName]
|
||||
return if (existingDownloader != null) {
|
||||
existingDownloader
|
||||
} else {
|
||||
val downloadTaskFactory = DownloadTask.Factory(
|
||||
applicationContext,
|
||||
{ clientFactory.create(user) },
|
||||
contentResolver
|
||||
)
|
||||
val newDownloader = DownloaderImpl(runner, downloadTaskFactory)
|
||||
newDownloader.registerDownloadListener(this::onDownloadUpdate)
|
||||
downloaders[user.accountName] = newDownloader
|
||||
newDownloader
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import java.lang.IllegalStateException
|
||||
import java.util.TreeMap
|
||||
import java.util.UUID
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* This class tracks status of all downloads. It serves as a state
|
||||
* machine and drives the download background task scheduler via callbacks.
|
||||
* Download status updates are triggering change callbacks that should be used
|
||||
* to notify listeners.
|
||||
*
|
||||
* No listener registration mechanism is provided at this level.
|
||||
*
|
||||
* This class is not thread-safe. All access from multiple threads shall
|
||||
* be lock protected.
|
||||
*
|
||||
* @property onStartDownload callback triggered when download is switched into running state
|
||||
* @property onDownloadChanged callback triggered whenever download status update
|
||||
* @property maxRunning maximum number of allowed simultaneous downloads
|
||||
*/
|
||||
internal class Registry(
|
||||
private val onStartDownload: (UUID, Request) -> Unit,
|
||||
private val onDownloadChanged: (Download) -> Unit,
|
||||
private val maxRunning: Int = 2
|
||||
) {
|
||||
private val pendingQueue = TreeMap<UUID, Download>()
|
||||
private val runningQueue = TreeMap<UUID, Download>()
|
||||
private val completedQueue = TreeMap<UUID, Download>()
|
||||
|
||||
val isRunning: Boolean get() = pendingQueue.size > 0 || runningQueue.size > 0
|
||||
|
||||
val pending: List<Download> get() = pendingQueue.map { it.value }
|
||||
val running: List<Download> get() = runningQueue.map { it.value }
|
||||
val completed: List<Download> get() = completedQueue.map { it.value }
|
||||
|
||||
/**
|
||||
* Insert new download into a pending queue.
|
||||
*
|
||||
* @return scheduled download id
|
||||
*/
|
||||
fun add(request: Request): UUID {
|
||||
val download = Download(
|
||||
uuid = request.uuid,
|
||||
state = DownloadState.PENDING,
|
||||
progress = 0,
|
||||
file = request.file,
|
||||
request = request
|
||||
)
|
||||
pendingQueue[download.uuid] = download
|
||||
return download.uuid
|
||||
}
|
||||
|
||||
/**
|
||||
* Move pending downloads into a running queue up
|
||||
* to max allowed simultaneous downloads.
|
||||
*/
|
||||
fun startNext() {
|
||||
val freeThreads = max(0, maxRunning - runningQueue.size)
|
||||
for (i in 0 until min(freeThreads, pendingQueue.size)) {
|
||||
val key = pendingQueue.firstKey()
|
||||
val pendingDownload = pendingQueue.remove(key) ?: throw IllegalStateException("Download $key not exist.")
|
||||
val runningDownload = pendingDownload.copy(state = DownloadState.RUNNING)
|
||||
runningQueue[key] = runningDownload
|
||||
onStartDownload.invoke(key, runningDownload.request)
|
||||
onDownloadChanged(runningDownload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress for a given download. If no download of a given id is currently running,
|
||||
* update is ignored.
|
||||
*
|
||||
* @param uuid ID of the download to update
|
||||
* @param progress progress 0-100%
|
||||
*/
|
||||
fun progress(uuid: UUID, progress: Int) {
|
||||
val download = runningQueue[uuid]
|
||||
if (download != null) {
|
||||
val runningDownload = download.copy(progress = progress)
|
||||
runningQueue[uuid] = runningDownload
|
||||
onDownloadChanged(runningDownload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete currently running download. If no download of a given id is currently running,
|
||||
* update is ignored.
|
||||
*
|
||||
* @param uuid of the download to complete
|
||||
* @param success if true, download will be marked as completed; if false - as failed
|
||||
* @param file if provided, update file in download status; if null, existing value is retained
|
||||
*/
|
||||
fun complete(uuid: UUID, success: Boolean, file: OCFile? = null) {
|
||||
val download = runningQueue.remove(uuid)
|
||||
if (download != null) {
|
||||
val status = if (success) {
|
||||
DownloadState.COMPLETED
|
||||
} else {
|
||||
DownloadState.FAILED
|
||||
}
|
||||
val completedDownload = download.copy(state = status, file = file ?: download.file)
|
||||
completedQueue[uuid] = completedDownload
|
||||
onDownloadChanged(completedDownload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a download by file path. It traverses
|
||||
* through all queues in order of pending, running and completed
|
||||
* downloads and returns first download status matching
|
||||
* file path.
|
||||
*
|
||||
* @param file Search for a file download
|
||||
* @return download status if found, null otherwise
|
||||
*/
|
||||
fun getDownload(file: OCFile): Download? {
|
||||
arrayOf(pendingQueue, runningQueue, completedQueue).forEach { queue ->
|
||||
queue.forEach { entry ->
|
||||
if (entry.value.request.file.remotePath == file.remotePath) {
|
||||
return entry.value
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download status by id. It traverses
|
||||
* through all queues in order of pending, running and completed
|
||||
* downloads and returns first download status matching
|
||||
* file path.
|
||||
*
|
||||
* @param id download id
|
||||
* @return download status if found, null otherwise
|
||||
*/
|
||||
fun getDownload(uuid: UUID): Download? {
|
||||
return pendingQueue[uuid] ?: runningQueue[uuid] ?: completedQueue[uuid]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.nextcloud.client.files.downloader
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.nextcloud.client.account.User
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Download request. This class should collect all information
|
||||
* required to trigger download operation.
|
||||
*
|
||||
* Class is immutable by design, although [OCFile] or other
|
||||
* types might not be immutable. Clients should no modify
|
||||
* contents of this object.
|
||||
*
|
||||
* @property user Download will be triggered for a given user
|
||||
* @property file Remote file to download
|
||||
* @property uuid Unique request identifier; this identifier can be set in [Download]
|
||||
* @property dummy if true, this requests a dummy test download; no real file transfer will occur
|
||||
*/
|
||||
class Request internal constructor(
|
||||
val user: User,
|
||||
val file: OCFile,
|
||||
val uuid: UUID,
|
||||
val test: Boolean = false
|
||||
) : Parcelable {
|
||||
|
||||
constructor(user: User, file: OCFile) : this(user, file, UUID.randomUUID())
|
||||
|
||||
constructor(user: User, file: OCFile, test: Boolean) : this(user, file, UUID.randomUUID(), test)
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
user = parcel.readParcelable<User>(User::class.java.classLoader) as User,
|
||||
file = parcel.readParcelable<OCFile>(OCFile::class.java.classLoader) as OCFile,
|
||||
uuid = parcel.readSerializable() as UUID,
|
||||
test = parcel.readInt() != 0
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeParcelable(user, flags)
|
||||
parcel.writeParcelable(file, flags)
|
||||
parcel.writeSerializable(uuid)
|
||||
parcel.writeInt(if (test) 1 else 0)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<Request> {
|
||||
override fun createFromParcel(parcel: Parcel): Request {
|
||||
return Request(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<Request?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -62,10 +62,8 @@ class AsyncFilter(private val asyncRunner: AsyncRunner, private val time: () ->
|
|||
|
||||
private fun <T> filterAsync(collection: Iterable<T>, predicate: (T) -> Boolean, onResult: (List<T>, Long) -> Unit) {
|
||||
startTime = time.invoke()
|
||||
filterTask = asyncRunner.post(
|
||||
task = {
|
||||
collection.filter { predicate.invoke(it) }
|
||||
},
|
||||
filterTask = asyncRunner.postQuickTask(
|
||||
task = { collection.filter { predicate.invoke(it) } },
|
||||
onResult = { filtered: List<T> ->
|
||||
onFilterCompleted(filtered, onResult)
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ class LogsEmailSender(private val context: Context, private val clock: Clock, pr
|
|||
fun send(logs: List<LogEntry>) {
|
||||
if (task == null) {
|
||||
val outFile = File(context.cacheDir, "attachments/logs.txt")
|
||||
task = runner.post(Task(context, logs, outFile, clock.tz), onResult = { task = null; send(it) })
|
||||
task = runner.postQuickTask(Task(context, logs, outFile, clock.tz), onResult = { task = null; send(it) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -61,7 +61,7 @@ internal class MigrationsManagerImpl(
|
|||
return 0
|
||||
}
|
||||
(status as MutableLiveData<Status>).value = Status.RUNNING
|
||||
asyncRunner.post(
|
||||
asyncRunner.postQuickTask(
|
||||
task = { asyncApplyMigrations(toApply) },
|
||||
onResult = { onMigrationSuccess() },
|
||||
onError = { onMigrationFailed(it) }
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* @author Tobias Kaminsky
|
||||
* Copyright (C) 2017 Tobias Kaminsky
|
||||
* Copyright (C) 2017 Nextcloud GmbH.
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -22,11 +23,9 @@
|
|||
package com.owncloud.android.ui.fragment.contactsbackup;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
|
@ -58,12 +57,15 @@ import com.google.android.material.snackbar.Snackbar;
|
|||
import com.nextcloud.client.account.User;
|
||||
import com.nextcloud.client.account.UserAccountManager;
|
||||
import com.nextcloud.client.di.Injectable;
|
||||
import com.nextcloud.client.files.downloader.Download;
|
||||
import com.nextcloud.client.files.downloader.DownloadState;
|
||||
import com.nextcloud.client.files.downloader.DownloaderConnection;
|
||||
import com.nextcloud.client.files.downloader.Request;
|
||||
import com.nextcloud.client.jobs.BackgroundJobManager;
|
||||
import com.nextcloud.client.network.ClientFactory;
|
||||
import com.owncloud.android.R;
|
||||
import com.owncloud.android.datamodel.FileDataStorageManager;
|
||||
import com.owncloud.android.datamodel.OCFile;
|
||||
import com.owncloud.android.files.services.FileDownloader;
|
||||
import com.owncloud.android.lib.common.utils.Log_OC;
|
||||
import com.owncloud.android.ui.TextDrawable;
|
||||
import com.owncloud.android.ui.activity.ContactsPreferenceActivity;
|
||||
|
@ -101,6 +103,7 @@ import butterknife.ButterKnife;
|
|||
import ezvcard.Ezvcard;
|
||||
import ezvcard.VCard;
|
||||
import ezvcard.property.Photo;
|
||||
import kotlin.Unit;
|
||||
|
||||
import static com.owncloud.android.ui.fragment.contactsbackup.ContactListFragment.getDisplayName;
|
||||
|
||||
|
@ -148,6 +151,7 @@ public class ContactListFragment extends FileFragment implements Injectable {
|
|||
@Inject UserAccountManager accountManager;
|
||||
@Inject ClientFactory clientFactory;
|
||||
@Inject BackgroundJobManager backgroundJobManager;
|
||||
private DownloaderConnection fileDownloader;
|
||||
|
||||
public static ContactListFragment newInstance(OCFile file, User user) {
|
||||
ContactListFragment frag = new ContactListFragment();
|
||||
|
@ -209,18 +213,12 @@ public class ContactListFragment extends FileFragment implements Injectable {
|
|||
ocFile = getArguments().getParcelable(FILE_NAME);
|
||||
setFile(ocFile);
|
||||
user = getArguments().getParcelable(USER);
|
||||
|
||||
fileDownloader = new DownloaderConnection(getActivity(), user);
|
||||
fileDownloader.registerDownloadListener(this::onDownloadUpdate);
|
||||
fileDownloader.bind();
|
||||
if (!ocFile.isDown()) {
|
||||
Intent i = new Intent(getContext(), FileDownloader.class);
|
||||
i.putExtra(FileDownloader.EXTRA_USER, user);
|
||||
i.putExtra(FileDownloader.EXTRA_FILE, ocFile);
|
||||
getContext().startService(i);
|
||||
|
||||
// Listen for download messages
|
||||
IntentFilter downloadIntentFilter = new IntentFilter(FileDownloader.getDownloadAddedMessage());
|
||||
downloadIntentFilter.addAction(FileDownloader.getDownloadFinishMessage());
|
||||
DownloadFinishReceiver mDownloadFinishReceiver = new DownloadFinishReceiver();
|
||||
getContext().registerReceiver(mDownloadFinishReceiver, downloadIntentFilter);
|
||||
Request request = new Request(user, ocFile);
|
||||
fileDownloader.download(request);
|
||||
} else {
|
||||
loadContactsTask.execute();
|
||||
}
|
||||
|
@ -240,6 +238,14 @@ public class ContactListFragment extends FileFragment implements Injectable {
|
|||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDetach() {
|
||||
super.onDetach();
|
||||
if (fileDownloader != null) {
|
||||
fileDownloader.unbind();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(@NonNull Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
|
@ -497,19 +503,13 @@ public class ContactListFragment extends FileFragment implements Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
private class DownloadFinishReceiver extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (FileDownloader.getDownloadFinishMessage().equalsIgnoreCase(intent.getAction())) {
|
||||
String downloadedRemotePath = intent.getStringExtra(FileDownloader.EXTRA_REMOTE_PATH);
|
||||
|
||||
FileDataStorageManager storageManager = new FileDataStorageManager(user.toPlatformAccount(),
|
||||
context.getContentResolver());
|
||||
ocFile = storageManager.getFileByPath(downloadedRemotePath);
|
||||
loadContactsTask.execute();
|
||||
}
|
||||
private Unit onDownloadUpdate(Download download) {
|
||||
final Activity activity = getActivity();
|
||||
if (download.getState() == DownloadState.COMPLETED && activity != null) {
|
||||
ocFile = download.getFile();
|
||||
loadContactsTask.execute();
|
||||
}
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
|
||||
public static class VCardComparator implements Comparator<VCard> {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Nextcloud Android client application
|
||||
|
||||
@author Chris Narkiewicz
|
||||
Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp"
|
||||
android:stretchColumns="1">
|
||||
|
||||
<TableRow
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:text="@string/etm_download_uuid" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/etm_download_uuid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="d7edb387-0b61-4e4e-a728-ffab3055d700" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:text="@string/etm_download_path" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/etm_download_path"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="file path" />
|
||||
|
||||
</TableRow>
|
||||
|
||||
<TableRow
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:text="@string/etm_download_user" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/etm_download_user"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="user@nextcloud.com" />
|
||||
|
||||
</TableRow>
|
||||
|
||||
<TableRow
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:text="@string/etm_download_state" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/etm_download_state"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="PENDING" />
|
||||
|
||||
</TableRow>
|
||||
|
||||
<TableRow
|
||||
android:id="@+id/etm_download_progress_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:text="@string/etm_download_progress" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/etm_download_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="50%" />
|
||||
|
||||
</TableRow>
|
||||
|
||||
</TableLayout>
|
|
@ -0,0 +1,31 @@
|
|||
<!--
|
||||
Nextcloud Android client application
|
||||
|
||||
@author Chris Narkiewicz
|
||||
Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context="com.nextcloud.client.etm.pages.EtmDownloaderFragment">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/etm_download_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"/>
|
||||
|
||||
</FrameLayout>
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Nextcloud Android client application
|
||||
|
||||
@author Chris Narkiewicz
|
||||
Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:ignore="AppCompatResource">
|
||||
|
||||
<item
|
||||
android:id="@+id/etm_test_download"
|
||||
android:title="@string/etm_download_enqueue_test_download"
|
||||
app:showAsAction="ifRoom"
|
||||
android:showAsAction="ifRoom"
|
||||
android:icon="@drawable/ic_plus" />
|
||||
|
||||
</menu>
|
|
@ -877,6 +877,13 @@
|
|||
<string name="etm_background_job_started">Started</string>
|
||||
<string name="etm_background_job_progress">Progress</string>
|
||||
<string name="etm_migrations">Migrations (app upgrade)</string>
|
||||
<string name="etm_downloader">Downloader</string>
|
||||
<string name="etm_download_uuid">@string/etm_background_job_uuid</string>
|
||||
<string name="etm_download_user">@string/etm_background_job_user</string>
|
||||
<string name="etm_download_path">Remote path</string>
|
||||
<string name="etm_download_state">@string/etm_background_job_state</string>
|
||||
<string name="etm_download_progress">@string/etm_background_job_progress</string>
|
||||
<string name="etm_download_enqueue_test_download">Enqueue test download</string>
|
||||
|
||||
<string name="logs_status_loading">Loading…</string>
|
||||
<string name="logs_status_filtered">Logs: %1$d kB, query matched %2$d / %3$d in %4$d ms</string>
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
package com.nextcloud.client.core
|
||||
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.justRun
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class LocalConnectionTest {
|
||||
|
||||
lateinit var context: Context
|
||||
lateinit var connection: LocalConnection<Service>
|
||||
var mockIntent: Intent? = null
|
||||
|
||||
@MockK
|
||||
lateinit var componentName: ComponentName
|
||||
|
||||
@MockK
|
||||
lateinit var binder: LocalBinder<Service>
|
||||
|
||||
@MockK
|
||||
lateinit var mockOnBound: (IBinder) -> Unit
|
||||
|
||||
@MockK
|
||||
lateinit var mockOnUnbound: () -> Unit
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
MockKAnnotations.init(this, relaxed = true)
|
||||
context = mockk()
|
||||
connection = object : LocalConnection<Service>(context) {
|
||||
override fun createBindIntent(): Intent? {
|
||||
return mockIntent
|
||||
}
|
||||
|
||||
override fun onBound(binder: IBinder) {
|
||||
mockOnBound.invoke(binder)
|
||||
}
|
||||
|
||||
override fun onUnbind() {
|
||||
mockOnUnbound.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun binding_disabled() {
|
||||
// GIVEN
|
||||
// no binding intent is provided
|
||||
mockIntent = null
|
||||
|
||||
// WHEN
|
||||
// bind requested
|
||||
connection.bind()
|
||||
|
||||
// THEN
|
||||
// no binding is performed
|
||||
verify(exactly = 0) { context.bindService(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bind_service() {
|
||||
// GIVEN
|
||||
// binding intent is provided
|
||||
mockIntent = mockk()
|
||||
|
||||
// WHEN
|
||||
// bind requested
|
||||
every { context.bindService(mockIntent, any(), any()) } returns true
|
||||
connection.bind()
|
||||
|
||||
// THEN
|
||||
// service bound
|
||||
verify { context.bindService(mockIntent, any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun service_connected() {
|
||||
// GIVEN
|
||||
// service is not bound
|
||||
|
||||
// WHEN
|
||||
// service is connected
|
||||
connection.onServiceConnected(componentName, binder)
|
||||
|
||||
// THEN
|
||||
// onBound callback called with binder instance
|
||||
verify { mockOnBound(binder) }
|
||||
assertTrue(connection.isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unbind_service() {
|
||||
// GIVEN
|
||||
// servic is bound
|
||||
connection.onServiceConnected(componentName, binder)
|
||||
|
||||
// WHEN
|
||||
// service is unbound multiple imes
|
||||
justRun { context.unbindService(connection) }
|
||||
connection.unbind()
|
||||
connection.unbind()
|
||||
|
||||
// THEN
|
||||
// service is unbound only when it's bound
|
||||
// later unbind invocations no-ops
|
||||
verify(exactly = 1) { mockOnUnbound.invoke() }
|
||||
verify(exactly = 1) { context.unbindService(connection) }
|
||||
assertFalse(connection.isConnected)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException::class)
|
||||
fun binder_must_implement_local_binder() {
|
||||
// WHEN
|
||||
// service connected using non-compliant binder
|
||||
val badBinder: IBinder = mockk()
|
||||
connection.onServiceConnected(componentName, badBinder)
|
||||
|
||||
// THEN
|
||||
// throws
|
||||
}
|
||||
}
|
|
@ -66,17 +66,17 @@ class ManualAsyncRunnerTest {
|
|||
@Test
|
||||
fun `tasks are queued`() {
|
||||
assertEquals(EMPTY, runner.size)
|
||||
runner.post(task, onResult, onError)
|
||||
runner.post(task, onResult, onError)
|
||||
runner.post(task, onResult, onError)
|
||||
runner.postQuickTask(task, onResult, onError)
|
||||
runner.postQuickTask(task, onResult, onError)
|
||||
runner.postQuickTask(task, onResult, onError)
|
||||
assertEquals("Expected 3 tasks to be enqueued", THREE_TASKS, runner.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `run one enqueued task`() {
|
||||
runner.post(task, onResult, onError)
|
||||
runner.post(task, onResult, onError)
|
||||
runner.post(task, onResult, onError)
|
||||
runner.postQuickTask(task, onResult, onError)
|
||||
runner.postQuickTask(task, onResult, onError)
|
||||
runner.postQuickTask(task, onResult, onError)
|
||||
|
||||
assertEquals("Queue should contain all enqueued tasks", THREE_TASKS, runner.size)
|
||||
val run = runner.runOne()
|
||||
|
@ -88,8 +88,8 @@ class ManualAsyncRunnerTest {
|
|||
|
||||
@Test
|
||||
fun `run all enqueued tasks`() {
|
||||
runner.post(task, onResult, onError)
|
||||
runner.post(task, onResult, onError)
|
||||
runner.postQuickTask(task, onResult, onError)
|
||||
runner.postQuickTask(task, onResult, onError)
|
||||
|
||||
assertEquals("Queue should contain all enqueued tasks", TWO_TASKS, runner.size)
|
||||
val count = runner.runAll()
|
||||
|
@ -115,13 +115,13 @@ class ManualAsyncRunnerTest {
|
|||
// WHEN
|
||||
// one task is scheduled
|
||||
// task callback schedules another task
|
||||
runner.post(
|
||||
runner.postQuickTask(
|
||||
task,
|
||||
{
|
||||
runner.post(
|
||||
runner.postQuickTask(
|
||||
task,
|
||||
{
|
||||
runner.post(task)
|
||||
runner.postQuickTask(task)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -141,7 +141,7 @@ class ManualAsyncRunnerTest {
|
|||
fun `runner detects infinite loops caused by scheduling tasks recusively`() {
|
||||
val recursiveTask: () -> String = object : Function0<String> {
|
||||
override fun invoke(): String {
|
||||
runner.post(this)
|
||||
runner.postQuickTask(this)
|
||||
return "result"
|
||||
}
|
||||
}
|
||||
|
@ -149,7 +149,7 @@ class ManualAsyncRunnerTest {
|
|||
// WHEN
|
||||
// one task is scheduled
|
||||
// task will schedule itself again, causing infinite loop
|
||||
runner.post(recursiveTask)
|
||||
runner.postQuickTask(recursiveTask)
|
||||
|
||||
// WHEN
|
||||
// runs all
|
||||
|
|
|
@ -33,24 +33,33 @@ import org.mockito.MockitoAnnotations
|
|||
class TaskTest {
|
||||
|
||||
@Mock
|
||||
private lateinit var taskBody: () -> String
|
||||
private lateinit var taskBody: TaskFunction<String, Int>
|
||||
@Mock
|
||||
private lateinit var removeFromQueue: (Runnable) -> Boolean
|
||||
@Mock
|
||||
private lateinit var onResult: OnResultCallback<String>
|
||||
@Mock
|
||||
private lateinit var onError: OnErrorCallback
|
||||
@Mock
|
||||
private lateinit var onProgress: OnProgressCallback<Int>
|
||||
|
||||
private lateinit var task: Task<String>
|
||||
private lateinit var task: Task<String, Int>
|
||||
|
||||
fun post(r: Runnable): Boolean {
|
||||
r.run()
|
||||
return true
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
MockitoAnnotations.initMocks(this)
|
||||
val postResult = { r: Runnable -> r.run() }
|
||||
task = Task(postResult, taskBody, onResult, onError)
|
||||
val postResult = { r: Runnable -> r.run(); true }
|
||||
task = Task(this::post, removeFromQueue, taskBody, onResult, onError, onProgress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task result is posted`() {
|
||||
whenever(taskBody.invoke()).thenReturn("result")
|
||||
whenever(taskBody.invoke(any(), any())).thenReturn("result")
|
||||
task.run()
|
||||
verify(onResult).invoke(eq("result"))
|
||||
verify(onError, never()).invoke(any())
|
||||
|
@ -58,7 +67,7 @@ class TaskTest {
|
|||
|
||||
@Test
|
||||
fun `task result is not posted when cancelled`() {
|
||||
whenever(taskBody.invoke()).thenReturn("result")
|
||||
whenever(taskBody.invoke(any(), any())).thenReturn("result")
|
||||
task.cancel()
|
||||
task.run()
|
||||
verify(onResult, never()).invoke(any())
|
||||
|
@ -68,7 +77,7 @@ class TaskTest {
|
|||
@Test
|
||||
fun `task error is posted`() {
|
||||
val exception = RuntimeException("")
|
||||
whenever(taskBody.invoke()).thenThrow(exception)
|
||||
whenever(taskBody.invoke(any(), any())).thenThrow(exception)
|
||||
task.run()
|
||||
verify(onResult, never()).invoke(any())
|
||||
verify(onError).invoke(same(exception))
|
||||
|
@ -77,7 +86,7 @@ class TaskTest {
|
|||
@Test
|
||||
fun `task error is not posted when cancelled`() {
|
||||
val exception = RuntimeException("")
|
||||
whenever(taskBody.invoke()).thenThrow(exception)
|
||||
whenever(taskBody.invoke(any(), any())).thenThrow(exception)
|
||||
task.cancel()
|
||||
task.run()
|
||||
verify(onResult, never()).invoke(any())
|
||||
|
|
|
@ -62,7 +62,7 @@ class ThreadPoolAsyncRunnerTest {
|
|||
val latch = CountDownLatch(1)
|
||||
val callerThread = Thread.currentThread()
|
||||
var taskThread: Thread? = null
|
||||
r.post({
|
||||
r.postQuickTask({
|
||||
taskThread = Thread.currentThread()
|
||||
latch.countDown()
|
||||
})
|
||||
|
@ -79,7 +79,7 @@ class ThreadPoolAsyncRunnerTest {
|
|||
}.whenever(handler).post(any())
|
||||
|
||||
val onResult: OnResultCallback<String> = mock()
|
||||
r.post(
|
||||
r.postQuickTask(
|
||||
{
|
||||
"result"
|
||||
},
|
||||
|
@ -99,11 +99,12 @@ class ThreadPoolAsyncRunnerTest {
|
|||
|
||||
val onResult: OnResultCallback<String> = mock()
|
||||
val onError: OnErrorCallback = mock()
|
||||
r.post(
|
||||
r.postQuickTask(
|
||||
{
|
||||
throw IllegalArgumentException("whatever")
|
||||
},
|
||||
onResult = onResult, onError = onError
|
||||
onResult = onResult,
|
||||
onError = onError
|
||||
)
|
||||
assertAwait(afterPostLatch)
|
||||
verify(onResult, never()).invoke(any())
|
||||
|
@ -114,13 +115,14 @@ class ThreadPoolAsyncRunnerTest {
|
|||
fun `cancelled task does not return result`() {
|
||||
val taskIsCancelled = CountDownLatch(INIT_COUNT)
|
||||
val taskIsRunning = CountDownLatch(INIT_COUNT)
|
||||
val t = r.post(
|
||||
val t = r.postQuickTask(
|
||||
{
|
||||
taskIsRunning.countDown()
|
||||
taskIsCancelled.await()
|
||||
"result"
|
||||
},
|
||||
onResult = {}, onError = {}
|
||||
onResult = {},
|
||||
onError = {}
|
||||
)
|
||||
assertAwait(taskIsRunning)
|
||||
t.cancel()
|
||||
|
@ -133,13 +135,14 @@ class ThreadPoolAsyncRunnerTest {
|
|||
fun `cancelled task does not return error`() {
|
||||
val taskIsCancelled = CountDownLatch(INIT_COUNT)
|
||||
val taskIsRunning = CountDownLatch(INIT_COUNT)
|
||||
val t = r.post(
|
||||
val t = r.postQuickTask(
|
||||
{
|
||||
taskIsRunning.countDown()
|
||||
taskIsCancelled.await()
|
||||
throw IllegalStateException("whatever")
|
||||
},
|
||||
onResult = {}, onError = {}
|
||||
onResult = {},
|
||||
onError = {}
|
||||
)
|
||||
assertAwait(taskIsRunning)
|
||||
t.cancel()
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue