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