New download manager

Signed-off-by: Chris Narkiewicz <hello@ezaquarii.com>
This commit is contained in:
Chris Narkiewicz 2020-05-13 01:09:21 +01:00 committed by tobiasKaminsky
parent 51f48549c3
commit 75b9fa8551
No known key found for this signature in database
GPG Key ID: 0E00D4D47D0C5AF7
50 changed files with 3194 additions and 106 deletions

View File

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

View File

@ -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 {

View File

@ -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

View File

@ -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);

View File

@ -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)
}
}

View File

@ -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()) }
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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"/>

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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()

View File

@ -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
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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);
}
}

View File

@ -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();
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}
}

View 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?
}

View File

@ -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) }
}
}

View File

@ -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)
}
}

View File

@ -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
}
}
}

View File

@ -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]
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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) })
}
}

View File

@ -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) }

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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();

View File

@ -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

View File

@ -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");

View File

@ -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> {

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
}
}

View File

@ -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

View File

@ -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())

View File

@ -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()

View File

@ -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,