diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt deleted file mode 100644 index 4e5131931..000000000 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt +++ /dev/null @@ -1,192 +0,0 @@ -package de.xikolo.testing.instrumented.unit - -import androidx.test.rule.ActivityTestRule -import de.xikolo.controllers.main.MainActivity -import de.xikolo.download.DownloadHandler -import de.xikolo.download.DownloadIdentifier -import de.xikolo.download.DownloadRequest -import de.xikolo.download.DownloadStatus -import de.xikolo.testing.instrumented.mocking.base.BaseTest -import org.junit.Assert -import org.junit.Rule -import org.junit.Test - -abstract class DownloadHandlerTest, - I : DownloadIdentifier, R : DownloadRequest> : BaseTest() { - - @Rule - @JvmField - var activityTestRule = ActivityTestRule(MainActivity::class.java, false, true) - - abstract var downloadHandler: T - - abstract var successfulTestRequest: R - abstract var successfulTestRequest2: R - abstract var failingTestRequest: R - abstract var invalidIdentifier: I - - @Test - fun testDownloadIdentifier() { - val identifier = downloadHandler.download(successfulTestRequest) - Assert.assertTrue(identifier.toString().isNotBlank()) - } - - @Test - fun testInvalidIdentifier() { - downloadHandler.status(invalidIdentifier) { - Assert.assertNull(it) - } - downloadHandler.cancel(invalidIdentifier) { - Assert.assertFalse(it) - } - } - - @Test - fun testDownloadStatusAfterStart() { - downloadHandler.download(successfulTestRequest, null) { identifier -> - identifier!! - downloadHandler.status(identifier) { status -> - status!! - Assert.assertNotEquals(DownloadStatus.State.FAILED, status.state) - Assert.assertNotEquals(DownloadStatus.State.SUCCESSFUL, status.state) - if (status.totalBytes >= 0) { - Assert.assertTrue( - status.downloadedBytes <= status.totalBytes - ) - } - - downloadHandler.cancel(identifier) - } - } - } - - @Test - fun testDownloadStatusAfterCancel() { - var called = false - downloadHandler.download( - successfulTestRequest, - { status -> - if (status?.state == DownloadStatus.State.CANCELLED) { - called = true - } - }, - { identifier -> - identifier!! - - downloadHandler.cancel(identifier) { success -> - Assert.assertTrue(success) - } - } - ) - - waitWhile({ !called }) - } - - @Test - fun testDownloadStatusAfterSuccess() { - var called = false - var id: I? = null - - downloadHandler.download( - successfulTestRequest, - { status -> - if (status?.state == DownloadStatus.State.SUCCESSFUL) { - called = true - } - }, - { identifier -> - identifier!! - id = identifier - } - ) - - waitWhile({ !called }) - - downloadHandler.status(id!!) { - it!! - Assert.assertEquals(DownloadStatus.State.SUCCESSFUL, it.state) - Assert.assertEquals(it.downloadedBytes, it.totalBytes) - } - } - - @Test - fun testParallelDownloading() { - downloadHandler.download( - successfulTestRequest, - null, - { identifier -> - identifier!! - downloadHandler.status(identifier) { - it!! - Assert.assertNotEquals( - DownloadStatus.State.FAILED, - it - ) - Assert.assertNotEquals( - DownloadStatus.State.SUCCESSFUL, - it - ) - downloadHandler.cancel(identifier) - } - } - ) - - downloadHandler.download( - successfulTestRequest2, - null, - { identifier -> - identifier!! - downloadHandler.status(identifier) { - it!! - Assert.assertNotEquals( - DownloadStatus.State.FAILED, - it - ) - Assert.assertNotEquals( - DownloadStatus.State.SUCCESSFUL, - it - ) - downloadHandler.cancel(identifier) - } - } - ) - } - - @Test - fun testDownloadStatusAfterFailure() { - var called = false - var id: I? = null - - downloadHandler.download( - failingTestRequest, - { status -> - if (status?.state == DownloadStatus.State.FAILED) { - called = true - } - }, - { identifier -> - identifier!! - id = identifier - } - ) - - waitWhile({ !called }) - - downloadHandler.status(id!!) { - it!! - Assert.assertEquals(DownloadStatus.State.FAILED, it.state) - Assert.assertNotEquals(it.downloadedBytes, it.totalBytes) - } - } - - private fun waitWhile(condition: () -> Boolean, timeout: Long = 300000) { - var waited = 0 - while (condition()) { - Thread.sleep(100) - waited += 100 - if (waited > timeout) { - throw Exception() - } - } - } -} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt deleted file mode 100644 index 1cb003fa1..000000000 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt +++ /dev/null @@ -1,158 +0,0 @@ -package de.xikolo.testing.instrumented.unit - -import androidx.test.rule.ActivityTestRule -import androidx.test.rule.GrantPermissionRule -import de.xikolo.controllers.main.MainActivity -import de.xikolo.download.DownloadIdentifier -import de.xikolo.download.DownloadItem -import de.xikolo.testing.instrumented.mocking.base.BaseTest -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -abstract class DownloadItemTest, - F, I : DownloadIdentifier> : BaseTest() { - - @Rule - @JvmField - var activityTestRule = - ActivityTestRule(MainActivity::class.java, false, true) - - @Rule - @JvmField - var permissionRule = - GrantPermissionRule.grant(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) - - abstract var testDownloadItem: T - abstract var testDownloadItemNotDownloadable: T - - @Before - fun deleteItem() { - testDownloadItem.delete(activityTestRule.activity) - } - - @Test - fun testIsDownloadable() { - assertTrue(testDownloadItem.isDownloadable) - assertFalse(testDownloadItemNotDownloadable.isDownloadable) - } - - @Test - fun testStateBeforeDownload() { - testDownloadItem.isDownloadRunning { - assertFalse(it) - - assertNull(testDownloadItem.download) - assertFalse(testDownloadItem.downloadExists) - } - } - - @Test - fun testStartDownload() { - var started = false - var completed = false - testDownloadItem.stateListener = object : DownloadItem.StateListener { - override fun onStarted() { - started = true - } - - override fun onCompleted() { - completed = true - } - - override fun onDeleted() {} - } - - testDownloadItem.start(activityTestRule.activity) { - assertNotNull(it) - - assertTrue(started) - testDownloadItem.isDownloadRunning { - assertTrue(it) - - testDownloadItem.start(activityTestRule.activity) { - assertNull(it) - - waitWhile({ !completed }) - - assertTrue(testDownloadItem.downloadExists) - assertNotNull(testDownloadItem.download) - } - } - } - } - - @Test - fun testCancelDownload() { - testDownloadItem.start(activityTestRule.activity) { - assertNotNull(it) - - testDownloadItem.isDownloadRunning { - assertTrue(it) - - testDownloadItem.cancel(activityTestRule.activity) { - assertTrue(it) - - testDownloadItem.isDownloadRunning { - assertFalse(it) - - assertFalse(testDownloadItem.downloadExists) - assertNull(testDownloadItem.download) - } - } - } - } - } - - @Test - fun testDeleteDownload() { - var deleted = true - var completed = false - testDownloadItem.stateListener = object : DownloadItem.StateListener { - override fun onStarted() {} - - override fun onCompleted() { - completed = true - } - - override fun onDeleted() { - deleted = true - } - } - - testDownloadItem.delete(activityTestRule.activity) { - assertFalse(it) - - testDownloadItem.start(activityTestRule.activity) { - assertNotNull(it) - - waitWhile({ !completed }) - - assertTrue(testDownloadItem.downloadExists) - - testDownloadItem.delete(activityTestRule.activity) { - assertTrue(it) - - assertTrue(deleted) - assertFalse(testDownloadItem.downloadExists) - assertNull(testDownloadItem.download) - } - } - } - } - - protected fun waitWhile(condition: () -> Boolean, timeout: Long = 300000) { - var waited = 0 - while (condition()) { - Thread.sleep(100) - waited += 100 - if (waited > timeout) { - throw Exception() - } - } - } -} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadHandlerTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadHandlerTest.kt deleted file mode 100644 index 07982c690..000000000 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadHandlerTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package de.xikolo.testing.instrumented.unit - -import de.xikolo.download.filedownload.FileDownloadHandler -import de.xikolo.download.filedownload.FileDownloadIdentifier -import de.xikolo.download.filedownload.FileDownloadRequest -import de.xikolo.testing.instrumented.mocking.SampleMockData -import de.xikolo.utils.extensions.preferredStorage - -class FileDownloadHandlerTest : DownloadHandlerTest() { - - override var downloadHandler = FileDownloadHandler - override var successfulTestRequest = FileDownloadRequest( - SampleMockData.mockVideoStreamSdUrl, - createTempFile(directory = context.preferredStorage.file), - "TITLE", - true - ) - override var successfulTestRequest2 = FileDownloadRequest( - SampleMockData.mockVideoStreamThumbnailUrl, - createTempFile(directory = context.preferredStorage.file), - "TITLE", - true - ) - override var failingTestRequest = FileDownloadRequest( - "https://www.example.com/notfoundfilehwqnqkdrzn42r.mp4", - createTempFile(directory = context.preferredStorage.file), - "TITLE", - true - ) - override var invalidIdentifier = FileDownloadIdentifier(-1) -} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadItemTest.kt deleted file mode 100644 index 130d16a27..000000000 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadItemTest.kt +++ /dev/null @@ -1,271 +0,0 @@ -package de.xikolo.testing.instrumented.unit - -import de.xikolo.download.DownloadItem -import de.xikolo.download.filedownload.FileDownloadIdentifier -import de.xikolo.download.filedownload.FileDownloadItem -import de.xikolo.testing.instrumented.mocking.SampleMockData -import de.xikolo.utils.extensions.preferredStorage -import junit.framework.Assert.assertFalse -import junit.framework.Assert.assertNotNull -import junit.framework.Assert.assertNull -import junit.framework.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.io.File - -class FileDownloadItemTest : DownloadItemTest() { - - private val storage = context.preferredStorage - - override var testDownloadItem = - FileDownloadItem(SampleMockData.mockVideoStreamSdUrl, "sdvideo.mp4", storage) - override var testDownloadItemNotDownloadable = FileDownloadItem(null, "null") - - private var testSecondaryItem = FileDownloadItem( - SampleMockData.mockVideoStreamThumbnailUrl, - "thumb.jpg", - storage - ) - private var testDownloadItemWithSecondary = - object : FileDownloadItem( - SampleMockData.mockVideoStreamSdUrl, - "sdvideo2.mp4", - storage - ) { - override val secondaryDownloadItems: Set = setOf(testSecondaryItem) - } - private var testDownloadItemWithSecondaryNotDeletingSecondary = - object : FileDownloadItem( - SampleMockData.mockVideoStreamSdUrl, - "sdvideo3.mp4", - storage - ) { - override val secondaryDownloadItems: Set = setOf(testSecondaryItem) - override val deleteSecondaryDownloadItemPredicate: (FileDownloadItem) -> Boolean = - { false } - } - - @Before - fun deleteSecondaryItems() { - testDownloadItemWithSecondary.delete(activityTestRule.activity) - - testSecondaryItem.delete(activityTestRule.activity) - } - - @Test - fun testDeletesTempFile() { - var completedMain = false - testDownloadItem.stateListener = object : DownloadItem.StateListener { - override fun onStarted() {} - - override fun onCompleted() { - completedMain = true - } - - override fun onDeleted() {} - } - testDownloadItem.start(activityTestRule.activity) { - assertTrue( - File(testDownloadItem.filePath + ".tmp").exists() - ) - - waitWhile({ !completedMain }) - - assertFalse( - File(testDownloadItem.filePath + ".tmp").exists() - ) - - testDownloadItem.delete(activityTestRule.activity) { - testDownloadItem.start(activityTestRule.activity) { - assertTrue( - File(testDownloadItem.filePath + ".tmp").exists() - ) - - testDownloadItem.cancel(activityTestRule.activity) { - assertFalse( - File(testDownloadItem.filePath + ".tmp").exists() - ) - } - } - } - } - } - - @Test - fun testStartDownloadWithSecondary() { - var completedMain = false - testDownloadItemWithSecondary.stateListener = object : DownloadItem.StateListener { - override fun onStarted() {} - - override fun onCompleted() { - completedMain = true - } - - override fun onDeleted() {} - } - var completedSecondary = false - testSecondaryItem.stateListener = object : DownloadItem.StateListener { - override fun onStarted() {} - - override fun onCompleted() { - completedSecondary = true - } - - override fun onDeleted() {} - } - - testDownloadItemWithSecondary.start(activityTestRule.activity) { - assertNotNull(it) - - testDownloadItemWithSecondary.isDownloadRunning { - assertTrue(it) - - testSecondaryItem.isDownloadRunning { - assertTrue(it) - } - - testDownloadItemWithSecondary.start(activityTestRule.activity) { - assertNull(it) - - testSecondaryItem.start(activityTestRule.activity) { - assertNull(it) - - waitWhile({ !completedMain }) - - assertTrue(completedSecondary) - assertTrue(testDownloadItemWithSecondary.downloadExists) - assertTrue(testSecondaryItem.downloadExists) - assertNotNull(testDownloadItemWithSecondary.download) - assertNotNull(testSecondaryItem.download) - - testDownloadItemWithSecondary.start(activityTestRule.activity) { - assertNull(it) - } - } - } - } - } - } - - @Test - fun testCancelDownloadWithSecondary() { - testDownloadItemWithSecondary.start(activityTestRule.activity) { - assertNotNull(it) - - testDownloadItemWithSecondary.cancel(activityTestRule.activity) { - assertTrue(it) - - testDownloadItemWithSecondary.isDownloadRunning { - assertFalse(it) - - assertFalse(testDownloadItemWithSecondary.downloadExists) - assertNull(testDownloadItemWithSecondary.download) - - testSecondaryItem.isDownloadRunning { - assertFalse(it) - - assertFalse(testSecondaryItem.downloadExists) - assertNull(testSecondaryItem.download) - } - } - } - } - } - - @Test - fun testDeleteDownloadWithSecondary() { - var completedMain = false - var deletedMain = false - testDownloadItemWithSecondary.stateListener = object : DownloadItem.StateListener { - override fun onStarted() {} - - override fun onCompleted() { - completedMain = true - } - - override fun onDeleted() { - deletedMain = true - } - } - - var deletedSecondary = false - testSecondaryItem.stateListener = object : DownloadItem.StateListener { - override fun onStarted() {} - - override fun onCompleted() {} - - override fun onDeleted() { - deletedSecondary = true - } - } - - testDownloadItemWithSecondary.delete(activityTestRule.activity) { - assertFalse(it) - - testDownloadItemWithSecondary.start(activityTestRule.activity) { - waitWhile({ !completedMain }) - - assertTrue(testDownloadItemWithSecondary.downloadExists) - assertTrue(testSecondaryItem.downloadExists) - - testDownloadItemWithSecondary.delete(activityTestRule.activity) { - assertTrue(it) - - assertTrue(deletedMain) - assertTrue(deletedSecondary) - assertFalse(testDownloadItemWithSecondary.downloadExists) - assertFalse(testSecondaryItem.downloadExists) - assertNull(testDownloadItemWithSecondary.download) - assertNull(testSecondaryItem.download) - } - } - } - } - - @Test - fun testDeleteDownloadWithoutSecondary() { - var completedMain = false - var deletedMain = false - testDownloadItemWithSecondaryNotDeletingSecondary.stateListener = - object : DownloadItem.StateListener { - override fun onStarted() {} - - override fun onCompleted() { - completedMain = true - } - - override fun onDeleted() { - deletedMain = true - } - } - - var deletedSecondary = false - testSecondaryItem.stateListener = - object : DownloadItem.StateListener { - override fun onStarted() {} - - override fun onCompleted() {} - - override fun onDeleted() { - deletedSecondary = true - } - } - - testDownloadItemWithSecondaryNotDeletingSecondary.start(activityTestRule.activity) { - assertNotNull(it) - waitWhile({ !completedMain }) - - testDownloadItemWithSecondaryNotDeletingSecondary.delete(activityTestRule.activity) { - assertTrue(it) - - assertTrue(deletedMain) - assertFalse(deletedSecondary) - assertFalse(testDownloadItemWithSecondaryNotDeletingSecondary.downloadExists) - assertTrue(testSecondaryItem.downloadExists) - assertNull(testDownloadItemWithSecondaryNotDeletingSecondary.download) - assertNotNull(testSecondaryItem.download) - } - } - } -} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/BaseDownloadTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/BaseDownloadTest.kt new file mode 100644 index 000000000..2bdc10469 --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/BaseDownloadTest.kt @@ -0,0 +1,22 @@ +package de.xikolo.testing.instrumented.unit.download + +import android.Manifest +import androidx.test.rule.ActivityTestRule +import androidx.test.rule.GrantPermissionRule +import de.xikolo.controllers.downloads.DownloadsActivity +import de.xikolo.testing.instrumented.mocking.base.BaseTest +import org.junit.Rule + +// Parallel test execution needs to be disabled for all downloading tests, because it can occur +// that @Before deleteAllDownloads is called while another test is still being executed. +abstract class BaseDownloadTest : BaseTest() { + + @Rule + @JvmField + var activityTestRule = + ActivityTestRule(DownloadsActivity::class.java, false, true) + + @Rule + @JvmField + var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE) +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/DownloadHandlerTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/DownloadHandlerTest.kt new file mode 100644 index 000000000..0b8a0beb7 --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/DownloadHandlerTest.kt @@ -0,0 +1,226 @@ +package de.xikolo.testing.instrumented.unit.download + +import de.xikolo.download.DownloadHandler +import de.xikolo.download.DownloadIdentifier +import de.xikolo.download.DownloadRequest +import de.xikolo.download.DownloadStatus +import de.xikolo.models.Storage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +abstract class DownloadHandlerTest, + I : DownloadIdentifier, R : DownloadRequest> : BaseDownloadTest() { + + abstract val downloadHandler: T + + abstract val successfulTestRequest: R + abstract val successfulTestRequest2: R + abstract val failingTestRequest: R + abstract val storage: Storage + + @Before + fun deleteAllDownloads() { + fun deleteDownload(request: R) { + val identifier = downloadHandler.identify(request) + var status: DownloadStatus? = null + downloadHandler.listen(identifier) { + status = it + } + downloadHandler.delete(identifier) + waitWhile({ status?.state?.equals(DownloadStatus.State.DELETED) != true }, 3000) + downloadHandler.listen(identifier, null) + } + + deleteDownload(successfulTestRequest) + deleteDownload(successfulTestRequest2) + deleteDownload(failingTestRequest) + } + + @Test + fun testDownloadIdentification() { + downloadHandler.identify(successfulTestRequest) + downloadHandler.identify(successfulTestRequest2) + downloadHandler.identify(failingTestRequest) + } + + @Test + fun testDownloadListenerRegistration() { + val identifier = downloadHandler.identify(successfulTestRequest) + + var listenerCalled = false + // register listener + downloadHandler.listen(identifier) { + listenerCalled = true + } + waitWhile({ !listenerCalled }, 3000) + + // reset `listenerCalled` to false + listenerCalled = false + // unregister listener + downloadHandler.listen(identifier, null) + // perform an action + downloadHandler.download(successfulTestRequest) + try { + // wait for listener to set `listenerCalled` to true, if this is the case then fail + waitWhile({ !listenerCalled }, 3000) + fail("Unregistered listener has been invoked") + } catch (e: Exception) { + // unregistered listener has not been invoked, which is expected behavior + } + } + + @Test + fun testDownloadStatusDuringProcess() { + val identifier = downloadHandler.identify(successfulTestRequest) + + var status: DownloadStatus? = null + downloadHandler.listen(identifier) { + status = it + } + + // start download + var downloadCallbackCalled = false + downloadHandler.download(successfulTestRequest) { + downloadCallbackCalled = it + } + // assert that the download callback has been called + waitWhile({ !downloadCallbackCalled }, 3000) + + // wait for download to start + waitWhile({ + status?.state?.equals(DownloadStatus.State.DELETED) != false || ( + status?.state?.equals(DownloadStatus.State.PENDING) != true && + status?.state?.equals(DownloadStatus.State.RUNNING) != true + ) + }) + + // test status after start + assertNotNull(status!!.totalBytes) + assertNotNull(status!!.downloadedBytes) + if (status!!.totalBytes!! >= 0L) { + assertTrue( + status!!.downloadedBytes!! <= status!!.totalBytes!! + ) + } + + var isDownloadingAnythingCallbackCalled = false + downloadHandler.isDownloadingAnything { + isDownloadingAnythingCallbackCalled = it + } + // assert that isDownloadingAnything returns true + waitWhile({ !isDownloadingAnythingCallbackCalled }, 3000) + + // wait for download to finish + waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) != true }) + + // test status after end + assertNotNull(status!!.totalBytes) + assertNotNull(status!!.downloadedBytes) + assertEquals(status!!.totalBytes, status!!.downloadedBytes) + + var deleteCallbackCalled = false + downloadHandler.delete(identifier) { + deleteCallbackCalled = it + } + // assert that the delete callback has been called + waitWhile({ !deleteCallbackCalled }, 3000) + waitWhile({ status!!.state != DownloadStatus.State.DELETED }, 3000) + } + + @Test + fun testDownloadStatusAfterCancel() { + val identifier = downloadHandler.identify(successfulTestRequest) + + // register listener + var status: DownloadStatus? = null + downloadHandler.listen(identifier) { + status = it + } + + var deleteCallbackCalled = false + // start download + downloadHandler.download(successfulTestRequest) { + // cancel running download and check status + downloadHandler.delete(identifier) { + deleteCallbackCalled = true + } + } + + // assert that the delete callback has been called + waitWhile({ !deleteCallbackCalled }, 3000) + waitWhile({ status?.state?.equals(DownloadStatus.State.DELETED) != true }, 3000) + } + + @Test + fun testDownloadStatusAfterFailure() { + val identifier = downloadHandler.identify(failingTestRequest) + + var status: DownloadStatus? = null + downloadHandler.listen(identifier) { + status = it + } + // start download + downloadHandler.download(successfulTestRequest) + // wait for download to fail + waitWhile({ status?.state?.equals(DownloadStatus.State.DELETED) != true }) + } + + @Test + fun testGettingDownloads() { + var count: Int? = null + downloadHandler.getDownloads(storage) { + count = it.size + } + // wait for and check result + waitWhile({ count == null }, 1000) + + var downloaded = false + downloadHandler.listen(downloadHandler.identify(successfulTestRequest)) { + if (it.state == DownloadStatus.State.DOWNLOADED) { + downloaded = true + } + } + // start download + downloadHandler.download(successfulTestRequest) + // wait for download to finish + waitWhile({ !downloaded }) + + var nextCount: Int? = null + downloadHandler.getDownloads(storage) { + nextCount = it.size + } + // wait for result + waitWhile({ nextCount == null }, 1000) + + assertEquals(count!! + 1, nextCount) + } + + @Test + fun testParallelDownloading() { + var result = false + downloadHandler.download(successfulTestRequest) { + result = it + } + var result2 = false + downloadHandler.download(successfulTestRequest2) { + result2 = it + } + // wait for `result` and `result2` to become true + waitWhile({ !result || !result2 }) + } + + protected fun waitWhile(condition: () -> Boolean, timeout: Long = 60000) { + var waited = 0 + while (condition()) { + Thread.sleep(100) + waited += 100 + if (waited > timeout) { + throw Exception("Condition timeout") + } + } + } +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/DownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/DownloadItemTest.kt new file mode 100644 index 000000000..274f39430 --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/DownloadItemTest.kt @@ -0,0 +1,184 @@ +package de.xikolo.testing.instrumented.unit.download + +import de.xikolo.download.DownloadIdentifier +import de.xikolo.download.DownloadItem +import de.xikolo.download.DownloadStatus +import de.xikolo.extensions.observe +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +abstract class DownloadItemTest, + D, I : DownloadIdentifier> : BaseDownloadTest() { + + abstract val testDownloadItem: T + abstract val testDownloadItemNotDownloadable: T + + @Before + fun deleteAllItems() { + fun deleteItem(item: DownloadItem) { + var deleted = false + onUiThread { + item.status.observe(activityTestRule.activity) { + if (it.state == DownloadStatus.State.DELETED) { + deleted = true + } + } + item.delete(activityTestRule.activity) + } + + waitWhile({ !deleted }, 10000) + } + + deleteItem(testDownloadItem) + } + + @Test + fun testIdentifier() { + testDownloadItem.identifier + try { + testDownloadItemNotDownloadable.identifier + fail("Statement should fail") + } catch (e: Exception) { + // expected behavior + } + } + + @Test + fun testDownload() { + testDownloadItem.download + assertNull(testDownloadItemNotDownloadable.download) + } + + @Test + fun testDownloadable() { + assertTrue(testDownloadItem.downloadable) + assertFalse(testDownloadItemNotDownloadable.downloadable) + } + + @Test + fun testTitle() { + testDownloadItem.title + testDownloadItemNotDownloadable.title + } + + @Test + fun testOpenAction() { + testDownloadItem.openAction + testDownloadItemNotDownloadable.openAction + } + + @Test + fun testSize() { + testDownloadItem.size + testDownloadItemNotDownloadable.size + } + + @Test + fun testStatus() { + var called = false + onUiThread { + testDownloadItem.status + called = true + } + + waitWhile({ !called }, 1000) + + try { + testDownloadItemNotDownloadable.status + fail("Statement should fail") + } catch (e: Exception) { + // expected behavior + } + } + + @Test + fun testStatusBefore() { + var called = false + onUiThread { + testDownloadItem.status.observe(activityTestRule.activity) { + assertNotNull(it) + assertNull(testDownloadItem.download) + called = true + } + } + + waitWhile({ !called }, 1000) + } + + @Test + fun testDownloadAndDelete() { + var downloaded = false + onUiThread { + testDownloadItem.status.observe(activityTestRule.activity) { + if (it.state == DownloadStatus.State.DOWNLOADED) { + downloaded = true + } + } + } + + assertNull(testDownloadItem.download) + + var startResult = false + onUiThread { + testDownloadItem.start(activityTestRule.activity) { + startResult = it + } + } + + waitWhile({ !startResult }, 3000) + waitWhile({ !downloaded }) + assertNotNull(testDownloadItem.download) + + var deleted = false + onUiThread { + testDownloadItem.status.observe(activityTestRule.activity) { + if (it.state == DownloadStatus.State.DELETED) { + deleted = true + } + } + } + + var deleteResult = false + onUiThread { + testDownloadItem.delete(activityTestRule.activity) { + deleteResult = it + } + } + + waitWhile({ !deleteResult }, 3000) + waitWhile({ !deleted }) + assertNull(testDownloadItem.download) + } + + @Test + fun testDownloadStartForNotDownloadable() { + var startResult = true + onUiThread { + testDownloadItemNotDownloadable.start(activityTestRule.activity) { + startResult = it + } + } + + waitWhile({ startResult }, 3000) + } + + protected fun waitWhile(condition: () -> Boolean, timeout: Long = 60000) { + var waited = 0 + while (condition()) { + Thread.sleep(100) + waited += 100 + if (waited > timeout) { + throw Exception("Condition timeout") + } + } + } + + protected fun onUiThread(block: Runnable) { + activityTestRule.activity.runOnUiThread(block) + } +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/AbstractFileDownloadHandlerTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/AbstractFileDownloadHandlerTest.kt new file mode 100644 index 000000000..8fa5dc57f --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/AbstractFileDownloadHandlerTest.kt @@ -0,0 +1,56 @@ +package de.xikolo.testing.instrumented.unit.download.filedownload + +import de.xikolo.download.DownloadCategory +import de.xikolo.download.DownloadStatus +import de.xikolo.download.filedownload.FileDownloadHandler +import de.xikolo.download.filedownload.FileDownloadIdentifier +import de.xikolo.download.filedownload.FileDownloadRequest +import de.xikolo.testing.instrumented.unit.download.DownloadHandlerTest +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +abstract class AbstractFileDownloadHandlerTest : DownloadHandlerTest() { + + override val downloadHandler = FileDownloadHandler + + override val successfulTestRequest: FileDownloadRequest + get() = FileDownloadRequest( + "https://file-examples-com.github.io/uploads/2017/04/file_example_MP4_1280_10MG.mp4", + File(storage.file, "file1"), + "File 1", + true, + DownloadCategory.Other + ) + override val successfulTestRequest2 + get() = FileDownloadRequest( + "https://file-examples-com.github.io/uploads/2017/10/file-example_PDF_1MB.pdf", + File(storage.file, "file2"), + "File 2", + true, + DownloadCategory.Other + ) + override val failingTestRequest + get() = FileDownloadRequest( + "https://www.example.com/notfoundfilehwqnqkdrzn42r.mp4", + File(storage.file, "failingfile"), + "Failing File", + true, + DownloadCategory.Other + ) + + @Test + fun testSizeAfterDownload() { + var status: DownloadStatus? = null + downloadHandler.listen(downloadHandler.identify(successfulTestRequest)) { + status = it + } + // start download + downloadHandler.download(successfulTestRequest) + // wait for download to finish + waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) != true }) + + assertTrue(successfulTestRequest.localFile.length() > 0) + } +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/AbstractFileDownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/AbstractFileDownloadItemTest.kt new file mode 100644 index 000000000..58f429ae9 --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/AbstractFileDownloadItemTest.kt @@ -0,0 +1,98 @@ +package de.xikolo.testing.instrumented.unit.download.filedownload + +import de.xikolo.download.DownloadCategory +import de.xikolo.download.DownloadStatus +import de.xikolo.download.filedownload.FileDownloadIdentifier +import de.xikolo.download.filedownload.FileDownloadItem +import de.xikolo.models.Storage +import de.xikolo.testing.instrumented.unit.download.DownloadItemTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +abstract class AbstractFileDownloadItemTest : DownloadItemTest() { + + abstract val storage: Storage + + override val testDownloadItem + get() = FileDownloadItem( + "https://file-examples-com.github.io/uploads/2017/04/file_example_MP4_1280_10MG.mp4", + DownloadCategory.Other, + "video.mp4", + storage + ) + + override val testDownloadItemNotDownloadable + get() = FileDownloadItem( + null, + DownloadCategory.Other, + "null.null", + storage + ) + + @Test + fun testFileName() { + testDownloadItem.fileName + testDownloadItemNotDownloadable.fileName + } + + @Test + fun testFileHandling() { + var downloaded = false + var downloadCallbackCalled = false + onUiThread { + testDownloadItem.status.observe(activityTestRule.activity) { + if (it.state == DownloadStatus.State.DOWNLOADED) { + downloaded = true + } + } + testDownloadItem.start(activityTestRule.activity) { + downloadCallbackCalled = true + } + } + + // wait for download to start + waitWhile({ !downloadCallbackCalled }, 10000) + + assertFalse( + File(testDownloadItem.filePath).exists() + ) + assertTrue( + File(testDownloadItem.filePath + ".tmp").exists() + ) + + // wait for completion + waitWhile({ !downloaded }) + + assertTrue( + File(testDownloadItem.filePath).exists() + ) + assertFalse( + File(testDownloadItem.filePath + ".tmp").exists() + ) + assertTrue( + testDownloadItem.download?.exists() == true + ) + + var deleted = false + onUiThread { + testDownloadItem.status.observe(activityTestRule.activity) { + if (it.state == DownloadStatus.State.DELETED) { + deleted = true + } + } + testDownloadItem.delete(activityTestRule.activity) + } + + waitWhile({ !deleted }, 3000) + + assertFalse( + File(testDownloadItem.filePath).exists() + ) + assertFalse( + testDownloadItem.download?.exists() == true + ) + } +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/ExternalStorageFileDownloadHandlerTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/ExternalStorageFileDownloadHandlerTest.kt new file mode 100644 index 000000000..eef2eec1b --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/ExternalStorageFileDownloadHandlerTest.kt @@ -0,0 +1,8 @@ +package de.xikolo.testing.instrumented.unit.download.filedownload + +import de.xikolo.utils.extensions.sdcardStorage + +class ExternalStorageFileDownloadHandlerTest : AbstractFileDownloadHandlerTest() { + + override val storage = context.sdcardStorage!! +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/ExternalStorageFileDownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/ExternalStorageFileDownloadItemTest.kt new file mode 100644 index 000000000..e3ea20e3c --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/ExternalStorageFileDownloadItemTest.kt @@ -0,0 +1,8 @@ +package de.xikolo.testing.instrumented.unit.download.filedownload + +import de.xikolo.utils.extensions.sdcardStorage + +class ExternalStorageFileDownloadItemTest : AbstractFileDownloadItemTest() { + + override val storage = context.sdcardStorage!! +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/InternalStorageFileDownloadHandlerTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/InternalStorageFileDownloadHandlerTest.kt new file mode 100644 index 000000000..19eb28cd4 --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/InternalStorageFileDownloadHandlerTest.kt @@ -0,0 +1,8 @@ +package de.xikolo.testing.instrumented.unit.download.filedownload + +import de.xikolo.utils.extensions.internalStorage + +class InternalStorageFileDownloadHandlerTest : AbstractFileDownloadHandlerTest() { + + override val storage = context.internalStorage +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/InternalStorageFileDownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/InternalStorageFileDownloadItemTest.kt new file mode 100644 index 000000000..49b243b01 --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/InternalStorageFileDownloadItemTest.kt @@ -0,0 +1,8 @@ +package de.xikolo.testing.instrumented.unit.download.filedownload + +import de.xikolo.utils.extensions.internalStorage + +class InternalStorageFileDownloadItemTest : AbstractFileDownloadItemTest() { + + override val storage = context.internalStorage +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/AbstractHlsVideoDownloadHandlerTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/AbstractHlsVideoDownloadHandlerTest.kt new file mode 100644 index 000000000..7a48513be --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/AbstractHlsVideoDownloadHandlerTest.kt @@ -0,0 +1,84 @@ +package de.xikolo.testing.instrumented.unit.download.hlsvideodownload + +import de.xikolo.controllers.helper.VideoSettingsHelper +import de.xikolo.download.DownloadCategory +import de.xikolo.download.DownloadStatus +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadHandler +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadIdentifier +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadRequest +import de.xikolo.testing.instrumented.unit.download.DownloadHandlerTest +import de.xikolo.utils.extensions.preferredStorage +import org.junit.Assert +import org.junit.Test + +abstract class AbstractHlsVideoDownloadHandlerTest : DownloadHandlerTest() { + + override val downloadHandler = HlsVideoDownloadHandler + + override val successfulTestRequest + get() = HlsVideoDownloadRequest( + "https://open.hpi.de/playlists/93a84211-e40a-416a-b224-4d3ecdbb12f9.m3u8?" + + "embed_subtitles_for_video=d7e056da-756f-4437-b64a-16970a33d5ef", + VideoSettingsHelper.VideoQuality.LOW.qualityFraction, + context.preferredStorage, + "Video 1", + true, + DownloadCategory.Other + ) + override val successfulTestRequest2 = HlsVideoDownloadRequest( + "https://open.hpi.de/playlists/04012fde-be48-47b6-a742-0edc69a9c2a9.m3u8?" + + "embed_subtitles_for_video=d7e056da-756f-4437-b64a-16970a33d5ef", + VideoSettingsHelper.VideoQuality.BEST.qualityFraction, + context.preferredStorage, + "Video 2", + true, + DownloadCategory.Other + ) + override val failingTestRequest = HlsVideoDownloadRequest( + "https://www.example.com/notfoundfilehwqnqkdrzn42r.m3u8", + VideoSettingsHelper.VideoQuality.BEST.qualityFraction, + context.preferredStorage, + "Failing Video", + true, + DownloadCategory.Other + ) + + @Test + fun testQualitySelection() { + val identifier1 = downloadHandler.identify(successfulTestRequest) + val identifier2 = downloadHandler.identify(successfulTestRequest2) + + Assert.assertNotEquals(identifier1, identifier2) + + var status1: DownloadStatus? = null + downloadHandler.listen(identifier1) { + status1 = it + } + var status2: DownloadStatus? = null + downloadHandler.listen(identifier2) { + status2 = it + } + + // start downloads + downloadHandler.download(successfulTestRequest) + downloadHandler.download(successfulTestRequest2) + + // wait for download to finish + waitWhile({ + status1?.state?.equals(DownloadStatus.State.DOWNLOADED) != true || + status2?.state?.equals(DownloadStatus.State.DOWNLOADED) != true + }) + + // test status after end + Assert.assertNotEquals( + status1?.downloadedBytes, + status2?.downloadedBytes + ) + + Assert.assertNotEquals( + status1?.totalBytes, + status2?.totalBytes + ) + } +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/AbstractHlsVideoDownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/AbstractHlsVideoDownloadItemTest.kt new file mode 100644 index 000000000..332fe1170 --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/AbstractHlsVideoDownloadItemTest.kt @@ -0,0 +1,35 @@ +package de.xikolo.testing.instrumented.unit.download.hlsvideodownload + +import com.google.android.exoplayer2.source.SingleSampleMediaSource +import com.google.android.exoplayer2.source.hls.HlsMediaSource +import de.xikolo.controllers.helper.VideoSettingsHelper +import de.xikolo.download.DownloadCategory +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadIdentifier +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadItem +import de.xikolo.models.Storage +import de.xikolo.testing.instrumented.unit.download.DownloadItemTest + +abstract class AbstractHlsVideoDownloadItemTest : DownloadItemTest>, HlsVideoDownloadIdentifier>() { + + abstract val storage: Storage + + override val testDownloadItem + get() = HlsVideoDownloadItem( + "https://open.hpi.de/playlists/93a84211-e40a-416a-b224-4d3ecdbb12f9.m3u8?" + + "embed_subtitles_for_video=d7e056da-756f-4437-b64a-16970a33d5ef", + DownloadCategory.Other, + VideoSettingsHelper.VideoQuality.HIGH.qualityFraction, + emptyMap(), + storage + ) + + override val testDownloadItemNotDownloadable + get() = HlsVideoDownloadItem( + null, + DownloadCategory.Other, + 0.0f, + emptyMap(), + storage + ) +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/ExternalStorageHlsVideoDownloadHandlerTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/ExternalStorageHlsVideoDownloadHandlerTest.kt new file mode 100644 index 000000000..d7555b1aa --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/ExternalStorageHlsVideoDownloadHandlerTest.kt @@ -0,0 +1,8 @@ +package de.xikolo.testing.instrumented.unit.download.hlsvideodownload + +import de.xikolo.utils.extensions.sdcardStorage + +class ExternalStorageHlsVideoDownloadHandlerTest : AbstractHlsVideoDownloadHandlerTest() { + + override val storage = context.sdcardStorage!! +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/ExternalStorageHlsVideoDownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/ExternalStorageHlsVideoDownloadItemTest.kt new file mode 100644 index 000000000..0446f81d4 --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/ExternalStorageHlsVideoDownloadItemTest.kt @@ -0,0 +1,8 @@ +package de.xikolo.testing.instrumented.unit.download.hlsvideodownload + +import de.xikolo.utils.extensions.sdcardStorage + +class ExternalStorageHlsVideoDownloadItemTest : AbstractHlsVideoDownloadItemTest() { + + override val storage = context.sdcardStorage!! +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/InternalStorageHlsVideoDownloadHandlerTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/InternalStorageHlsVideoDownloadHandlerTest.kt new file mode 100644 index 000000000..20499cd28 --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/InternalStorageHlsVideoDownloadHandlerTest.kt @@ -0,0 +1,8 @@ +package de.xikolo.testing.instrumented.unit.download.hlsvideodownload + +import de.xikolo.utils.extensions.internalStorage + +class InternalStorageHlsVideoDownloadHandlerTest : AbstractHlsVideoDownloadHandlerTest() { + + override val storage = context.internalStorage +} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/InternalStorageHlsVideoDownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/InternalStorageHlsVideoDownloadItemTest.kt new file mode 100644 index 000000000..daacebc3e --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/InternalStorageHlsVideoDownloadItemTest.kt @@ -0,0 +1,8 @@ +package de.xikolo.testing.instrumented.unit.download.hlsvideodownload + +import de.xikolo.utils.extensions.internalStorage + +class InternalStorageHlsVideoDownloadItemTest : AbstractHlsVideoDownloadItemTest() { + + override val storage = context.internalStorage +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 12e3be0da..0279505c1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,7 +8,6 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + = 26 && App.instance.packageManager diff --git a/app/src/main/java/de/xikolo/controllers/base/BaseActivity.kt b/app/src/main/java/de/xikolo/controllers/base/BaseActivity.kt index 8961889d5..a7c20f439 100644 --- a/app/src/main/java/de/xikolo/controllers/base/BaseActivity.kt +++ b/app/src/main/java/de/xikolo/controllers/base/BaseActivity.kt @@ -265,7 +265,7 @@ abstract class BaseActivity : AppCompatActivity(), CastStateListener { private fun handleIntent(intent: Intent?) { if (intent != null) { - NotificationUtil.deleteDownloadNotificationsFromIntent(intent) + NotificationUtil.getInstance(this).deleteDownloadNotificationsFromIntent(intent) } } diff --git a/app/src/main/java/de/xikolo/controllers/channels/ChannelCourseListAdapter.kt b/app/src/main/java/de/xikolo/controllers/channels/ChannelCourseListAdapter.kt index 2c1c908eb..04b4acd60 100644 --- a/app/src/main/java/de/xikolo/controllers/channels/ChannelCourseListAdapter.kt +++ b/app/src/main/java/de/xikolo/controllers/channels/ChannelCourseListAdapter.kt @@ -22,7 +22,7 @@ import de.xikolo.utils.extensions.isBetween import de.xikolo.utils.extensions.setMarkdownText import de.xikolo.utils.extensions.videoThumbnailSize import de.xikolo.views.CustomSizeImageView -import java.util.* +import java.util.Date class ChannelCourseListAdapter(fragment: Fragment, onCourseButtonClickListener: OnCourseButtonClickListener) : BaseCourseListAdapter>(fragment, onCourseButtonClickListener) { @@ -59,13 +59,19 @@ class ChannelCourseListAdapter(fragment: Fragment, onCourseButtonClickListener: holder.text.visibility = View.GONE } - if (stageStream?.hdUrl != null || stageStream?.sdUrl != null) { + if (stageStream?.hlsUrl != null || + stageStream?.hdUrl != null || + stageStream?.sdUrl != null + ) { holder.videoPreview.visibility = View.VISIBLE if (imageUrl != null) { GlideApp.with(fragment) .load(imageUrl) - .override(holder.imageVideoThumbnail.forcedWidth, holder.imageVideoThumbnail.forcedHeight) + .override( + holder.imageVideoThumbnail.forcedWidth, + holder.imageVideoThumbnail.forcedHeight + ) .into(holder.imageVideoThumbnail) } diff --git a/app/src/main/java/de/xikolo/controllers/channels/ChannelDetailsActivity.kt b/app/src/main/java/de/xikolo/controllers/channels/ChannelDetailsActivity.kt index 3f361b77e..359cddf70 100644 --- a/app/src/main/java/de/xikolo/controllers/channels/ChannelDetailsActivity.kt +++ b/app/src/main/java/de/xikolo/controllers/channels/ChannelDetailsActivity.kt @@ -50,7 +50,10 @@ class ChannelDetailsActivity : CollapsingToolbarViewModelActivity() { private fun showDescription(course: Course) { when { - course.teaserStream != null - && (course.teaserStream.hdUrl != null || course.teaserStream.sdUrl != null) -> { + course.teaserStream != null && ( + course.teaserStream.hlsUrl != null || + course.teaserStream.hdUrl != null || + course.teaserStream.sdUrl != null + ) -> { courseImage.visibility = View.GONE videoPreview.visibility = View.VISIBLE diff --git a/app/src/main/java/de/xikolo/controllers/dialogs/ModuleDownloadDialog.kt b/app/src/main/java/de/xikolo/controllers/dialogs/ModuleDownloadDialog.kt index c0dee0ff3..a442b9771 100644 --- a/app/src/main/java/de/xikolo/controllers/dialogs/ModuleDownloadDialog.kt +++ b/app/src/main/java/de/xikolo/controllers/dialogs/ModuleDownloadDialog.kt @@ -17,10 +17,7 @@ class ModuleDownloadDialog : BaseDialogFragment() { var listener: ItemSelectionListener? = null @AutoBundleField(required = false) - var hdVideo: Boolean = false - - @AutoBundleField(required = false) - var sdVideo: Boolean = false + var video: Boolean = false @AutoBundleField(required = false) var slides: Boolean = false @@ -38,13 +35,12 @@ class ModuleDownloadDialog : BaseDialogFragment() { null ) { _, which, isChecked -> when (which) { - 0 -> hdVideo = isChecked - 1 -> sdVideo = isChecked - 2 -> slides = isChecked + 0 -> video = isChecked + 1 -> slides = isChecked } } .setPositiveButton(R.string.download) { _, _ -> - listener?.onSelected(this, hdVideo, sdVideo, slides) + listener?.onSelected(this, video, slides) } .setNegativeButton(R.string.dialog_negative) { _, _ -> dialog?.cancel() } .setCancelable(true) @@ -56,7 +52,7 @@ class ModuleDownloadDialog : BaseDialogFragment() { } interface ItemSelectionListener { - fun onSelected(dialog: DialogFragment, hdVideo: Boolean, sdVideo: Boolean, slides: Boolean) + fun onSelected(dialog: DialogFragment, video: Boolean, slides: Boolean) } } diff --git a/app/src/main/java/de/xikolo/controllers/dialogs/StorageMigrationDialog.kt b/app/src/main/java/de/xikolo/controllers/dialogs/StorageMigrationDialog.kt deleted file mode 100644 index b57b1f818..000000000 --- a/app/src/main/java/de/xikolo/controllers/dialogs/StorageMigrationDialog.kt +++ /dev/null @@ -1,41 +0,0 @@ -package de.xikolo.controllers.dialogs - -import android.app.Dialog -import android.os.Bundle -import androidx.appcompat.app.AlertDialog -import com.yatatsu.autobundle.AutoBundleField -import de.xikolo.R -import de.xikolo.controllers.dialogs.base.BaseDialogFragment -import de.xikolo.models.Storage -import de.xikolo.utils.extensions.buildMigrationMessage - -class StorageMigrationDialog : BaseDialogFragment() { - - companion object { - val TAG: String = StorageMigrationDialog::class.java.simpleName - } - - @AutoBundleField - lateinit var from: Storage.Type - - var listener: Listener? = null - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = AlertDialog.Builder(requireActivity()) - .setTitle(activity!!.getString(R.string.dialog_storage_migration_title)) - .setMessage(activity?.buildMigrationMessage(from)) - .setPositiveButton(R.string.dialog_storage_migration_confirm) { _, _ -> listener?.onDialogPositiveClick() } - .setNegativeButton(R.string.dialog_no) { dialog, _ -> dialog.cancel() } - .setCancelable(true) - - val dialog = builder.create() - dialog.setCanceledOnTouchOutside(true) - - return dialog - } - - interface Listener { - fun onDialogPositiveClick() - } - -} diff --git a/app/src/main/java/de/xikolo/controllers/dialogs/VideoDownloadQualityHintDialog.kt b/app/src/main/java/de/xikolo/controllers/dialogs/VideoDownloadQualityHintDialog.kt new file mode 100644 index 000000000..68fcb838d --- /dev/null +++ b/app/src/main/java/de/xikolo/controllers/dialogs/VideoDownloadQualityHintDialog.kt @@ -0,0 +1,54 @@ +package de.xikolo.controllers.dialogs + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import de.xikolo.R +import de.xikolo.controllers.dialogs.base.BaseDialogFragment + +class VideoDownloadQualityHintDialog : BaseDialogFragment() { + + companion object { + @JvmField + val TAG: String = VideoDownloadQualityHintDialog::class.java.simpleName + } + + var listener: Listener? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_download_quality_hint_intent_title) + .setMessage( + getString( + R.string.dialog_download_quality_hint_intent_message, + getString(R.string.settings_title_video_download_quality) + ) + ) + .setPositiveButton( + getString(R.string.dialog_download_quality_hint_intent_yes) + ) { _, _ -> + listener?.onOpenSettingsClicked() + } + .setNegativeButton( + getString(R.string.dialog_download_quality_hint_intent_no) + ) { _, _ -> + listener?.onDismissed() + } + .setCancelable(true) + + val dialog = builder.create() + dialog.setCanceledOnTouchOutside(false) + + return dialog + } + + override fun onCancel(dialog: DialogInterface) { + listener?.onDismissed() + } + + interface Listener { + fun onOpenSettingsClicked() + fun onDismissed() + } +} diff --git a/app/src/main/java/de/xikolo/controllers/downloads/DownloadsAdapter.kt b/app/src/main/java/de/xikolo/controllers/downloads/DownloadsAdapter.kt index d5e00f51b..68636a88d 100644 --- a/app/src/main/java/de/xikolo/controllers/downloads/DownloadsAdapter.kt +++ b/app/src/main/java/de/xikolo/controllers/downloads/DownloadsAdapter.kt @@ -11,11 +11,8 @@ import de.xikolo.App import de.xikolo.R import de.xikolo.utils.MetaSectionList import de.xikolo.utils.extensions.asFormattedFileSize -import de.xikolo.utils.extensions.fileCount -import de.xikolo.utils.extensions.folderSize -import java.io.File -class DownloadsAdapter(private val callback: OnDeleteButtonClickedListener) : RecyclerView.Adapter() { +class DownloadsAdapter : RecyclerView.Adapter() { companion object { val TAG: String = DownloadsAdapter::class.java.simpleName @@ -23,12 +20,12 @@ class DownloadsAdapter(private val callback: OnDeleteButtonClickedListener) : Re private const val ITEM_VIEW_TYPE_ITEM = 1 } - private val sectionList: MetaSectionList> = + private val sectionList: MetaSectionList> = MetaSectionList() - fun addItem(header: String, folder: List) { - if (folder.isNotEmpty()) { - sectionList.add(header, folder) + fun addItem(header: String, downloadCategory: List) { + if (downloadCategory.isNotEmpty()) { + sectionList.add(header, downloadCategory) notifyDataSetChanged() } } @@ -68,30 +65,34 @@ class DownloadsAdapter(private val callback: OnDeleteButtonClickedListener) : Re if (holder is HeaderViewHolder) { holder.title.text = sectionList.get(position) as String } else { - val viewHolder = holder as FolderViewHolder - - val folderItem = sectionList.get(position) as FolderItem - - val context = App.instance - - val dir = File(folderItem.path) - viewHolder.textTitle.text = folderItem.title.replace("_".toRegex(), " ") - - val numberOfFiles = dir.fileCount.toLong() + val downloadCategory = sectionList.get(position) as DownloadCategory + val viewHolder = holder as FolderViewHolder + viewHolder.textTitle.text = downloadCategory.title.replace("_".toRegex(), " ") viewHolder.textButtonDelete.setOnClickListener { - callback.onDeleteButtonClicked( - folderItem - ) + downloadCategory.onDelete() } - if (numberOfFiles > 0) { - viewHolder.textSubTitle.text = numberOfFiles.toString() + " " + context.getString(R.string.files) + ": " + dir.folderSize.asFormattedFileSize - viewHolder.textButtonDelete.visibility = View.VISIBLE - } else { - viewHolder.textSubTitle.text = numberOfFiles.toString() + " " + context.getString(R.string.files) - viewHolder.textButtonDelete.visibility = View.GONE - } + viewHolder.textSubTitle.text = + when { + downloadCategory.itemCount < 0 -> { + viewHolder.textButtonDelete.visibility = View.VISIBLE + downloadCategory.size.asFormattedFileSize + } + downloadCategory.itemCount == 0 -> { + viewHolder.textButtonDelete.visibility = View.GONE + downloadCategory.itemCount.toString() + " " + + App.instance.getString(R.string.files) + } + else -> { + viewHolder.textButtonDelete.visibility = View.VISIBLE + downloadCategory.itemCount.toString() + " " + + App.instance.getString(R.string.files) + + if (downloadCategory.size > 0) { + ": " + downloadCategory.size.asFormattedFileSize + } else "" + } + } if (position == itemCount - 1 || sectionList.isHeader(position + 1)) { viewHolder.viewDivider.visibility = View.INVISIBLE @@ -101,13 +102,12 @@ class DownloadsAdapter(private val callback: OnDeleteButtonClickedListener) : Re } } - class FolderItem(val title: String, val path: String) - - interface OnDeleteButtonClickedListener { - - fun onDeleteButtonClicked(item: FolderItem) - - } + data class DownloadCategory( + val title: String, + val size: Long, + val itemCount: Int, + val onDelete: () -> Unit + ) internal class FolderViewHolder(view: View) : RecyclerView.ViewHolder(view) { diff --git a/app/src/main/java/de/xikolo/controllers/downloads/DownloadsFragment.kt b/app/src/main/java/de/xikolo/controllers/downloads/DownloadsFragment.kt index 863f35a36..44be9efe8 100644 --- a/app/src/main/java/de/xikolo/controllers/downloads/DownloadsFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/downloads/DownloadsFragment.kt @@ -15,21 +15,21 @@ import de.xikolo.config.Feature import de.xikolo.controllers.dialogs.ConfirmDeleteDialog import de.xikolo.controllers.dialogs.ConfirmDeleteDialogAutoBundle import de.xikolo.controllers.helper.NetworkStateHelper +import de.xikolo.download.DownloadCategory +import de.xikolo.download.DownloadIdentifier +import de.xikolo.download.DownloadStatus +import de.xikolo.download.Downloaders import de.xikolo.extensions.observe import de.xikolo.managers.PermissionManager -import de.xikolo.models.Storage +import de.xikolo.models.dao.CourseDao import de.xikolo.storages.ApplicationPreferences -import de.xikolo.utils.extensions.createIfNotExists -import de.xikolo.utils.extensions.deleteAll -import de.xikolo.utils.extensions.foldersWithFiles import de.xikolo.utils.extensions.internalStorage import de.xikolo.utils.extensions.preferredStorage import de.xikolo.utils.extensions.sdcardStorage import de.xikolo.utils.extensions.showToast -import java.io.File -import java.util.ArrayList -class DownloadsFragment : Fragment(), SwipeRefreshLayout.OnRefreshListener, DownloadsAdapter.OnDeleteButtonClickedListener, NetworkStateHelper.NetworkStateOwner { +class DownloadsFragment : + Fragment(), SwipeRefreshLayout.OnRefreshListener, NetworkStateHelper.NetworkStateOwner { companion object { val TAG: String = DownloadsFragment::class.java.simpleName @@ -46,11 +46,15 @@ class DownloadsFragment : Fragment(), SwipeRefreshLayout.OnRefreshListener, Down activity?.let { activity -> permissionManager = PermissionManager(activity) - adapter = DownloadsAdapter(this) + adapter = DownloadsAdapter() } } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { // Inflate the layout for this fragment val layout = inflater.inflate(R.layout.fragment_downloads, container, false) @@ -83,138 +87,208 @@ class DownloadsFragment : Fragment(), SwipeRefreshLayout.OnRefreshListener, Down networkStateHelper.hideAnyProgress() } - private fun fetchItems() { - activity?.let { activity -> - adapter?.clear() - if (permissionManager?.requestPermission(PermissionManager.WRITE_EXTERNAL_STORAGE) == 1) { - networkStateHelper.showContent() - - // total items - - var internalAddition = "" - var sdcardAddition = "" - - val sdcardStorageAvailable = activity.sdcardStorage != null + private fun buildItems( + internalStorageDownloads: Map>, + sdcardStorageDownloads: Map>? + ) { + var internalAddition = "" + var sdcardAddition = "" - if (sdcardStorageAvailable) { - if (activity.preferredStorage.file == activity.sdcardStorage?.file) { - sdcardAddition = " " + getString(R.string.settings_storage_addition) - } else { - internalAddition = " " + getString(R.string.settings_storage_addition) - } - } - - fun buildTotalItem(appFolder: String, title: String): DownloadsAdapter.FolderItem { - // clean up the storage before fetching items - Storage(File(appFolder)).clean() - - return DownloadsAdapter.FolderItem( - title, - appFolder - ) - } - - var list: MutableList = ArrayList() + if (activity?.sdcardStorage != null) { + if (activity?.preferredStorage == activity?.sdcardStorage) { + sdcardAddition = " " + getString(R.string.settings_storage_addition) + } else { + internalAddition = " " + getString(R.string.settings_storage_addition) + } + } - val storage = activity.internalStorage - storage.file.createIfNotExists() - list.add(buildTotalItem( - storage.file.absolutePath, + adapter?.addItem( + getString(R.string.overall), + mutableListOf( + buildSummary( + internalStorageDownloads, getString(R.string.settings_title_storage_internal) + internalAddition - )) - - activity.sdcardStorage?.let { sdcardStorage -> - sdcardStorage.file.createIfNotExists() - list.add(buildTotalItem( - sdcardStorage.file.absolutePath, - getString(R.string.settings_title_storage_external) + sdcardAddition - )) - } - - adapter?.addItem(getString(R.string.overall), list) - - // documents - - if (Feature.enabled("documents")) { - list = ArrayList() - - list.add(buildTotalItem( - activity.internalStorage.file.absolutePath + File.separator + "Documents", - getString(R.string.settings_title_storage_internal) + internalAddition - )) - - activity.sdcardStorage?.let { sdcardStorage -> - list.add(buildTotalItem( - sdcardStorage.file.absolutePath + File.separator + "Documents", + ) + ).apply { + if (sdcardStorageDownloads != null) { + add( + buildSummary( + sdcardStorageDownloads, getString(R.string.settings_title_storage_external) + sdcardAddition - )) + ) + ) + } + } + ) + + if (Feature.enabled("documents")) { + adapter?.addItem( + getString(R.string.tab_documents), + buildCategories( + internalStorageDownloads, + { it is DownloadCategory.Documents }, + { getString(R.string.settings_title_storage_internal) + internalAddition } + ).toMutableList() + .apply { + if (sdcardStorageDownloads != null) { + addAll( + buildCategories( + sdcardStorageDownloads, + { it is DownloadCategory.Documents }, + { + getString(R.string.settings_title_storage_external) + + sdcardAddition + } + ) + ) + } } + ) + } - adapter?.addItem(getString(R.string.tab_documents), list) + adapter?.addItem( + getString(R.string.tab_certificates), + buildCategories( + internalStorageDownloads, + { it is DownloadCategory.Certificates }, + { getString(R.string.settings_title_storage_internal) + internalAddition } + ).toMutableList() + .apply { + if (sdcardStorageDownloads != null) { + addAll( + buildCategories( + sdcardStorageDownloads, + { it is DownloadCategory.Certificates }, + { + getString(R.string.settings_title_storage_external) + + sdcardAddition + } + ) + ) + } } + ) + + adapter?.addItem( + getString(R.string.courses) + if (sdcardStorageDownloads != null) { + " (" + getString(R.string.settings_title_storage_internal) + ")" + } else "", + buildCategories( + internalStorageDownloads, + { it is DownloadCategory.Course }, + { CourseDao.Unmanaged.find((it as DownloadCategory.Course).id)?.title ?: "" } + ) + ) + + if (sdcardStorageDownloads != null) { + adapter?.addItem( + getString(R.string.courses) + + " (" + getString(R.string.settings_title_storage_external) + ")", + buildCategories( + sdcardStorageDownloads, + { it is DownloadCategory.Course }, + { CourseDao.Unmanaged.find((it as DownloadCategory.Course).id)?.title ?: "" } + ) + ) + } - // certificates + showContent() + } - list = ArrayList() + private fun buildSummary( + downloads: Map>, + title: String + ): DownloadsAdapter.DownloadCategory { + return DownloadsAdapter.DownloadCategory( + title, + downloads.values.sumOf { + it.first.takeIf { it.state == DownloadStatus.State.DOWNLOADED }?.totalBytes ?: 0L + }, + -1 // hide number of files because of ExoPlayer Cache + ) { deleteDownloads(downloads.keys) } + } - list.add(buildTotalItem( - activity.internalStorage.file.absolutePath + File.separator + "Certificates", - getString(R.string.settings_title_storage_internal) + internalAddition - )) + private fun buildCategories( + downloads: Map>, + categoryFilter: (DownloadCategory) -> Boolean, + titleSelector: (DownloadCategory) -> String + ): List { + val downloadCategories: MutableList = + mutableListOf() + downloads + .entries + .filter { + it.value.first.state == DownloadStatus.State.DOWNLOADED && + it.value.second.takeIf { categoryFilter(it) } != null + } + .groupBy { it.value.second } + .mapValues { it.value.map { Pair(it.key, it.value.first) } } + .forEach { + downloadCategories.add( + DownloadsAdapter.DownloadCategory( + titleSelector(it.key), + it.value.sumOf { it.second.totalBytes ?: 0L }, + it.value.count() + ) { deleteDownloads(it.value.map { it.first }) } + ) + } + return downloadCategories + } - activity.sdcardStorage?.let { sdcardStorage -> - list.add(buildTotalItem( - sdcardStorage.file.absolutePath + File.separator + "Certificates", - getString(R.string.settings_title_storage_external) + sdcardAddition - )) + private fun deleteDownloads(downloads: Collection) { + fun executeDelete() { + showAnyProgress() + Downloaders.deleteDownloads(downloads) { + if (!it) { + showToast(R.string.error_plain) } + fetchItems() + hideAnyProgress() + } + } - adapter?.addItem(getString(R.string.tab_certificates), list) - - // course folders - - fun buildCourseItems(storage: File): List { - val folders = File(storage.absolutePath + File.separator + "Courses") - .foldersWithFiles - val folderList: MutableList = ArrayList() - if (folders.isNotEmpty()) { - for (folder in folders) { - val name = try { - folder.substring( - folder.lastIndexOf(File.separator) + 1, - folder.lastIndexOf("_") - ) - } catch (e: Exception) { - folder - } + val appPreferences = ApplicationPreferences() + if (appPreferences.confirmBeforeDeleting) { + val dialog = + ConfirmDeleteDialogAutoBundle.builder(true).build() + dialog.listener = + object : ConfirmDeleteDialog.Listener { + override fun onDialogPositiveClick( + dialog: DialogFragment + ) { + executeDelete() + } - val item = DownloadsAdapter.FolderItem(name, folder) - folderList.add(item) - } + override fun onDialogPositiveAndAlwaysClick( + dialog: DialogFragment + ) { + appPreferences.confirmBeforeDeleting = false + executeDelete() } - return folderList } + dialog.show( + requireActivity().supportFragmentManager, + ConfirmDeleteDialog.TAG + ) + } else { + executeDelete() + } + } - val internalCourseTitle = if (sdcardStorageAvailable) { - getString(R.string.courses) + " (" + getString(R.string.settings_title_storage_internal) + ")" - } else { - getString(R.string.courses) - } - adapter?.addItem( - internalCourseTitle, - buildCourseItems(activity.internalStorage.file) - ) + private fun fetchItems() { + activity?.let { activity -> + adapter?.clear() + if ( + permissionManager?.requestPermission(PermissionManager.WRITE_EXTERNAL_STORAGE) == 1 + ) { + Downloaders.getDownloads(activity.internalStorage) { + val internalStorageDownloads = it - activity.sdcardStorage?.let { sdcardStorage -> - val sdcardCourseTitle = if (sdcardStorageAvailable) { - getString(R.string.courses) + " (" + getString(R.string.settings_title_storage_external) + ")" - } else { - getString(R.string.courses) - } - adapter?.addItem( - sdcardCourseTitle, - buildCourseItems(sdcardStorage.file) - ) + activity.sdcardStorage?.let { sdcardStorage -> + Downloaders.getDownloads(sdcardStorage) { sdcardStorageDownloads -> + buildItems(internalStorageDownloads, sdcardStorageDownloads) + } + } ?: buildItems(internalStorageDownloads, null) } } else { networkStateHelper.setMessageTitle(R.string.dialog_title_permissions) @@ -226,40 +300,4 @@ class DownloadsFragment : Fragment(), SwipeRefreshLayout.OnRefreshListener, Down } } } - - override fun onDeleteButtonClicked(item: DownloadsAdapter.FolderItem) { - activity?.let { activity -> - val appPreferences = ApplicationPreferences() - - if (appPreferences.confirmBeforeDeleting) { - val dialog = ConfirmDeleteDialogAutoBundle.builder(true).build() - dialog.listener = object : ConfirmDeleteDialog.Listener { - override fun onDialogPositiveClick(dialog: DialogFragment) { - deleteFolder(item) - } - - override fun onDialogPositiveAndAlwaysClick(dialog: DialogFragment) { - appPreferences.confirmBeforeDeleting = false - deleteFolder(item) - } - } - dialog.show(activity.supportFragmentManager, ConfirmDeleteDialog.TAG) - } else { - deleteFolder(item) - } - } - } - - private fun deleteFolder(item: DownloadsAdapter.FolderItem) { - val dir = File(item.path) - - if (dir.exists()) { - dir.deleteAll() - } else { - showToast(R.string.error) - } - - fetchItems() - } - } diff --git a/app/src/main/java/de/xikolo/controllers/helper/DownloadViewHelper.kt b/app/src/main/java/de/xikolo/controllers/helper/DownloadViewHelper.kt index 7c211e2ef..588f9dc26 100644 --- a/app/src/main/java/de/xikolo/controllers/helper/DownloadViewHelper.kt +++ b/app/src/main/java/de/xikolo/controllers/helper/DownloadViewHelper.kt @@ -6,7 +6,6 @@ import android.view.View import android.widget.Button import android.widget.ProgressBar import android.widget.TextView -import androidx.annotation.StringRes import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentActivity import butterknife.BindView @@ -15,31 +14,33 @@ import de.xikolo.R import de.xikolo.controllers.dialogs.ConfirmDeleteDialog import de.xikolo.controllers.dialogs.ConfirmDeleteDialogAutoBundle import de.xikolo.controllers.dialogs.MobileDownloadDialog -import de.xikolo.download.DownloadIdentifier import de.xikolo.download.DownloadItem +import de.xikolo.download.DownloadStatus import de.xikolo.storages.ApplicationPreferences import de.xikolo.utils.extensions.ConnectivityType import de.xikolo.utils.extensions.asFormattedFileSize import de.xikolo.utils.extensions.connectivityType -import de.xikolo.utils.extensions.fileSize import de.xikolo.utils.extensions.isOnline import de.xikolo.utils.extensions.showToast -import java.io.File /** * When the url of the DownloadAsset's URL is null, the urlNotAvailableMessage is shown and the UI will be disabled. */ class DownloadViewHelper( private val activity: FragmentActivity, - private val download: DownloadItem<*, DownloadIdentifier>, + private val download: DownloadItem<*, *>, title: CharSequence? = null, description: CharSequence? = null, - urlNotAvailableMessage: CharSequence? = null + urlNotAvailableMessage: CharSequence? = null, + openText: CharSequence? = null, + openClick: (() -> Unit)? = null, + downloadClick: (() -> Unit)? = null, + private val onDeleted: (() -> Unit)? = null ) { companion object { val TAG: String = DownloadViewHelper::class.java.simpleName - private const val MILLISECONDS = 250L + private const val MILLISECONDS = 1000L } val view: View @@ -88,6 +89,7 @@ class DownloadViewHelper( val appPreferences = ApplicationPreferences() buttonDownloadStart.setOnClickListener { + downloadClick?.invoke() if (activity.isOnline) { if (activity.connectivityType == ConnectivityType.CELLULAR && appPreferences.isDownloadNetworkLimitedOnMobile) { val dialog = MobileDownloadDialog() @@ -106,8 +108,8 @@ class DownloadViewHelper( } } - buttonDownloadCancel.setOnClickListener { _ -> - download.cancel(activity) + buttonDownloadCancel.setOnClickListener { + download.delete(activity) showStartState() } @@ -116,17 +118,17 @@ class DownloadViewHelper( val dialog = ConfirmDeleteDialogAutoBundle.builder(false).build() dialog.listener = object : ConfirmDeleteDialog.Listener { override fun onDialogPositiveClick(dialog: DialogFragment) { - deleteFile() + deleteDownload() } override fun onDialogPositiveAndAlwaysClick(dialog: DialogFragment) { appPreferences.confirmBeforeDeleting = false - deleteFile() + deleteDownload() } } dialog.show(activity.supportFragmentManager, ConfirmDeleteDialog.TAG) } else { - deleteFile() + deleteDownload() } } @@ -143,7 +145,8 @@ class DownloadViewHelper( textDescription.visibility = View.GONE } - if (!download.isDownloadable) { + if (!download.downloadable) { + showStartState() view.isEnabled = false buttonDownloadStart.isEnabled = false @@ -154,67 +157,50 @@ class DownloadViewHelper( } } - // ToDo will be refactored in the HLS feature to hide the open button if openAction is null - buttonOpenDownload.setOnClickListener { - download.openAction?.invoke(activity) ?: run { - activity.showToast(R.string.error_plain) + if (openText != null) { + buttonOpenDownload.text = openText + } + + when { + openClick != null -> buttonOpenDownload.setOnClickListener { openClick() } + download.openAction != null -> buttonOpenDownload.setOnClickListener { + download.openAction?.invoke(activity) } + else -> buttonOpenDownload.visibility = View.GONE } - progressBarUpdater = object : Runnable { - override fun run() { - Handler(Looper.getMainLooper()).post { - download.getProgress { progress -> + progressBarUpdater = + object : Runnable { + override fun run() { + Handler(Looper.getMainLooper()).post { + onStatusChanged(download.status.value) + if (progressBarUpdaterRunning) { - val downloadedBytes = progress.first ?: 0L - val totalBytes = progress.second ?: 0L - - progressBarDownload.isIndeterminate = false - if (totalBytes == 0L) { - progressBarDownload.progress = 0 - } else { - progressBarDownload.progress = - (downloadedBytes * 100 / totalBytes).toInt() - } - textFileSize.text = activity.getString( - R.string.download_slash, - downloadedBytes.asFormattedFileSize, - totalBytes.asFormattedFileSize - ) + progressBarDownload.postDelayed(this, MILLISECONDS) } } - - if (progressBarUpdaterRunning) { - progressBarDownload.postDelayed(this, MILLISECONDS) - } } } - } - view.visibility = View.INVISIBLE - download.isDownloadRunning { - when { - it -> showRunningState() - download.downloadExists -> showEndState() - else -> showStartState() + if (download.downloadable) { + download.status.observe(activity) { + onStatusChanged(it) } - view.visibility = View.VISIBLE } - - registerDownloadStateListener() } - private fun deleteFile() { + private fun deleteDownload() { download.delete(activity) { if (it) { showStartState() + onDeleted?.invoke() } } } private fun startDownload() { download.start(activity) { - if (it != null) { + if (it) { showRunningState() } } @@ -226,26 +212,38 @@ class DownloadViewHelper( viewDownloadEnd.visibility = View.INVISIBLE progressBarDownload.progress = 0 - progressBarDownload.isIndeterminate = true progressBarUpdaterRunning = false - if (download.downloadSize != 0L) { + if (download.size != 0L) { textFileSize.visibility = View.VISIBLE - textFileSize.text = download.downloadSize.asFormattedFileSize + textFileSize.text = download.size.asFormattedFileSize } else { textFileSize.visibility = View.GONE } } + private fun showPendingState() { + viewDownloadStart.visibility = View.INVISIBLE + viewDownloadRunning.visibility = View.VISIBLE + viewDownloadEnd.visibility = View.INVISIBLE + + buttonDownloadCancel.visibility = View.INVISIBLE + progressBarDownload.isIndeterminate = true + } + private fun showRunningState() { viewDownloadStart.visibility = View.INVISIBLE viewDownloadRunning.visibility = View.VISIBLE viewDownloadEnd.visibility = View.INVISIBLE + buttonDownloadCancel.visibility = View.VISIBLE + progressBarDownload.isIndeterminate = false textFileSize.visibility = View.VISIBLE - progressBarUpdaterRunning = true - Thread(progressBarUpdater).start() + if (!progressBarUpdaterRunning) { + progressBarUpdaterRunning = true + Thread(progressBarUpdater).start() + } } private fun showEndState() { @@ -255,39 +253,45 @@ class DownloadViewHelper( textFileSize.visibility = View.VISIBLE - textFileSize.text = if (download.downloadSize != 0L) { - download.downloadSize.asFormattedFileSize + if (download.size != 0L) { + textFileSize.visibility = View.VISIBLE + textFileSize.text = download.size.asFormattedFileSize } else { - (download.download as? File?).fileSize.asFormattedFileSize + textFileSize.visibility = View.GONE } progressBarUpdaterRunning = false } - private fun registerDownloadStateListener() { - download.stateListener = object : DownloadItem.StateListener { - override fun onStarted() { - if (!progressBarUpdaterRunning) { - showRunningState() - } - } + private fun onStatusChanged(status: DownloadStatus) { + when (status.state) { + DownloadStatus.State.PENDING -> showPendingState() + DownloadStatus.State.RUNNING -> { + showRunningState() + + val downloadedBytes = status.downloadedBytes ?: 0L + val totalBytes = status.totalBytes ?: 0L - override fun onCompleted() { - if (download.downloadExists) { - showEndState() + if (totalBytes == 0L) { + progressBarDownload.progress = 0 + textFileSize.text = "" + } else { + progressBarDownload.progress = + (downloadedBytes * 100 / totalBytes).toInt() + textFileSize.text = activity.getString( + R.string.download_slash, + downloadedBytes.asFormattedFileSize, + totalBytes.asFormattedFileSize + ) } } - - override fun onDeleted() { - if (progressBarUpdaterRunning) { - showStartState() + DownloadStatus.State.DOWNLOADED -> showEndState() + DownloadStatus.State.DELETED -> { + showStartState() + if (status.error != null) { + activity.showToast(R.string.error) } } } } - - fun onOpenFileClick(@StringRes buttonText: Int, onClick: () -> Unit) { - buttonOpenDownload.text = activity.getString(buttonText) - buttonOpenDownload.setOnClickListener { onClick.invoke() } - } } diff --git a/app/src/main/java/de/xikolo/controllers/helper/SectionDownloadHelper.kt b/app/src/main/java/de/xikolo/controllers/helper/SectionDownloadHelper.kt index 5203c448d..504009b63 100644 --- a/app/src/main/java/de/xikolo/controllers/helper/SectionDownloadHelper.kt +++ b/app/src/main/java/de/xikolo/controllers/helper/SectionDownloadHelper.kt @@ -9,6 +9,8 @@ import de.xikolo.controllers.dialogs.ModuleDownloadDialog import de.xikolo.controllers.dialogs.ModuleDownloadDialogAutoBundle import de.xikolo.controllers.dialogs.ProgressDialogIndeterminate import de.xikolo.controllers.dialogs.ProgressDialogIndeterminateAutoBundle +import de.xikolo.download.DownloadItem +import de.xikolo.download.DownloadStatus import de.xikolo.models.Course import de.xikolo.models.DownloadAsset import de.xikolo.models.Item @@ -32,23 +34,27 @@ class SectionDownloadHelper(private val activity: FragmentActivity) { listDialog.listener = object : ModuleDownloadDialog.ItemSelectionListener { - override fun onSelected(dialog: DialogFragment, hdVideo: Boolean, sdVideo: Boolean, slides: Boolean) { + override fun onSelected(dialog: DialogFragment, video: Boolean, slides: Boolean) { val appPreferences = ApplicationPreferences() - if (hdVideo || sdVideo || slides) { + if (video || slides) { if (activity.isOnline) { if (activity.connectivityType === ConnectivityType.CELLULAR && appPreferences.isDownloadNetworkLimitedOnMobile) { val permissionDialog = MobileDownloadDialog() - permissionDialog.listener = object : MobileDownloadDialog.MobileDownloadGrantedListener { + permissionDialog.listener = + object : MobileDownloadDialog.MobileDownloadGrantedListener { - override fun onGranted(dialog: DialogFragment) { - appPreferences.isDownloadNetworkLimitedOnMobile = false - startSectionDownloads(course, section, hdVideo, sdVideo, slides) + override fun onGranted(dialog: DialogFragment) { + appPreferences.isDownloadNetworkLimitedOnMobile = false + startSectionDownloads(course, section, video, slides) + } } - } - permissionDialog.show(activity.supportFragmentManager, MobileDownloadDialog.TAG) + permissionDialog.show( + activity.supportFragmentManager, + MobileDownloadDialog.TAG + ) } else { - startSectionDownloads(course, section, hdVideo, sdVideo, slides) + startSectionDownloads(course, section, video, slides) } } else { activity.showToast(R.string.toast_no_network) @@ -60,8 +66,13 @@ class SectionDownloadHelper(private val activity: FragmentActivity) { listDialog.show(activity.supportFragmentManager, ModuleDownloadDialog.TAG) } - private fun startSectionDownloads(course: Course, section: Section, hdVideo: Boolean, sdVideo: Boolean, slides: Boolean) { - LanalyticsUtil.trackDownloadedSection(section.id, course.id, hdVideo, sdVideo, slides) + private fun startSectionDownloads( + course: Course, + section: Section, + video: Boolean, + slides: Boolean + ) { + LanalyticsUtil.trackDownloadedSection(section.id, course.id, video, false, slides) val dialog = ProgressDialogIndeterminateAutoBundle.builder().build() dialog.show(activity.supportFragmentManager, ProgressDialogIndeterminate.TAG) @@ -77,14 +88,23 @@ class SectionDownloadHelper(private val activity: FragmentActivity) { dialog.dismissAllowingStateLoss() for (item in section.accessibleItems) { if (item.contentType == Item.TYPE_VIDEO) { - if (sdVideo) { - startDownload(DownloadAsset.Course.Item.VideoSD(item, VideoDao.Unmanaged.find(item.contentId)!!)) - } - if (hdVideo) { - startDownload(DownloadAsset.Course.Item.VideoHD(item, VideoDao.Unmanaged.find(item.contentId)!!)) + if (video) { + startDownload( + DownloadAsset.Course.Item.VideoHLS( + item, + VideoDao.Unmanaged.find(item.contentId)!!, + ApplicationPreferences().videoDownloadQuality + ) + ) } + if (slides) { - startDownload(DownloadAsset.Course.Item.Slides(item, VideoDao.Unmanaged.find(item.contentId)!!)) + startDownload( + DownloadAsset.Course.Item.Slides( + item, + VideoDao.Unmanaged.find(item.contentId)!! + ) + ) } } } @@ -98,12 +118,9 @@ class SectionDownloadHelper(private val activity: FragmentActivity) { ListItemsWithContentForSectionJob(section.id, itemRequestNetworkState, false).run() } - private fun startDownload(item: DownloadAsset.Course.Item) { - item.isDownloadRunning { - if (!item.downloadExists && !it) { - item.start(activity) - } + private fun startDownload(item: DownloadItem<*, *>) { + if (item.status.state == DownloadStatus.State.DELETED) { + item.start(activity) } } - } diff --git a/app/src/main/java/de/xikolo/controllers/helper/VideoSettingsHelper.kt b/app/src/main/java/de/xikolo/controllers/helper/VideoSettingsHelper.kt index 96ded00e5..fc66846da 100644 --- a/app/src/main/java/de/xikolo/controllers/helper/VideoSettingsHelper.kt +++ b/app/src/main/java/de/xikolo/controllers/helper/VideoSettingsHelper.kt @@ -20,10 +20,67 @@ import de.xikolo.storages.ApplicationPreferences import de.xikolo.utils.LanguageUtil import de.xikolo.views.CustomFontTextView -class VideoSettingsHelper(private val context: Context, private val subtitles: List?, private val changeListener: OnSettingsChangeListener, private val clickListener: OnSettingsClickListener, private val videoInfoCallback: VideoInfoCallback) { +class VideoSettingsHelper( + private val context: Context, + private val subtitles: List, + private val changeListener: OnSettingsChangeListener, + private val clickListener: OnSettingsClickListener, + private val videoInfoCallback: VideoInfoCallback +) { + + // targetBitrate = lowestBitrate + (highestBitrate - lowestBitrate) * bitrateScale / 100 + /** + * Video quality classes based on adaptive bitrate selection by specifying it as a fraction of + * the bitrate range. + * + * targetBitrate = lowestBitrate + (highestBitrate - lowestBitrate) * qualityFraction + */ + enum class VideoQuality(val qualityFraction: Float) { + LOW(0f), + MEDIUM(0.33f), + HIGH(0.67f), + BEST(1f); + + fun toString(context: Context): String { + return when (this) { + LOW -> context.getString(R.string.settings_video_download_quality_low_value) + MEDIUM -> context.getString(R.string.settings_video_download_quality_medium_value) + HIGH -> context.getString(R.string.settings_video_download_quality_high_value) + BEST -> context.getString(R.string.settings_video_download_quality_best_value) + } + } - enum class VideoMode(val title: String) { - SD("SD"), HD("HD"), AUTO("Auto") + companion object { + fun get(context: Context, str: String?): VideoQuality { + return when (str) { + context.getString(R.string.settings_video_download_quality_low_value) -> LOW + context.getString(R.string.settings_video_download_quality_medium_value) -> + MEDIUM + context.getString(R.string.settings_video_download_quality_high_value) -> HIGH + context.getString(R.string.settings_video_download_quality_best_value) -> BEST + else -> get( + context, + context.getString(R.string.settings_default_value_video_download_quality) + ) + } + } + } + } + + enum class PlaybackMode { + AUTO, BEST, HIGH, MEDIUM, LOW, LEGACY_HD, LEGACY_SD; + + fun getTitle(context: Context): String { + return when (this) { + AUTO -> context.getString(R.string.exo_track_selection_auto) + LOW -> context.getString(R.string.settings_video_download_quality_low) + MEDIUM -> context.getString(R.string.settings_video_download_quality_medium) + HIGH -> context.getString(R.string.settings_video_download_quality_high) + BEST -> context.getString(R.string.settings_video_download_quality_best) + LEGACY_HD -> context.getString(R.string.settings_video_download_quality_high) + LEGACY_SD -> context.getString(R.string.settings_video_download_quality_low) + } + } } enum class PlaybackSpeed(val value: Float) { @@ -42,7 +99,7 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L "x1.5" -> X15 "x1.8" -> X18 "x2.0" -> X20 - else -> X10 + else -> X10 } } } @@ -52,7 +109,7 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L private val applicationPreferences: ApplicationPreferences = ApplicationPreferences() - var currentQuality: VideoMode = VideoMode.HD + var currentMode: PlaybackMode = PlaybackMode.AUTO var currentSpeed: PlaybackSpeed = PlaybackSpeed.X10 var currentVideoSubtitles: VideoSubtitles? = null var isImmersiveModeEnabled: Boolean = false @@ -60,18 +117,20 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L init { currentSpeed = applicationPreferences.videoPlaybackSpeed - currentQuality = when { - videoInfoCallback.isAvailable(VideoMode.HD) -> VideoMode.HD - videoInfoCallback.isAvailable(VideoMode.SD) -> VideoMode.SD - videoInfoCallback.isAvailable(VideoMode.AUTO) -> VideoMode.AUTO - else -> throw IllegalArgumentException("No video available") + currentMode = when { + videoInfoCallback.isAvailable(PlaybackMode.AUTO) -> PlaybackMode.AUTO + videoInfoCallback.isAvailable(PlaybackMode.BEST) -> PlaybackMode.BEST + videoInfoCallback.isAvailable(PlaybackMode.HIGH) -> PlaybackMode.HIGH + videoInfoCallback.isAvailable(PlaybackMode.MEDIUM) -> PlaybackMode.MEDIUM + videoInfoCallback.isAvailable(PlaybackMode.LOW) -> PlaybackMode.LOW + videoInfoCallback.isAvailable(PlaybackMode.LEGACY_HD) -> PlaybackMode.LEGACY_HD + videoInfoCallback.isAvailable(PlaybackMode.LEGACY_SD) -> PlaybackMode.LEGACY_SD + else -> throw IllegalArgumentException("No video available") } - subtitles?.let { - for (videoSubtitles in it) { - if (videoSubtitles.language == applicationPreferences.videoSubtitlesLanguage) { - currentVideoSubtitles = videoSubtitles - } + for (videoSubtitles in subtitles) { + if (videoSubtitles.language == applicationPreferences.videoSubtitlesLanguage) { + currentVideoSubtitles = videoSubtitles } } @@ -84,9 +143,13 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L list.addView( buildSettingsItem( R.string.icon_quality, - context.getString(R.string.video_settings_quality) + " " + context.getString(R.string.video_settings_separator) + " " + currentQuality.title + - if (videoInfoCallback.isOfflineAvailable(currentQuality)) " " + context.getString(R.string.video_settings_quality_offline) else "", - View.OnClickListener { clickListener.onQualityClick() }, + context.getString(R.string.video_settings_quality) + " " + + context.getString(R.string.video_settings_separator) + " " + + currentMode.getTitle(context) + + if (videoInfoCallback.isOfflineAvailable(currentMode)) { + " " + context.getString(R.string.video_settings_quality_offline) + } else "", + { clickListener.onQualityClick() }, false, Config.FONT_MATERIAL ) @@ -95,12 +158,12 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L buildSettingsItem( R.string.icon_speed, context.getString(R.string.video_settings_speed) + " " + context.getString(R.string.video_settings_separator) + " " + currentSpeed.toString(), - View.OnClickListener { clickListener.onPlaybackSpeedClick() }, + { clickListener.onPlaybackSpeedClick() }, false, Config.FONT_MATERIAL ) ) - if (subtitles?.isNotEmpty() == true) { + if (subtitles.isNotEmpty()) { list.addView( buildSettingsItem( R.string.icon_subtitles, @@ -110,7 +173,7 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L " " + LanguageUtil.toNativeName(it.language) } ?: "" ), - View.OnClickListener { clickListener.onSubtitleClick() }, + { clickListener.onSubtitleClick() }, false, Config.FONT_MATERIAL ) @@ -126,7 +189,7 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L buildSettingsItem( R.string.icon_pip, context.getString(R.string.video_settings_pip), - View.OnClickListener { clickListener.onPipClick() }, + { clickListener.onPipClick() }, false, Config.FONT_MATERIAL ) @@ -139,50 +202,24 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L fun buildQualityView(): ViewGroup { val list = buildSettingsPanel(context.getString(R.string.video_settings_quality)) - if (videoInfoCallback.isAvailable(VideoMode.AUTO)) { - list.addView( - buildSettingsItem( - null, - VideoMode.AUTO.title + - if (videoInfoCallback.isOfflineAvailable(VideoMode.AUTO)) " " + context.getString(R.string.video_settings_quality_offline) else "", - View.OnClickListener { - val oldQuality = currentQuality - currentQuality = VideoMode.AUTO - changeListener.onQualityChanged(oldQuality, currentQuality) - }, - currentQuality == VideoMode.AUTO - ) - ) - } - if (videoInfoCallback.isAvailable(VideoMode.HD)) { - list.addView( - buildSettingsItem( - null, - VideoMode.HD.title + - if (videoInfoCallback.isOfflineAvailable(VideoMode.HD)) " " + context.getString(R.string.video_settings_quality_offline) else "", - View.OnClickListener { - val oldQuality = currentQuality - currentQuality = VideoMode.HD - changeListener.onQualityChanged(oldQuality, currentQuality) - }, - currentQuality == VideoMode.HD + PlaybackMode.values().forEach { playbackMode -> + if (videoInfoCallback.isAvailable(playbackMode)) { + list.addView( + buildSettingsItem( + null, + playbackMode.getTitle(context) + + if (videoInfoCallback.isOfflineAvailable(playbackMode)) + " " + context.getString(R.string.video_settings_quality_offline) + else "", + { + val oldQuality = currentMode + currentMode = playbackMode + changeListener.onPlaybackModeChanged(oldQuality, currentMode) + }, + currentMode == playbackMode + ) ) - ) - } - if (videoInfoCallback.isAvailable(VideoMode.SD)) { - list.addView( - buildSettingsItem( - null, - VideoMode.SD.title + - if (videoInfoCallback.isOfflineAvailable(VideoMode.SD)) " " + context.getString(R.string.video_settings_quality_offline) else "", - View.OnClickListener { - val oldQuality = currentQuality - currentQuality = VideoMode.SD - changeListener.onQualityChanged(oldQuality, currentQuality) - }, - currentQuality == VideoMode.SD - ) - ) + } } return list.parent as ViewGroup @@ -196,7 +233,7 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L buildSettingsItem( null, speed.toString(), - View.OnClickListener { + { val oldSpeed = currentSpeed currentSpeed = speed changeListener.onPlaybackSpeedChanged(oldSpeed, currentSpeed) @@ -212,17 +249,20 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L fun buildSubtitleView(): ViewGroup { val list = buildSettingsPanel( context.getString(R.string.video_settings_subtitles), - context.getString(R.string.icon_settings), - View.OnClickListener { - ContextCompat.startActivity(context, Intent(Settings.ACTION_CAPTIONING_SETTINGS), null) - } - ) + context.getString(R.string.icon_settings) + ) { + ContextCompat.startActivity( + context, + Intent(Settings.ACTION_CAPTIONING_SETTINGS), + null + ) + } list.addView( buildSettingsItem( null, context.getString(R.string.video_settings_subtitles_none), - View.OnClickListener { + { val oldVideoSubtitles = currentVideoSubtitles currentVideoSubtitles = null applicationPreferences.videoSubtitlesLanguage = null @@ -231,13 +271,13 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L currentVideoSubtitles == null ) ) - for (videoSubtitles in subtitles!!) { + for (videoSubtitles in subtitles) { list.addView( buildSettingsItem( null, LanguageUtil.toNativeName(videoSubtitles.language) + if (videoSubtitles.createdByMachine) " " + context.getString(R.string.video_settings_subtitles_generated) else "", - View.OnClickListener { + { val oldVideoSubtitles = currentVideoSubtitles currentVideoSubtitles = videoSubtitles applicationPreferences.videoSubtitlesLanguage = videoSubtitles.language @@ -267,7 +307,11 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L return list } - private fun buildSettingsPanel(title: String?, icon: String, iconClickListener: View.OnClickListener): ViewGroup { + private fun buildSettingsPanel( + title: String?, + icon: String, + iconClickListener: View.OnClickListener + ): ViewGroup { val panel = buildSettingsPanel(title) val iconView = panel.findViewById(R.id.content_settings_icon) as TextView iconView.text = icon @@ -277,7 +321,13 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L } @SuppressLint("InflateParams") - private fun buildSettingsItem(@StringRes icon: Int?, title: String, clickListener: View.OnClickListener, active: Boolean, font: String = Config.FONT_XIKOLO): ViewGroup { + private fun buildSettingsItem( + @StringRes icon: Int?, + title: String, + clickListener: View.OnClickListener, + active: Boolean, + font: String = Config.FONT_XIKOLO + ): ViewGroup { val item = inflater.inflate(R.layout.item_video_settings, null) as LinearLayout val iconView = item.findViewById(R.id.item_settings_icon) as CustomFontTextView @@ -306,12 +356,19 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L private fun buildImmersiveSettingsItem(parent: ViewGroup): View { return buildSettingsItem( if (isImmersiveModeEnabled) R.string.icon_show_fitting else R.string.icon_show_immersive, - if (isImmersiveModeEnabled) context.getString(R.string.video_settings_show_fitting) else context.getString(R.string.video_settings_show_immersive), - View.OnClickListener { + if (isImmersiveModeEnabled) { + context.getString(R.string.video_settings_show_fitting) + } else { + context.getString(R.string.video_settings_show_immersive) + }, + { isImmersiveModeEnabled = !isImmersiveModeEnabled applicationPreferences.isVideoShownImmersive = isImmersiveModeEnabled - changeListener.onImmersiveModeChanged(!isImmersiveModeEnabled, isImmersiveModeEnabled) + changeListener.onImmersiveModeChanged( + !isImmersiveModeEnabled, + isImmersiveModeEnabled + ) val index = parent.indexOfChild(it) parent.removeViewAt(index) @@ -337,7 +394,7 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L // also invoked when old value equal to new value interface OnSettingsChangeListener { - fun onQualityChanged(old: VideoMode, new: VideoMode) + fun onPlaybackModeChanged(old: PlaybackMode, new: PlaybackMode) fun onPlaybackSpeedChanged(old: PlaybackSpeed, new: PlaybackSpeed) @@ -349,9 +406,9 @@ class VideoSettingsHelper(private val context: Context, private val subtitles: L interface VideoInfoCallback { - fun isAvailable(videoMode: VideoMode): Boolean + fun isAvailable(mode: PlaybackMode): Boolean - fun isOfflineAvailable(videoMode: VideoMode): Boolean + fun isOfflineAvailable(mode: PlaybackMode): Boolean fun isImmersiveModeAvailable(): Boolean } diff --git a/app/src/main/java/de/xikolo/controllers/main/SplashActivity.kt b/app/src/main/java/de/xikolo/controllers/main/SplashActivity.kt index 854916439..842cf04ff 100644 --- a/app/src/main/java/de/xikolo/controllers/main/SplashActivity.kt +++ b/app/src/main/java/de/xikolo/controllers/main/SplashActivity.kt @@ -6,20 +6,12 @@ import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle -import de.xikolo.R import de.xikolo.controllers.dialogs.* import de.xikolo.extensions.observe -import de.xikolo.models.Storage import de.xikolo.network.jobs.CheckHealthJob import de.xikolo.network.jobs.base.HealthCheckNetworkState import de.xikolo.network.jobs.base.HealthCheckNetworkStateLiveData import de.xikolo.network.jobs.base.NetworkCode -import de.xikolo.storages.ApplicationPreferences -import de.xikolo.utils.extensions.createIfNotExists -import de.xikolo.utils.extensions.fileCount -import de.xikolo.utils.extensions.internalStorage -import de.xikolo.utils.extensions.publicAppStorageFolder -import java.io.File import java.util.* import java.util.concurrent.TimeUnit @@ -68,48 +60,13 @@ class SplashActivity : AppCompatActivity() { } } - migrateStorage() + runHealthJob() } private fun runHealthJob() { CheckHealthJob(networkState, false).run() } - private fun migrateStorage() { - if (!ApplicationPreferences().contains(getString(R.string.preference_storage))) { - val old = Storage(publicAppStorageFolder) - val new = Storage(File(internalStorage.file.absolutePath + File.separator + "Courses")) - new.file.createIfNotExists() - - val fileCount = old.file.fileCount - - val progressDialog = ProgressDialogHorizontalAutoBundle.builder() - .title(getString(R.string.app_name)) - .message(getString(R.string.dialog_app_being_prepared)) - .build() - progressDialog.max = 100 - progressDialog.show(supportFragmentManager, ProgressDialogHorizontal.TAG) - - old.migrateTo(new, object : Storage.MigrationCallback { - override fun onProgressChanged(count: Int) { - runOnUiThread { progressDialog.progress = Math.ceil(100.0 * count / fileCount).toInt() } - } - - override fun onCompleted(success: Boolean) { - runOnUiThread { - old.clean() - new.clean() - progressDialog.dismiss() - ApplicationPreferences().setToDefault(getString(R.string.preference_storage), getString(R.string.settings_default_value_storage)) - runHealthJob() - } - } - }) - } else { - runHealthJob() - } - } - private fun showApiVersionExpiredDialog() { val dialog = ApiVersionExpiredDialog() dialog.listener = object : ApiVersionExpiredDialog.Listener { diff --git a/app/src/main/java/de/xikolo/controllers/section/VideoPreviewFragment.kt b/app/src/main/java/de/xikolo/controllers/section/VideoPreviewFragment.kt index 8fd99ab14..691fe10ae 100644 --- a/app/src/main/java/de/xikolo/controllers/section/VideoPreviewFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/section/VideoPreviewFragment.kt @@ -1,5 +1,6 @@ package de.xikolo.controllers.section +import android.content.Intent import android.content.res.Configuration import android.graphics.Point import android.os.Bundle @@ -11,17 +12,23 @@ import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity import butterknife.BindView import com.yatatsu.autobundle.AutoBundleField import de.xikolo.R import de.xikolo.config.GlideApp import de.xikolo.controllers.base.ViewModelFragment +import de.xikolo.controllers.dialogs.VideoDownloadQualityHintDialog import de.xikolo.controllers.helper.DownloadViewHelper +import de.xikolo.controllers.helper.VideoSettingsHelper +import de.xikolo.controllers.settings.SettingsActivity import de.xikolo.controllers.video.VideoItemPlayerActivityAutoBundle +import de.xikolo.download.DownloadStatus import de.xikolo.extensions.observe import de.xikolo.models.DownloadAsset import de.xikolo.models.Item import de.xikolo.models.Video +import de.xikolo.storages.ApplicationPreferences import de.xikolo.utils.LanalyticsUtil import de.xikolo.utils.LanguageUtil import de.xikolo.utils.extensions.cast @@ -82,8 +89,6 @@ class VideoPreviewFragment : ViewModelFragment() { @BindView(R.id.videoMetadata) lateinit var videoMetadata: ViewGroup - private var downloadViewHelpers: MutableList = ArrayList() - private var video: Video? = null override val layoutResource = R.layout.fragment_video_preview @@ -150,61 +155,115 @@ class VideoPreviewFragment : ViewModelFragment() { displayAvailableSubtitles() + val minutes = TimeUnit.SECONDS.toMinutes(video.duration.toLong()) + val seconds = video.duration - + TimeUnit.MINUTES.toSeconds(TimeUnit.SECONDS.toMinutes(video.duration.toLong())) + textDuration.text = getString(R.string.duration, minutes, seconds) + + viewPlay.setOnClickListener { play() } + + activity?.let { + updateDownloadViewHelpers(it, item, video) + } + } + + private fun updateDownloadViewHelpers(activity: FragmentActivity, item: Item, video: Video) { linearLayoutDownloads.removeAllViews() - downloadViewHelpers.clear() - activity?.let { activity -> - if (video.streamToPlay?.hdUrl != null) { - val dvh = DownloadViewHelper( - activity, - DownloadAsset.Course.Item.VideoHD(item, video), - activity.getText(R.string.video_hd_as_mp4) - ) - dvh.onOpenFileClick(R.string.play) { play() } - linearLayoutDownloads.addView(dvh.view) - downloadViewHelpers.add(dvh) + if (video.streamToPlay?.hlsUrl != null) { + val videoLow = DownloadAsset.Course.Item.VideoHLS( + item, + video, + VideoSettingsHelper.VideoQuality.LOW + ) to activity.getString(R.string.settings_video_download_quality_low) + val videoMedium = DownloadAsset.Course.Item.VideoHLS( + item, + video, + VideoSettingsHelper.VideoQuality.MEDIUM + ) to activity.getString(R.string.settings_video_download_quality_medium) + val videoHigh = DownloadAsset.Course.Item.VideoHLS( + item, + video, + VideoSettingsHelper.VideoQuality.HIGH + ) to activity.getString(R.string.settings_video_download_quality_high) + val videoBest = DownloadAsset.Course.Item.VideoHLS( + item, + video, + VideoSettingsHelper.VideoQuality.BEST + ) to activity.getString(R.string.settings_video_download_quality_best) + + val videoDefault = when (ApplicationPreferences().videoDownloadQuality) { + VideoSettingsHelper.VideoQuality.LOW -> videoLow + VideoSettingsHelper.VideoQuality.MEDIUM -> videoMedium + VideoSettingsHelper.VideoQuality.HIGH -> videoHigh + VideoSettingsHelper.VideoQuality.BEST -> videoBest } - if (video.streamToPlay?.sdUrl != null) { - val dvh = DownloadViewHelper( - activity, - DownloadAsset.Course.Item.VideoSD(item, video), - activity.getText(R.string.video_sd_as_mp4) - ) - dvh.onOpenFileClick(R.string.play) { play() } - linearLayoutDownloads.addView(dvh.view) - downloadViewHelpers.add(dvh) + fun showDownloadQualityHint() { + val prefs = ApplicationPreferences() + if (!prefs.videoDownloadQualityHintShown) { + val dialog = VideoDownloadQualityHintDialog() + dialog.listener = object : VideoDownloadQualityHintDialog.Listener { + override fun onOpenSettingsClicked() { + startActivity( + Intent(context, SettingsActivity::class.java) + ) + prefs.videoDownloadQualityHintShown = true + } + + override fun onDismissed() { + prefs.videoDownloadQualityHintShown = true + } + } + dialog.show(childFragmentManager, VideoDownloadQualityHintDialog.TAG) + } } - if (video.slidesUrl != null) { - val dvh = DownloadViewHelper( + linearLayoutDownloads.addView( + DownloadViewHelper( activity, - DownloadAsset.Course.Item.Slides(item, video), - activity.getText(R.string.slides_as_pdf) - ) - linearLayoutDownloads.addView(dvh.view) - downloadViewHelpers.add(dvh) - } + videoDefault.first, + getString(R.string.video_with_quality).format(videoDefault.second), + openText = getString(R.string.play), + openClick = { play() }, + downloadClick = { showDownloadQualityHint() } + ).view + ) - if (video.transcriptUrl != null) { - val dvh = DownloadViewHelper( - activity, - DownloadAsset.Course.Item.Transcript(item, video), - activity.getText(R.string.transcript_as_pdf) - ) - linearLayoutDownloads.addView(dvh.view) - downloadViewHelpers.add(dvh) + (setOf(videoLow, videoMedium, videoHigh, videoBest) - videoDefault).forEach { + if (it.first.status.state != DownloadStatus.State.DELETED) { + linearLayoutDownloads.addView( + DownloadViewHelper( + activity, + it.first, + getString(R.string.video_with_quality).format(it.second), + openText = getString(R.string.play), + openClick = { play() }, + downloadClick = { showDownloadQualityHint() }, + onDeleted = { updateDownloadViewHelpers(activity, item, video) } + ).view + ) + } } } - val minutes = TimeUnit.SECONDS.toMinutes(video.duration.toLong()) - val seconds = - video.duration - TimeUnit.MINUTES.toSeconds( - TimeUnit.SECONDS.toMinutes(video.duration.toLong()) + if (video.slidesUrl != null) { + val dvh = DownloadViewHelper( + activity, + DownloadAsset.Course.Item.Slides(item, video), + activity.getText(R.string.slides_as_pdf) ) - textDuration.text = getString(R.string.duration, minutes, seconds) + linearLayoutDownloads.addView(dvh.view) + } - viewPlay.setOnClickListener { play() } + if (video.transcriptUrl != null) { + val dvh = DownloadViewHelper( + activity, + DownloadAsset.Course.Item.Transcript(item, video), + activity.getText(R.string.transcript_as_pdf) + ) + linearLayoutDownloads.addView(dvh.view) + } } private fun play() { diff --git a/app/src/main/java/de/xikolo/controllers/settings/SettingsFragment.kt b/app/src/main/java/de/xikolo/controllers/settings/SettingsFragment.kt index dec60ad2c..db4e5a936 100644 --- a/app/src/main/java/de/xikolo/controllers/settings/SettingsFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/settings/SettingsFragment.kt @@ -1,7 +1,6 @@ package de.xikolo.controllers.settings import android.content.Intent -import android.content.SharedPreferences import android.net.Uri import android.os.Bundle import androidx.browser.customtabs.CustomTabsIntent @@ -10,33 +9,23 @@ import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat -import androidx.preference.PreferenceManager import de.psdev.licensesdialog.LicensesDialog import de.xikolo.App import de.xikolo.BuildConfig import de.xikolo.R import de.xikolo.config.Feature -import de.xikolo.controllers.dialogs.ProgressDialogHorizontal -import de.xikolo.controllers.dialogs.ProgressDialogHorizontalAutoBundle -import de.xikolo.controllers.dialogs.StorageMigrationDialog -import de.xikolo.controllers.dialogs.StorageMigrationDialogAutoBundle import de.xikolo.controllers.login.LoginActivityAutoBundle -import de.xikolo.download.filedownload.FileDownloadHandler +import de.xikolo.download.Downloaders import de.xikolo.extensions.observe import de.xikolo.managers.PermissionManager import de.xikolo.managers.UserManager -import de.xikolo.models.Storage -import de.xikolo.utils.extensions.asStorageType -import de.xikolo.utils.extensions.fileCount import de.xikolo.utils.extensions.getString import de.xikolo.utils.extensions.getStringArray -import de.xikolo.utils.extensions.internalStorage -import de.xikolo.utils.extensions.sdcardStorage import de.xikolo.utils.extensions.showToast import de.xikolo.utils.extensions.storages import java.util.Calendar -class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener { +class SettingsFragment : PreferenceFragmentCompat() { companion object { val TAG: String = SettingsFragment::class.java.simpleName @@ -58,95 +47,17 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP } override fun onResume() { - preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this) refreshPipStatus() super.onResume() } - override fun onPause() { - preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) - super.onPause() - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (key.equals(getString(R.string.preference_storage))) { - val newStoragePreference = sharedPreferences?.getString(getString(R.string.preference_storage), getString(R.string.settings_default_value_storage))!! - findPreference(getString(R.string.preference_storage))?.summary = newStoragePreference - - val newStorageType = newStoragePreference.asStorageType - var oldStorageType = Storage.Type.INTERNAL - var oldStorage = App.instance.internalStorage - if (newStorageType == Storage.Type.INTERNAL) { - oldStorageType = Storage.Type.SDCARD - oldStorage = App.instance.sdcardStorage!! - } - - // clean up before - oldStorage.clean() - - val fileCount = oldStorage.file.fileCount - if (fileCount > 0) { - val dialog = StorageMigrationDialogAutoBundle.builder(oldStorageType).build() - dialog.listener = object : StorageMigrationDialog.Listener { - override fun onDialogPositiveClick() { - val progressDialog = ProgressDialogHorizontalAutoBundle.builder() - .title(getString(R.string.dialog_storage_migration_title)) - .message(getString(R.string.dialog_storage_migration_message)) - .build() - progressDialog.max = fileCount - progressDialog.show(fragmentManager!!, ProgressDialogHorizontal.TAG) - - val migrationCallback = object : Storage.MigrationCallback { - override fun onProgressChanged(count: Int) { - activity?.runOnUiThread { progressDialog.progress = count } - } - - override fun onCompleted(success: Boolean) { - activity?.runOnUiThread { - if (success) { - showToast(R.string.dialog_storage_migration_successful) - } else { - showToast(R.string.error_plain) - } - progressDialog.dismiss() - } - } - } - - if (newStorageType == Storage.Type.INTERNAL) { - App.instance.sdcardStorage!!.migrateTo( - App.instance.internalStorage, - migrationCallback - ) - } else { - App.instance.internalStorage.migrateTo( - App.instance.sdcardStorage!!, - migrationCallback - ) - } - } - } - - - dialog.show(fragmentManager!!, StorageMigrationDialog.TAG) - } - } - } - override fun onCreatePreferences(bundle: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.settings) - val prefs = PreferenceManager.getDefaultSharedPreferences(activity) - - findPreference(getString(R.string.preference_storage))?.summary = - prefs.getString( - getString(R.string.preference_storage), - getString(R.string.settings_default_value_storage) - )!! findPreference(getString(R.string.preference_storage))?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { preference, newValue -> - FileDownloadHandler.isDownloadingAnything { isDownloadingAnything -> - if (isDownloadingAnything) { + Downloaders.isDownloadingAnything { + if (it) { showToast(R.string.notification_storage_locked) } else { val listener = preference.onPreferenceChangeListener diff --git a/app/src/main/java/de/xikolo/controllers/video/VideoItemPlayerFragment.kt b/app/src/main/java/de/xikolo/controllers/video/VideoItemPlayerFragment.kt index 629e91bf5..04b720af0 100644 --- a/app/src/main/java/de/xikolo/controllers/video/VideoItemPlayerFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/video/VideoItemPlayerFragment.kt @@ -3,6 +3,8 @@ package de.xikolo.controllers.video import android.content.res.Configuration import android.os.Bundle import de.xikolo.controllers.helper.VideoSettingsHelper +import de.xikolo.download.DownloadItem +import de.xikolo.download.DownloadStatus import de.xikolo.models.DownloadAsset import de.xikolo.models.Item import de.xikolo.models.Video @@ -71,11 +73,33 @@ class VideoItemPlayerFragment : VideoStreamPlayerFragment() { private val item: Item get() = ItemDao.Unmanaged.find(itemId)!! - private val videoDownloadAssetHD: DownloadAsset.Course.Item.VideoHD - get() = DownloadAsset.Course.Item.VideoHD(item, video) - - private val videoDownloadAssetSD: DownloadAsset.Course.Item.VideoSD - get() = DownloadAsset.Course.Item.VideoSD(item, video) + private val videoDownloadBest: DownloadAsset.Course.Item.VideoHLS + get() = DownloadAsset.Course.Item.VideoHLS( + item, + video, + VideoSettingsHelper.VideoQuality.BEST + ) + + private val videoDownloadHigh: DownloadAsset.Course.Item.VideoHLS + get() = DownloadAsset.Course.Item.VideoHLS( + item, + video, + VideoSettingsHelper.VideoQuality.HIGH + ) + + private val videoDownloadMedium: DownloadAsset.Course.Item.VideoHLS + get() = DownloadAsset.Course.Item.VideoHLS( + item, + video, + VideoSettingsHelper.VideoQuality.MEDIUM + ) + + private val videoDownloadLow: DownloadAsset.Course.Item.VideoHLS + get() = DownloadAsset.Course.Item.VideoHLS( + item, + video, + VideoSettingsHelper.VideoQuality.LOW + ) private val videoDao = VideoDao(Realm.getDefaultInstance()) @@ -89,73 +113,87 @@ class VideoItemPlayerFragment : VideoStreamPlayerFragment() { override fun play(fromUser: Boolean) { if (fromUser) { - LanalyticsUtil.trackVideoPlay(itemId, + LanalyticsUtil.trackVideoPlay( + itemId, courseId, sectionId, currentPosition, currentPlaybackSpeed.value, - activity!!.resources.configuration.orientation, + requireActivity().resources.configuration.orientation, currentQualityString, - sourceString) + sourceString + ) } super.play(fromUser) } override fun pause(fromUser: Boolean) { if (fromUser) { - LanalyticsUtil.trackVideoPause(itemId, + LanalyticsUtil.trackVideoPause( + itemId, courseId, sectionId, currentPosition, currentPlaybackSpeed.value, - activity!!.resources.configuration.orientation, + requireActivity().resources.configuration.orientation, currentQualityString, - sourceString) + sourceString + ) } super.pause(fromUser) } override fun seekTo(progress: Int, fromUser: Boolean) { if (fromUser) { - LanalyticsUtil.trackVideoSeek(itemId, + LanalyticsUtil.trackVideoSeek( + itemId, courseId, sectionId, currentPosition, progress, currentPlaybackSpeed.value, - activity!!.resources.configuration.orientation, + requireActivity().resources.configuration.orientation, currentQualityString, - sourceString) + sourceString + ) } super.seekTo(progress, fromUser) } - override fun changeQuality(oldVideoMode: VideoSettingsHelper.VideoMode, newVideoMode: VideoSettingsHelper.VideoMode, fromUser: Boolean) { + override fun changePlaybackMode( + oldMode: VideoSettingsHelper.PlaybackMode, + newMode: VideoSettingsHelper.PlaybackMode, + fromUser: Boolean + ) { if (fromUser) { val oldSourceString = sourceString - super.changeQuality(oldVideoMode, newVideoMode, true) - LanalyticsUtil.trackVideoChangeQuality(itemId, + super.changePlaybackMode(oldMode, newMode, true) + LanalyticsUtil.trackVideoChangeQuality( + itemId, courseId, sectionId, currentPosition, currentPlaybackSpeed.value, - activity!!.resources.configuration.orientation, - getQualityString(oldVideoMode), - getQualityString(newVideoMode), + requireActivity().resources.configuration.orientation, + getQualityString(oldMode), + getQualityString(newMode), oldSourceString, - sourceString) + sourceString + ) } else { - super.changeQuality(oldVideoMode, newVideoMode, false) + super.changePlaybackMode(oldMode, newMode, false) } } override fun changePlaybackSpeed(oldSpeed: VideoSettingsHelper.PlaybackSpeed, newSpeed: VideoSettingsHelper.PlaybackSpeed, fromUser: Boolean) { super.changePlaybackSpeed(oldSpeed, newSpeed, fromUser) if (fromUser) { - LanalyticsUtil.trackVideoChangeSpeed(itemId, + LanalyticsUtil.trackVideoChangeSpeed( + itemId, courseId, sectionId, currentPosition, oldSpeed.value, newSpeed.value, - activity!!.resources.configuration.orientation, + requireActivity().resources.configuration.orientation, currentQualityString, - sourceString) + sourceString + ) } } @@ -164,70 +202,73 @@ class VideoItemPlayerFragment : VideoStreamPlayerFragment() { videoDao.updateProgress(video, currentPosition) } - override fun getSubtitleList(): List? { - return video.subtitles + override fun getSubtitleList(): List { + return video.subtitles ?: emptyList() } - override fun getSubtitleUri(currentSubtitles: VideoSubtitles): String { - val downloadAsset = DownloadAsset.Course.Item.Subtitles(currentSubtitles, item) - return if (downloadAsset.downloadExists) { - "file://" + downloadAsset.download!!.absolutePath - } else { - super.getSubtitleUri(currentSubtitles) + override fun setVideo(mode: VideoSettingsHelper.PlaybackMode): Boolean { + val item = when (mode) { + VideoSettingsHelper.PlaybackMode.BEST -> videoDownloadBest + VideoSettingsHelper.PlaybackMode.HIGH -> videoDownloadHigh + VideoSettingsHelper.PlaybackMode.MEDIUM -> videoDownloadMedium + VideoSettingsHelper.PlaybackMode.LOW -> videoDownloadLow + else -> null } - } - override fun setVideoUri(currentQuality: VideoSettingsHelper.VideoMode): Boolean { - val videoAssetDownload: DownloadAsset.Course.Item? = when (currentQuality) { - VideoSettingsHelper.VideoMode.HD -> videoDownloadAssetHD - VideoSettingsHelper.VideoMode.SD -> videoDownloadAssetSD - else -> null + if (item != null && videoDownloadPresent(item)) { + val mediaSource = item.download!! + playerView.setVideoSource(mediaSource.first) + playerView.setSubtitleSources(mediaSource.second) + isOfflineVideo = true + return true } - return if (videoAssetDownload != null && videoDownloadPresent(videoAssetDownload)) { - setLocalVideoUri("file://" + videoAssetDownload.download!!) - true - } else { - super.setVideoUri(currentQuality) - } + return super.setVideo(mode) } - override fun getVideoMode(): VideoSettingsHelper.VideoMode { + override fun getPlaybackMode(): VideoSettingsHelper.PlaybackMode { return when { - videoDownloadPresent(videoDownloadAssetHD) -> // hd video download available - VideoSettingsHelper.VideoMode.HD - videoDownloadPresent(videoDownloadAssetSD) -> // sd video download available - VideoSettingsHelper.VideoMode.SD - else -> super.getVideoMode() + videoDownloadPresent(videoDownloadBest) -> + VideoSettingsHelper.PlaybackMode.BEST + videoDownloadPresent(videoDownloadHigh) -> + VideoSettingsHelper.PlaybackMode.HIGH + videoDownloadPresent(videoDownloadMedium) -> + VideoSettingsHelper.PlaybackMode.MEDIUM + videoDownloadPresent(videoDownloadLow) -> + VideoSettingsHelper.PlaybackMode.LOW + else -> super.getPlaybackMode() } } - override fun getOfflineAvailability(videoMode: VideoSettingsHelper.VideoMode): Boolean { - return when (videoMode) { - VideoSettingsHelper.VideoMode.HD -> videoDownloadPresent(videoDownloadAssetHD) - VideoSettingsHelper.VideoMode.SD -> videoDownloadPresent(videoDownloadAssetSD) - else -> false + override fun getOfflineAvailability(mode: VideoSettingsHelper.PlaybackMode): Boolean { + return when (mode) { + VideoSettingsHelper.PlaybackMode.BEST -> videoDownloadPresent(videoDownloadBest) + VideoSettingsHelper.PlaybackMode.HIGH -> videoDownloadPresent(videoDownloadHigh) + VideoSettingsHelper.PlaybackMode.MEDIUM -> videoDownloadPresent(videoDownloadMedium) + VideoSettingsHelper.PlaybackMode.LOW -> videoDownloadPresent(videoDownloadLow) + else -> false } } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - LanalyticsUtil.trackVideoChangeOrientation(itemId, + LanalyticsUtil.trackVideoChangeOrientation( + itemId, courseId, sectionId, currentPosition, currentPlaybackSpeed.value, newConfig.orientation, currentQualityString, - sourceString) + sourceString + ) } - private fun videoDownloadPresent(item: DownloadAsset.Course.Item): Boolean { - return item.downloadExists + private fun videoDownloadPresent(item: DownloadItem<*, *>): Boolean { + return item.status.state == DownloadStatus.State.DOWNLOADED } - private fun getQualityString(videoMode: VideoSettingsHelper.VideoMode): String { - return videoMode.name.toLowerCase(Locale.ENGLISH) + private fun getQualityString(mode: VideoSettingsHelper.PlaybackMode): String { + return mode.name.toLowerCase(Locale.ENGLISH) } - } diff --git a/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt b/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt index cb291be02..9edd1e22e 100644 --- a/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt @@ -5,8 +5,8 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.HandlerThread +import android.os.Looper import android.os.Message -import android.util.Log import android.view.LayoutInflater import android.view.ScaleGestureDetector import android.view.View @@ -19,8 +19,6 @@ import com.github.rubensousa.previewseekbar.PreviewBar import com.github.rubensousa.previewseekbar.PreviewSeekBar import com.google.android.material.bottomsheet.BottomSheetBehavior import de.xikolo.R -import de.xikolo.config.Config -import de.xikolo.config.Feature import de.xikolo.controllers.base.BaseFragment import de.xikolo.controllers.helper.VideoSettingsHelper import de.xikolo.models.VideoStream @@ -57,7 +55,11 @@ open class VideoStreamPlayerFragment : BaseFragment() { private const val BUNDLING_KEY_STREAM = "stream" private const val BUNDLING_KEY_AUTOPLAY = "autoplay" - fun bundle(instance: VideoStreamPlayerFragment, stream: VideoStream, autoPlay: Boolean = true) { + fun bundle( + instance: VideoStreamPlayerFragment, + stream: VideoStream, + autoPlay: Boolean = true + ) { val arguments = instance.arguments ?: Bundle() arguments.putAll( Bundle().apply { @@ -158,7 +160,7 @@ open class VideoStreamPlayerFragment : BaseFragment() { private lateinit var controlsVisibilityHandler: ControlsVisibilityHandler - private var isOfflineVideo = false + protected var isOfflineVideo = false private val applicationPreferences = ApplicationPreferences() @@ -184,7 +186,7 @@ open class VideoStreamPlayerFragment : BaseFragment() { get() = if (isOfflineVideo) "offline" else "online" val currentQualityString: String - get() = videoSettingsHelper.currentQuality.name.toLowerCase(Locale.ENGLISH) + get() = videoSettingsHelper.currentMode.name.toLowerCase(Locale.ENGLISH) var isShowingControls: Boolean = false private set @@ -200,27 +202,38 @@ open class VideoStreamPlayerFragment : BaseFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - VideoStreamPlayerFragment.unbundle(this, arguments) + unbundle(this, arguments) controlsVisibilityHandler = ControlsVisibilityHandler(this) } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { return inflater.inflate(R.layout.fragment_video_player, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val gestureDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { - override fun onScaleEnd(detector: ScaleGestureDetector?) { - detector?.let { - val immersive = it.scaleFactor > 1 - videoSettingsHelper.isImmersiveModeEnabled = immersive - changeImmersiveMode(videoSettingsHelper.isImmersiveModeEnabled, immersive, true) + val gestureDetector = ScaleGestureDetector( + context, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScaleEnd(detector: ScaleGestureDetector?) { + detector?.let { + val immersive = it.scaleFactor > 1 + videoSettingsHelper.isImmersiveModeEnabled = immersive + changeImmersiveMode( + videoSettingsHelper.isImmersiveModeEnabled, + immersive, + true + ) + } } } - }) + ) this.view?.setOnTouchListener { _, event -> if (!settingsOpen && controllerInterface?.isImmersiveModeAvailable() == true) { @@ -304,7 +317,7 @@ open class VideoStreamPlayerFragment : BaseFragment() { } if (!hasEnded && isPlaying) { - Handler().postDelayed( + Handler(Looper.getMainLooper()).postDelayed( Thread(this), SEEKBAR_UPDATER_INTERVAL ) @@ -408,24 +421,26 @@ open class VideoStreamPlayerFragment : BaseFragment() { settingsContainer = it } bottomSheetBehavior = BottomSheetBehavior.from(settingsContainer) - bottomSheetBehavior?.setBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_HIDDEN) { - settingsOpen = false - controllerInterface?.onSettingsClosed() - showControls() - } - if (newState == BottomSheetBehavior.STATE_EXPANDED) { - settingsOpen = true - controllerInterface?.onSettingsOpened() - showControls(Integer.MAX_VALUE) + bottomSheetBehavior?.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_HIDDEN) { + settingsOpen = false + controllerInterface?.onSettingsClosed() + showControls() + } + if (newState == BottomSheetBehavior.STATE_EXPANDED) { + settingsOpen = true + controllerInterface?.onSettingsOpened() + showControls(Integer.MAX_VALUE) + } } - } - override fun onSlide(bottomSheet: View, slideOffset: Float) { - controllerInterface?.onSettingsSliding(slideOffset) + override fun onSlide(bottomSheet: View, slideOffset: Float) { + controllerInterface?.onSettingsSliding(slideOffset) + } } - }) + ) showProgress() setupVideo() @@ -451,17 +466,20 @@ open class VideoStreamPlayerFragment : BaseFragment() { } } - override fun onQualityChanged( - old: VideoSettingsHelper.VideoMode, - new: VideoSettingsHelper.VideoMode + override fun onPlaybackModeChanged( + old: VideoSettingsHelper.PlaybackMode, + new: VideoSettingsHelper.PlaybackMode ) { hideSettings() if (old != new) { - changeQuality(old, new, true) + changePlaybackMode(old, new, true) } } - override fun onPlaybackSpeedChanged(old: VideoSettingsHelper.PlaybackSpeed, new: VideoSettingsHelper.PlaybackSpeed) { + override fun onPlaybackSpeedChanged( + old: VideoSettingsHelper.PlaybackSpeed, + new: VideoSettingsHelper.PlaybackSpeed + ) { hideSettings() if (old != new) { changePlaybackSpeed(old, new, true) @@ -494,12 +512,12 @@ open class VideoStreamPlayerFragment : BaseFragment() { } }, object : VideoSettingsHelper.VideoInfoCallback { - override fun isAvailable(videoMode: VideoSettingsHelper.VideoMode): Boolean { - return getVideoAvailability(videoMode) + override fun isAvailable(mode: VideoSettingsHelper.PlaybackMode): Boolean { + return getVideoAvailability(mode) } - override fun isOfflineAvailable(videoMode: VideoSettingsHelper.VideoMode): Boolean { - return getOfflineAvailability(videoMode) + override fun isOfflineAvailable(mode: VideoSettingsHelper.PlaybackMode): Boolean { + return getOfflineAvailability(mode) } override fun isImmersiveModeAvailable(): Boolean { @@ -507,85 +525,122 @@ open class VideoStreamPlayerFragment : BaseFragment() { } } ) - videoSettingsHelper.currentQuality = getVideoMode() + videoSettingsHelper.currentMode = getPlaybackMode() controllerInterface?.onImmersiveModeChanged(videoSettingsHelper.isImmersiveModeEnabled) } - protected open fun getVideoAvailability(videoMode: VideoSettingsHelper.VideoMode): Boolean { - return (videoMode == VideoSettingsHelper.VideoMode.HD && videoStream.hdUrl != null) - || (videoMode == VideoSettingsHelper.VideoMode.SD && videoStream.sdUrl != null) - || (videoMode == VideoSettingsHelper.VideoMode.AUTO && Feature.HLS_VIDEO && videoStream.hlsUrl != null) + protected open fun getVideoAvailability(mode: VideoSettingsHelper.PlaybackMode): Boolean { + return when (mode) { + VideoSettingsHelper.PlaybackMode.AUTO, + VideoSettingsHelper.PlaybackMode.LOW, + VideoSettingsHelper.PlaybackMode.MEDIUM, + VideoSettingsHelper.PlaybackMode.HIGH, + VideoSettingsHelper.PlaybackMode.BEST -> videoStream.hlsUrl != null + VideoSettingsHelper.PlaybackMode.LEGACY_HD -> + videoStream.hlsUrl == null && videoStream.hdUrl != null + VideoSettingsHelper.PlaybackMode.LEGACY_SD -> + videoStream.hlsUrl == null && videoStream.sdUrl != null + } } - protected open fun getSubtitleList(): List? { - return null + protected open fun getSubtitleList(): List { + return emptyList() } - protected open fun getOfflineAvailability(videoMode: VideoSettingsHelper.VideoMode): Boolean { + protected open fun getOfflineAvailability(mode: VideoSettingsHelper.PlaybackMode): Boolean { return false } - protected open fun getVideoMode(): VideoSettingsHelper.VideoMode { - return if (getVideoAvailability(VideoSettingsHelper.VideoMode.AUTO)) { - VideoSettingsHelper.VideoMode.AUTO - } else if (context.connectivityType == ConnectivityType.WIFI - || !applicationPreferences.isVideoQualityLimitedOnMobile) { - if (getVideoAvailability(VideoSettingsHelper.VideoMode.HD)) { - VideoSettingsHelper.VideoMode.HD - } else { - VideoSettingsHelper.VideoMode.SD + protected open fun getPlaybackMode(): VideoSettingsHelper.PlaybackMode { + return if (getVideoAvailability(VideoSettingsHelper.PlaybackMode.AUTO)) { + VideoSettingsHelper.PlaybackMode.AUTO + } else if (context.connectivityType == ConnectivityType.WIFI || + !applicationPreferences.isVideoQualityLimitedOnMobile + ) { + when { + getVideoAvailability(VideoSettingsHelper.PlaybackMode.BEST) -> + VideoSettingsHelper.PlaybackMode.BEST + getVideoAvailability(VideoSettingsHelper.PlaybackMode.HIGH) -> + VideoSettingsHelper.PlaybackMode.HIGH + getVideoAvailability(VideoSettingsHelper.PlaybackMode.MEDIUM) -> + VideoSettingsHelper.PlaybackMode.MEDIUM + getVideoAvailability(VideoSettingsHelper.PlaybackMode.LOW) -> + VideoSettingsHelper.PlaybackMode.LOW + getVideoAvailability(VideoSettingsHelper.PlaybackMode.LEGACY_HD) -> + VideoSettingsHelper.PlaybackMode.LEGACY_HD + getVideoAvailability(VideoSettingsHelper.PlaybackMode.LEGACY_SD) -> + VideoSettingsHelper.PlaybackMode.LEGACY_SD + else -> throw IllegalArgumentException("No supported playback mode") } } else { - if (getVideoAvailability(VideoSettingsHelper.VideoMode.SD)) { - VideoSettingsHelper.VideoMode.SD - } else { - VideoSettingsHelper.VideoMode.HD + when { + getVideoAvailability(VideoSettingsHelper.PlaybackMode.MEDIUM) -> + VideoSettingsHelper.PlaybackMode.MEDIUM + getVideoAvailability(VideoSettingsHelper.PlaybackMode.LOW) -> + VideoSettingsHelper.PlaybackMode.LOW + getVideoAvailability(VideoSettingsHelper.PlaybackMode.HIGH) -> + VideoSettingsHelper.PlaybackMode.HIGH + getVideoAvailability(VideoSettingsHelper.PlaybackMode.BEST) -> + VideoSettingsHelper.PlaybackMode.BEST + getVideoAvailability(VideoSettingsHelper.PlaybackMode.LEGACY_SD) -> + VideoSettingsHelper.PlaybackMode.LEGACY_SD + getVideoAvailability(VideoSettingsHelper.PlaybackMode.LEGACY_HD) -> + VideoSettingsHelper.PlaybackMode.LEGACY_HD + else -> throw IllegalArgumentException("No supported playback mode") } } } - protected open fun getSubtitleUri(currentSubtitles: VideoSubtitles): String { - return currentSubtitles.vttUrl - } - - protected open fun getSubtitleLanguage(currentSubtitles: VideoSubtitles): String { - return currentSubtitles.language - } - - protected open fun setVideoUri(currentQuality: VideoSettingsHelper.VideoMode): Boolean { - val stream: String - val isHls: Boolean - - when (currentQuality) { - VideoSettingsHelper.VideoMode.HD -> { - stream = videoStream.hdUrl - isHls = false - } - VideoSettingsHelper.VideoMode.SD -> { - stream = videoStream.sdUrl - isHls = false - } - else -> { - stream = videoStream.hlsUrl - isHls = true - } - } - + protected open fun setVideo(mode: VideoSettingsHelper.PlaybackMode): Boolean { return when { - context.isOnline -> { // device has internet connection - if (isHls) { - setHlsVideoUri(stream) - } else { - setVideoUri(stream) + context.isOnline -> { // device has internet connection + when (mode) { + VideoSettingsHelper.PlaybackMode.AUTO -> { + playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) + playerView.setDesiredQuality(null) + } + VideoSettingsHelper.PlaybackMode.LOW -> { + playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) + playerView.setDesiredQuality( + VideoSettingsHelper.VideoQuality.LOW.qualityFraction + ) + } + VideoSettingsHelper.PlaybackMode.MEDIUM -> { + playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) + playerView.setDesiredQuality( + VideoSettingsHelper.VideoQuality.MEDIUM.qualityFraction + ) + } + VideoSettingsHelper.PlaybackMode.HIGH -> { + playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) + playerView.setDesiredQuality( + VideoSettingsHelper.VideoQuality.HIGH.qualityFraction + ) + } + VideoSettingsHelper.PlaybackMode.BEST -> { + playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) + playerView.setDesiredQuality( + VideoSettingsHelper.VideoQuality.BEST.qualityFraction + ) + } + VideoSettingsHelper.PlaybackMode.LEGACY_HD -> { + playerView.setProgressiveVideoUri(Uri.parse(videoStream.hdUrl)) + } + VideoSettingsHelper.PlaybackMode.LEGACY_SD -> { + playerView.setProgressiveVideoUri(Uri.parse(videoStream.sdUrl)) + } } + playerView.setSubtitleUris( + getSubtitleList().associate { + it.language to Uri.parse(it.vttUrl) + } + ) + isOfflineVideo = false true } - currentQuality == VideoSettingsHelper.VideoMode.AUTO -> // retry with HD instead of HLS - setVideoUri(VideoSettingsHelper.VideoMode.HD) - videoSettingsHelper.currentQuality == VideoSettingsHelper.VideoMode.HD -> // retry with SD instead of HD - setVideoUri(VideoSettingsHelper.VideoMode.SD) - else -> { + else -> { + playerView.pause() warningContainer.visibility = View.VISIBLE warningText.text = getString(R.string.video_notification_no_offline_video) false @@ -593,22 +648,35 @@ open class VideoStreamPlayerFragment : BaseFragment() { } } - protected open fun changeSubtitles(oldSubtitles: VideoSubtitles?, newSubtitles: VideoSubtitles?, fromUser: Boolean) { + protected open fun changeSubtitles( + oldSubtitles: VideoSubtitles?, + newSubtitles: VideoSubtitles?, + fromUser: Boolean + ) { showProgress() updateSubtitles() prepare() } - protected open fun changeQuality(oldVideoMode: VideoSettingsHelper.VideoMode, newVideoMode: VideoSettingsHelper.VideoMode, fromUser: Boolean) { + protected open fun changePlaybackMode( + oldMode: VideoSettingsHelper.PlaybackMode, + newMode: VideoSettingsHelper.PlaybackMode, + fromUser: Boolean + ) { showProgress() if (updateVideo()) { prepare() } else { + pause(false) showError() } } - protected open fun changePlaybackSpeed(oldSpeed: VideoSettingsHelper.PlaybackSpeed, newSpeed: VideoSettingsHelper.PlaybackSpeed, fromUser: Boolean) { + protected open fun changePlaybackSpeed( + oldSpeed: VideoSettingsHelper.PlaybackSpeed, + newSpeed: VideoSettingsHelper.PlaybackSpeed, + fromUser: Boolean + ) { updatePlaybackSpeed() } @@ -618,6 +686,7 @@ open class VideoStreamPlayerFragment : BaseFragment() { private fun showError() { saveCurrentPosition() + hideProgress() warningContainer.visibility = View.VISIBLE warningText.text = getString(R.string.error_plain) } @@ -767,7 +836,7 @@ open class VideoStreamPlayerFragment : BaseFragment() { private fun updateSubtitles() { val currentSubtitles = videoSettingsHelper.currentVideoSubtitles if (currentSubtitles != null) { - playerView.showSubtitles(getSubtitleUri(currentSubtitles), getSubtitleLanguage(currentSubtitles)) + playerView.showSubtitles(currentSubtitles.language) } else { playerView.removeSubtitles() } @@ -776,14 +845,10 @@ open class VideoStreamPlayerFragment : BaseFragment() { private fun updateVideo(): Boolean { warningContainer.visibility = View.GONE - if (setVideoUri(videoSettingsHelper.currentQuality)) { + if (setVideo(videoSettingsHelper.currentMode)) { updateSubtitles() updatePlaybackSpeed() - if (isOfflineVideo) { - playerView.uri?.let { - playerView.setPreviewUri(it) - } - } else if (context.isOnline) { + if (!isOfflineVideo && context.isOnline) { if (videoStream.sdUrl != null) { playerView.setPreviewUri(Uri.parse(videoStream.sdUrl)) } else if (videoStream.hdUrl != null) { @@ -795,27 +860,6 @@ open class VideoStreamPlayerFragment : BaseFragment() { return false } - protected fun setLocalVideoUri(localUri: String) { - setVideoUri(localUri) - isOfflineVideo = true - } - - private fun setHlsVideoUri(hlsUri: String) { - if (Config.DEBUG) { - Log.i(TAG, "HLS Video HOST_URL: $hlsUri") - } - playerView.setVideoURI(Uri.parse(hlsUri), true) - isOfflineVideo = false - } - - private fun setVideoUri(uri: String) { - if (Config.DEBUG) { - Log.i(TAG, "Video HOST_URL: $uri") - } - playerView.setVideoURI(Uri.parse(uri), false) - isOfflineVideo = false - } - private fun prepare() { initialPlaybackState = isPlaying if (!isInitialPreparing) { @@ -829,9 +873,15 @@ open class VideoStreamPlayerFragment : BaseFragment() { protected open fun saveCurrentPosition() {} private fun getTimeString(millis: Int): String { - return String.format(Locale.US, "%02d:%02d", + return String.format( + Locale.US, + "%02d:%02d", TimeUnit.MILLISECONDS.toMinutes(millis.toLong()), - TimeUnit.MILLISECONDS.toSeconds(millis.toLong()) - TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(millis.toLong())) + TimeUnit.MILLISECONDS.toSeconds(millis.toLong()) - TimeUnit.MINUTES.toSeconds( + TimeUnit.MILLISECONDS.toMinutes( + millis.toLong() + ) + ) ) } @@ -862,8 +912,8 @@ open class VideoStreamPlayerFragment : BaseFragment() { fun onToggleFullscreen(): Boolean } - protected class ControlsVisibilityHandler(private val controller: VideoStreamPlayerFragment) : Handler() { - + protected class ControlsVisibilityHandler(private val controller: VideoStreamPlayerFragment) : + Handler(Looper.getMainLooper()) { override fun handleMessage(message: Message) { when (message.what) { MESSAGE_CONTROLS_FADE_OUT -> @@ -873,5 +923,4 @@ open class VideoStreamPlayerFragment : BaseFragment() { } } } - } diff --git a/app/src/main/java/de/xikolo/download/DownloadCategory.kt b/app/src/main/java/de/xikolo/download/DownloadCategory.kt new file mode 100644 index 000000000..80898de25 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/DownloadCategory.kt @@ -0,0 +1,78 @@ +package de.xikolo.download + +import com.google.gson.JsonSyntaxException +import com.google.gson.TypeAdapter +import com.google.gson.annotations.JsonAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter + +/** + * Represents a download category. + * This class is sealed to ensure exhaustiveness. + */ +@JsonAdapter(DownloadCategory.Companion.JsonAdapter::class) +sealed class DownloadCategory { + + /** + * Category other downloads. + */ + object Other : DownloadCategory() + + /** + * Documents category. + */ + object Documents : DownloadCategory() + + /** + * Certificates category. + */ + object Certificates : DownloadCategory() + + /** + * Course category. + * + * @param id The course id. + */ + data class Course(val id: String) : DownloadCategory() + + companion object { + + /** + * Custom JSON adapter to persist the class names. + */ + class JsonAdapter : TypeAdapter() { + override fun write(out: JsonWriter?, value: DownloadCategory?) { + out?.beginObject() + out?.name("name") + when (value) { + is Other -> out?.value("other") + is Documents -> out?.value("documents") + is Certificates -> out?.value("certificates") + is Course -> { + out?.value("course") + out?.name("id") + out?.value(value.id) + } + } + out?.endObject() + } + + override fun read(input: JsonReader?): DownloadCategory? { + input?.beginObject() + input?.nextName() + val category = when (input?.nextString()) { + "other" -> Other + "documents" -> Documents + "certificates" -> Certificates + "course" -> { + input.nextName() + Course(input.nextString()) + } + else -> throw JsonSyntaxException("unsupported category") + } + input.endObject() + return category + } + } + } +} diff --git a/app/src/main/java/de/xikolo/download/DownloadHandler.kt b/app/src/main/java/de/xikolo/download/DownloadHandler.kt index ca411e16b..c8f22e695 100644 --- a/app/src/main/java/de/xikolo/download/DownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/DownloadHandler.kt @@ -1,22 +1,83 @@ package de.xikolo.download +import de.xikolo.models.Storage + +/** + * Definition of a download handler, + * the component which handles the actual downloading and download management. + * + * @param I The [DownloadIdentifier] type. + * @param R The [DownloadRequest] type. + */ interface DownloadHandler { + /** + * Checks whether anything is currently being downloaded. + * + * @param callback An asynchronous callback either returning true or false. + * This callback is always invoked if not null. + */ fun isDownloadingAnything(callback: (Boolean) -> Unit) + /** + * Returns the identifier for a download request without downloading it. + * + * @param request The download request. + * @return The [DownloadIdentifier] for the request. + */ + fun identify(request: R): I + + /** + * Initiates the downloading process. + * + * @param request The download request which specifies the downloading. + * @param callback An asynchronous callback to deliver a return value. + * It returns true when the download was initiated successfully, otherwise false. + * This callback is always invoked if not null. + */ fun download( request: R, - listener: ((DownloadStatus?) -> Unit)? = null, - callback: ((I?) -> Unit)? = null + callback: ((Boolean) -> Unit)? = null ) - fun cancel( + /** + * Deletes a download. When the download is pending or running, it is canceled first. + * + * @param identifier The identifier of the download. + * @param callback An asynchronous callback to deliver a return value. + * It returns true when the download deletion was initiated successfully, otherwise false. + * This callback is always invoked if not null. + */ + fun delete( identifier: I, callback: ((Boolean) -> Unit)? = null ) - fun status( + /** + * Registers a listener for a download that notifies when the download status changes. + * Overrides the previous listener. + * The listener is set immediately, but the callback might get called with a delay. + * + * @param identifier The identifier of the download. + * @param listener An asynchronous callback that is invoked regularly with the most recent + * download status. There does not necessarily have to be a status change between calls. + * Supplying null here removes any listener. + */ + fun listen( identifier: I, - callback: (DownloadStatus?) -> Unit + listener: ((DownloadStatus) -> Unit)? + ) + + /** + * Queries all downloads that have been successfully downloaded. + * + * @param storage The storage location of downloads to query. + * @param callback An asynchronous callback that returns a Map from download identifier to + * download status and category. + * This callback is always invoked if not null. + */ + fun getDownloads( + storage: Storage, + callback: (Map>) -> Unit ) } diff --git a/app/src/main/java/de/xikolo/download/DownloadIdentifier.kt b/app/src/main/java/de/xikolo/download/DownloadIdentifier.kt index 80623c925..9c6da179a 100644 --- a/app/src/main/java/de/xikolo/download/DownloadIdentifier.kt +++ b/app/src/main/java/de/xikolo/download/DownloadIdentifier.kt @@ -1,3 +1,6 @@ package de.xikolo.download +/** + * Definition of a download identifier. + */ interface DownloadIdentifier diff --git a/app/src/main/java/de/xikolo/download/DownloadItem.kt b/app/src/main/java/de/xikolo/download/DownloadItem.kt index 9035059d6..ae026fcb6 100644 --- a/app/src/main/java/de/xikolo/download/DownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/DownloadItem.kt @@ -2,41 +2,70 @@ package de.xikolo.download import androidx.fragment.app.FragmentActivity -abstract class DownloadItem { - - abstract val isDownloadable: Boolean - - abstract val downloadSize: Long - - abstract val title: String - - abstract val download: F? - - abstract val openAction: ((FragmentActivity) -> Unit)? - - abstract var stateListener: StateListener? - - abstract fun start(activity: FragmentActivity, callback: ((I?) -> Unit)? = null) - - abstract fun cancel(activity: FragmentActivity, callback: ((Boolean) -> Unit)? = null) - - abstract fun delete(activity: FragmentActivity, callback: ((Boolean) -> Unit)? = null) - - abstract fun getProgress(callback: (Pair) -> Unit) - - abstract fun isDownloadRunning(callback: (Boolean) -> Unit) - - val downloadExists: Boolean - get() { - return download != null - } - - interface StateListener { - - fun onStarted() - - fun onCompleted() - - fun onDeleted() - } +/** + * Definition of a downloadable thing. + * + * @param D The type of the download object. + * @param I The [DownloadIdentifier] type. + */ +interface DownloadItem { + + /** + * The identifier of the download. + * Must not be accessed when [downloadable] is false. + */ + val identifier: I + + /** + * The download object. + * Is null when the download is not available, e.g. it has not been downloaded, + * or when an error occurred. + */ + val download: D? + + /** + * Whether the item can be downloaded. + */ + val downloadable: Boolean + + /** + * The title of the download. + */ + val title: String + + /** + * Executable block to open the download. + */ + val openAction: ((FragmentActivity) -> Unit)? + + /** + * Total size of the download. + * Returns 0 per default if the download size cannot be determined. + */ + val size: Long + + /** + * Subscriptable [LiveData] status of the download. + */ + val status: DownloadStatus.DownloadStatusLiveData + + /** + * Starts the downloading process. + * + * @param activity The context activity for the download. Used to e.g. check permissions. + * @param callback An asynchronous callback to deliver a return value. + * It returns true when downloading started successfully, otherwise false. + * This callback is always invoked if not null. + */ + fun start(activity: FragmentActivity, callback: ((Boolean) -> Unit)? = null) + + /** + * Deletes the download. When the download is pending or running, it is canceled first. + * + * @param activity The context activity for the download. Used to e.g. check permissions. + * @param callback An asynchronous callback to deliver a return value. + * It returns true when the download deletion was initiated successfully, otherwise false. + * This callback is always invoked if not null. + */ + fun delete(activity: FragmentActivity, callback: ((Boolean) -> Unit)? = null) } diff --git a/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt b/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt new file mode 100644 index 000000000..2b11915ec --- /dev/null +++ b/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt @@ -0,0 +1,152 @@ +package de.xikolo.download + +import android.util.Log +import androidx.fragment.app.FragmentActivity +import de.xikolo.App +import de.xikolo.extensions.observeOnce +import de.xikolo.managers.PermissionManager +import de.xikolo.models.DownloadAsset +import de.xikolo.models.Storage +import de.xikolo.states.PermissionStateLiveData +import de.xikolo.utils.LanalyticsUtil +import de.xikolo.utils.extensions.buildWriteErrorMessage +import de.xikolo.utils.extensions.showToast + +/** + * Abstract implementation of [DownloadItem] behavior. + * + * @param D The type of the download object. + * @param I The [DownloadIdentifier] type. + * @param R The [DownloadRequest] type. + * + * @param storage The storage to persist the download on. + */ +abstract class DownloadItemImpl( + var storage: Storage +) : DownloadItem { + + companion object { + val TAG: String? = DownloadItemImpl::class.simpleName + } + + protected val context = App.instance + + /** + * The download handler. + */ + abstract val downloader: DownloadHandler + + final override val identifier: I + get() = downloader.identify(request) + + override val openAction: ((FragmentActivity) -> Unit)? = null + + /** + * The download request. + * Must not be accessed when [downloadable] is false. + */ + abstract val request: R + + private val statusCache: DownloadStatus.DownloadStatusLiveData by lazy { + // this has to be lazy initialized because [download] might not be available at class init + DownloadStatus.DownloadStatusLiveData( + null, + null, + if (download != null) DownloadStatus.State.DOWNLOADED else DownloadStatus.State.DELETED + ) + } + + final override val status: DownloadStatus.DownloadStatusLiveData by lazy { + // register a listener here which updates the cache and always return the cached status to + // realize synchronous function + downloader.listen(identifier) { + onStatusChanged(it) + } + statusCache + } + + protected open fun onStatusChanged(newStatus: DownloadStatus) { + // update the status cache + statusCache.value = newStatus + } + + override fun start(activity: FragmentActivity, callback: ((Boolean) -> Unit)?) { + performAction(activity) { + when { + !downloadable || + status.state == DownloadStatus.State.PENDING || + status.state == DownloadStatus.State.RUNNING || + status.state == DownloadStatus.State.DOWNLOADED -> callback?.invoke(false) + downloadable -> { + downloader.download( + request, + { success -> + if (success) { + if (this is DownloadAsset.Course.Item) { + LanalyticsUtil.trackDownloadedFile(this) + } + + callback?.invoke(true) + } else { + callback?.invoke(false) + } + } + ) + downloader.listen(identifier) { + onStatusChanged(it) + } + status.state = DownloadStatus.State.PENDING + } + else -> callback?.invoke(false) + } + } + } + + override fun delete(activity: FragmentActivity, callback: ((Boolean) -> Unit)?) { + performAction(activity) { + val existed = status.state == DownloadStatus.State.DOWNLOADED + downloader.delete(identifier) { + /*if (it) { + status.state = DownloadStatus.State.DELETED + }*/ + callback?.invoke(it && existed) + } + } + } + + /** + * Checks and requests permissions. + * + * @param activity The context activity for permission management. + * @param action Executable block which is invoked when permissions are fine. + */ + private fun performAction(activity: FragmentActivity, action: () -> Unit): Boolean { + return if (storage.isWritable) { + if ( + PermissionManager(activity) + .requestPermission(PermissionManager.WRITE_EXTERNAL_STORAGE) == 1 + ) { + action() + true + } else { + context.state.permission.of( + PermissionManager.REQUEST_CODE_WRITE_EXTERNAL_STORAGE + ) + .observeOnce(activity) { state -> + return@observeOnce if ( + state == PermissionStateLiveData.PermissionStateCode.GRANTED + ) { + performAction(activity, action) + true + } else false + } + false + } + } else { + val msg = context.buildWriteErrorMessage() + Log.w(TAG, msg) + activity.showToast(msg) + false + } + } +} diff --git a/app/src/main/java/de/xikolo/download/DownloadRequest.kt b/app/src/main/java/de/xikolo/download/DownloadRequest.kt index d7203bda2..e6c3e3c8b 100644 --- a/app/src/main/java/de/xikolo/download/DownloadRequest.kt +++ b/app/src/main/java/de/xikolo/download/DownloadRequest.kt @@ -1,6 +1,22 @@ package de.xikolo.download +/** + * Definition of a download request. + */ interface DownloadRequest { + + /** + * The title of the download. Might be shown in a notification. + */ val title: String + + /** + * Whether a notification should be shown for the download. + */ val showNotification: Boolean + + /** + * The category of the download. + */ + val category: DownloadCategory } diff --git a/app/src/main/java/de/xikolo/download/DownloadStatus.kt b/app/src/main/java/de/xikolo/download/DownloadStatus.kt index 3cb92b96f..3e52f0121 100644 --- a/app/src/main/java/de/xikolo/download/DownloadStatus.kt +++ b/app/src/main/java/de/xikolo/download/DownloadStatus.kt @@ -1,22 +1,107 @@ package de.xikolo.download -class DownloadStatus( - var totalBytes: Long, - var downloadedBytes: Long, - var state: State +import androidx.lifecycle.LiveData + +/** + * Represents the status of a download. + * + * @param totalBytes The total size of the download data in bytes. + * + * @param downloadedBytes The currently downloaded number of bytes. + * Equal to [totalBytes] when [state] is [DOWNLOADED]. + * + * @param state The current state of the download process. + * + * @param error A throwable indicating the error that occurred when the download failed. + */ +data class DownloadStatus( + var totalBytes: Long?, + var downloadedBytes: Long?, + var state: State, + var error: Throwable? ) { + /** + * Represents the state a download is in. + */ enum class State { - PENDING, RUNNING, SUCCESSFUL, CANCELLED, FAILED; - // Determines what two states result in together. + /** + * The download has been initiated but did not start to download. + */ + PENDING, + + /** + * The download is running. + */ + RUNNING, + + /** + * The download has been downloaded successfully and is persisted. + */ + DOWNLOADED, + + /** + * The download is not downloaded or persisted. + */ + DELETED; + + /** + * Determines the combined state of two download states. + */ fun and(other: State): State { return when { - this == FAILED || other == FAILED -> FAILED - this == CANCELLED || other == CANCELLED -> CANCELLED + this == DELETED || other == DELETED -> DELETED this == RUNNING || other == RUNNING -> RUNNING this == PENDING || other == PENDING -> PENDING - else -> SUCCESSFUL + else -> DOWNLOADED } } } + + /** + * [LiveData] wrapper for a [DownloadStatus]. + */ + class DownloadStatusLiveData( + totalBytes: Long? = null, + downloadedBytes: Long? = null, + state: State = State.DELETED, + error: Throwable? = null + ) : LiveData() { + + public override fun setValue(value: DownloadStatus) { + super.setValue(value) + } + + override fun getValue(): DownloadStatus { + return super.getValue()!! + } + + var totalBytes: Long? + get() = value.totalBytes + set(value) { + this.value = this.value.apply { totalBytes = value } + } + + var downloadedBytes: Long? + get() = value.downloadedBytes + set(value) { + this.value = this.value.apply { downloadedBytes = value } + } + + var state: State + get() = value.state + set(value) { + this.value = this.value.apply { state = value } + } + + var error: Throwable? + get() = value.error + set(value) { + this.value = this.value.apply { error = value } + } + + init { + super.setValue(DownloadStatus(totalBytes, downloadedBytes, state, error)) + } + } } diff --git a/app/src/main/java/de/xikolo/download/Downloaders.kt b/app/src/main/java/de/xikolo/download/Downloaders.kt new file mode 100644 index 000000000..4321e4d20 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/Downloaders.kt @@ -0,0 +1,92 @@ +package de.xikolo.download + +import de.xikolo.download.filedownload.FileDownloadHandler +import de.xikolo.download.filedownload.FileDownloadIdentifier +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadHandler +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadIdentifier +import de.xikolo.models.Storage +import java.util.concurrent.atomic.AtomicInteger + +/** + * Convenience class for combination of multiple download handlers. + */ +object Downloaders { + + private lateinit var fileDownloader: FileDownloadHandler + + private lateinit var hlsDownloader: HlsVideoDownloadHandler + + /** + * Initializes all download handlers. + */ + fun initialize() { + fileDownloader = FileDownloadHandler + hlsDownloader = HlsVideoDownloadHandler + } + + /** + * Checks whether any download handler currently downloads something. + * + * @param callback An asynchronous callback either returning true or false. + * This callback is always invoked if not null. + */ + fun isDownloadingAnything(callback: (Boolean) -> Unit) { + fileDownloader.isDownloadingAnything { a -> + hlsDownloader.isDownloadingAnything { b -> + callback.invoke(a || b) + } + } + } + + /** + * Queries all download handlers for downloads that have been successfully downloaded. + * + * @param storage The storage location of downloads to query. + * @param callback An asynchronous callback that returns a Map from download identifier to + * download status and category. + * This callback is always invoked if not null. + */ + fun getDownloads( + storage: Storage, + callback: (Map>) -> Unit + ) { + fileDownloader.getDownloads(storage) { a -> + hlsDownloader.getDownloads(storage) { b -> + callback.invoke(a + b) + } + } + } + + /** + * Deletes a collection of downloads. + * + * @param identifiers The collection of download identifiers. + * @param callback An asynchronous callback that returns true if all downloads were deleted + * successfully, otherwise false. + * This callback is always invoked if not null. + */ + fun deleteDownloads( + identifiers: Collection, + callback: ((Boolean) -> Unit)? = null + ) { + val lock = AtomicInteger(identifiers.size) + var globalSuccess = true + val globalCallback: (Boolean) -> Unit = { success -> + globalSuccess = globalSuccess and success + if (lock.getAndDecrement() == 1) { + callback?.invoke(globalSuccess) + } + } + if (identifiers.isEmpty()) { + callback?.invoke(true) + return + } + identifiers.forEach { + when (it) { + is FileDownloadIdentifier -> fileDownloader.delete(it, globalCallback) + is HlsVideoDownloadIdentifier -> hlsDownloader.delete(it, globalCallback) + else -> globalCallback(false) + } + } + } +} diff --git a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadHandler.kt b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadHandler.kt index b8ccc76e2..074ebdbb2 100644 --- a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadHandler.kt @@ -1,6 +1,13 @@ package de.xikolo.download.filedownload +import android.app.NotificationManager import android.content.Context +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import com.google.gson.Gson import com.tonyodev.fetch2.DefaultFetchNotificationManager import com.tonyodev.fetch2.Download import com.tonyodev.fetch2.DownloadNotification @@ -8,19 +15,41 @@ import com.tonyodev.fetch2.Error import com.tonyodev.fetch2.Fetch import com.tonyodev.fetch2.FetchConfiguration import com.tonyodev.fetch2.FetchListener +import com.tonyodev.fetch2.NetworkType +import com.tonyodev.fetch2.Request import com.tonyodev.fetch2.Status import com.tonyodev.fetch2core.DownloadBlock +import com.tonyodev.fetch2core.Extras import de.xikolo.App +import de.xikolo.R +import de.xikolo.config.Config +import de.xikolo.download.DownloadCategory import de.xikolo.download.DownloadHandler import de.xikolo.download.DownloadStatus -import de.xikolo.download.filedownload.FileDownloadRequest.Companion.REQUEST_EXTRA_SHOW_NOTIFICATION -import de.xikolo.download.filedownload.FileDownloadRequest.Companion.REQUEST_EXTRA_TITLE +import de.xikolo.managers.UserManager +import de.xikolo.models.Storage +import de.xikolo.storages.ApplicationPreferences import de.xikolo.utils.NotificationUtil +import de.xikolo.utils.extensions.createIfNotExists +import java.util.Locale +/** + * DownloadHandler for progressive downloads to a file. + * Based on [Fetch]. + */ object FileDownloadHandler : DownloadHandler { + const val REQUEST_EXTRA_TITLE = "title" + const val REQUEST_EXTRA_SHOW_NOTIFICATION = "showNotification" + const val REQUEST_EXTRA_CATEGORY = "category" + + private val context: Context + get() = App.instance + + private val handler: Handler + private val disabledNotificationsConfiguration = - FetchConfiguration.Builder(App.instance) + FetchConfiguration.Builder(context) .setAutoRetryMaxAttempts(1) .setDownloadConcurrentLimit(5) .enableFileExistChecks(false) @@ -28,17 +57,66 @@ object FileDownloadHandler : DownloadHandler, + context: Context + ): Boolean { + val util = NotificationUtil.getInstance(context) + if (downloadNotifications.isNotEmpty()) { + util.notify( + NotificationUtil.DOWNLOAD_RUNNING_NOTIFICATION_ID, + util.getDownloadRunningGroupNotification( + this, + downloadNotifications.size + ) + ) + } else { + util.cancel(NotificationUtil.DOWNLOAD_RUNNING_NOTIFICATION_ID) + } + return false + } + + override fun updateNotification( + notificationBuilder: NotificationCompat.Builder, + downloadNotification: DownloadNotification, + context: Context + ) { + NotificationUtil.getInstance(context).updateDownloadRunningNotification( + notificationBuilder, + downloadNotification.title, + downloadNotification.progress, + getActionPendingIntent( + downloadNotification, + DownloadNotification.ActionType.CANCEL + ) + ) + } + override fun getDownloadNotificationTitle(download: Download): String { return download.request.extras.getString( REQUEST_EXTRA_TITLE, @@ -46,16 +124,19 @@ object FileDownloadHandler : DownloadHandler Unit)?> = mutableMapOf() + private val listeners: MutableMap Unit)?> = mutableMapOf() override fun isDownloadingAnything(callback: (Boolean) -> Unit) { - disabledNotificationsManager.hasActiveDownloads(true) { a -> - enabledNotificationsManager.hasActiveDownloads(true) { b -> - callback(a || b) + val mainHandler = Handler(Looper.getMainLooper()) + + handler.post { + disabledNotificationsManager.hasActiveDownloads(true) { a -> + enabledNotificationsManager.hasActiveDownloads(true) { b -> + mainHandler.post { + callback(a || b) + } + } } } } - override fun download( - request: FileDownloadRequest, - listener: ((DownloadStatus?) -> Unit)?, - callback: ((FileDownloadIdentifier?) -> Unit)? - ) { - val req = request.buildRequest() - - if (req.extras.getBoolean(REQUEST_EXTRA_SHOW_NOTIFICATION, true)) { - enabledNotificationsManager - } else { - disabledNotificationsManager - }.enqueue( - req, - { - callback?.invoke(FileDownloadIdentifier(it.id)) - }, - { - callback?.invoke(null) + override fun identify(request: FileDownloadRequest): FileDownloadIdentifier { + return FileDownloadIdentifier(Request(request.url, request.localFile.toUri()).id) + } + + override fun download(request: FileDownloadRequest, callback: ((Boolean) -> Unit)?) { + val mainHandler = Handler(Looper.getMainLooper()) + + val req = Request(request.url, request.localFile.toUri()).apply { + networkType = + if (ApplicationPreferences().isDownloadNetworkLimitedOnMobile) { + NetworkType.WIFI_ONLY + } else { + NetworkType.ALL + } + + val extrasMap = + mutableMapOf( + REQUEST_EXTRA_TITLE to request.title, + REQUEST_EXTRA_SHOW_NOTIFICATION to request.showNotification.toString(), + REQUEST_EXTRA_CATEGORY to Gson().toJson( + request.category, + DownloadCategory::class.java + ) + ) + + extras = Extras(extrasMap) + groupId = 0 + + addHeader(Config.HEADER_USER_AGENT, Config.HEADER_USER_AGENT_VALUE) + addHeader( + Config.HEADER_ACCEPT, + Config.MEDIA_TYPE_JSON_API + "; xikolo-version=" + Config.XIKOLO_API_VERSION + ) + addHeader(Config.HEADER_CONTENT_TYPE, Config.MEDIA_TYPE_JSON_API) + addHeader(Config.HEADER_USER_PLATFORM, Config.HEADER_USER_PLATFORM_VALUE) + addHeader(Config.HEADER_ACCEPT_LANGUAGE, Locale.getDefault().language) + + if (url.toUri().host == App.instance.getString(R.string.app_host) && + UserManager.isAuthorized + ) { + addHeader( + Config.HEADER_AUTH, + Config.HEADER_AUTH_VALUE_PREFIX_JSON_API + UserManager.token!! + ) } - ) + } - listeners[req.id] = listener - } + request.localFile.parentFile?.createIfNotExists() - override fun cancel(identifier: FileDownloadIdentifier, callback: ((Boolean) -> Unit)?) { - enabledNotificationsManager.getDownload(identifier.id) { d1 -> - if (d1 != null) { - enabledNotificationsManager.cancel(d1.id) - enabledNotificationsManager.delete(d1.id) - callback?.invoke(true) + handler.post { + if (req.extras.getBoolean(REQUEST_EXTRA_SHOW_NOTIFICATION, true)) { + enabledNotificationsManager } else { - disabledNotificationsManager.getDownload(identifier.id) { d2 -> - if (d2 != null) { - disabledNotificationsManager.cancel(d2.id) - disabledNotificationsManager.delete(d2.id) + disabledNotificationsManager + }.enqueue( + req, + { + mainHandler.post { callback?.invoke(true) - } else { + } + }, + { + mainHandler.post { callback?.invoke(false) } } + ) + } + } + + override fun delete(identifier: FileDownloadIdentifier, callback: ((Boolean) -> Unit)?) { + val mainHandler = Handler(Looper.getMainLooper()) + + handler.post { + enabledNotificationsManager.getDownload(identifier.get()) { d1 -> + if (d1 != null) { + enabledNotificationsManager.cancel(d1.id) + enabledNotificationsManager.delete(d1.id) + enabledNotificationsManager.remove(d1.id) + mainHandler.post { + callback?.invoke(true) + } + } else { + disabledNotificationsManager.getDownload(identifier.get()) { d2 -> + if (d2 != null) { + disabledNotificationsManager.cancel(d2.id) + disabledNotificationsManager.delete(d2.id) + disabledNotificationsManager.remove(d2.id) + mainHandler.post { + callback?.invoke(true) + } + } else { + mainHandler.post { + callback?.invoke(false) + } + } + } + } } } } - override fun status(identifier: FileDownloadIdentifier, callback: (DownloadStatus?) -> Unit) { - disabledNotificationsManager.getDownload(identifier.id) { a -> - enabledNotificationsManager.getDownload(identifier.id) { b -> - callback( - getDownloadStatus(a ?: b) - ) + override fun listen( + identifier: FileDownloadIdentifier, + listener: ((DownloadStatus) -> Unit)? + ) { + val mainHandler = Handler(Looper.getMainLooper()) + + listeners[identifier.get()] = listener + + handler.post { + disabledNotificationsManager.getDownload(identifier.get()) { a -> + enabledNotificationsManager.getDownload(identifier.get()) { b -> + mainHandler.post { + listener?.invoke( + getDownloadStatus(a ?: b) + ) + } + } + } + } + } + + override fun getDownloads( + storage: Storage, + callback: (Map>) -> Unit + ) { + val mainHandler = Handler(Looper.getMainLooper()) + + handler.post { + disabledNotificationsManager.getDownloadsWithStatus(Status.COMPLETED) { a -> + enabledNotificationsManager.getDownloadsWithStatus(Status.COMPLETED) { b -> + mainHandler.post { + callback.invoke( + (a + b) + .filter { it.file.contains(storage.file.absolutePath) } + .associate { + Pair( + FileDownloadIdentifier(it.id), + getDownloadStatus(it) to Gson().fromJson( + it.extras.getString( + REQUEST_EXTRA_CATEGORY, + "" + ), + DownloadCategory::class.java + ) + ) + } + ) + } + } } } } @@ -210,7 +407,7 @@ object FileDownloadHandler : DownloadHandler DownloadStatus.State.PENDING - Status.FAILED -> DownloadStatus.State.FAILED - Status.COMPLETED -> DownloadStatus.State.SUCCESSFUL - Status.CANCELLED -> DownloadStatus.State.CANCELLED + Status.FAILED -> DownloadStatus.State.DELETED + Status.COMPLETED -> DownloadStatus.State.DOWNLOADED + Status.CANCELLED -> DownloadStatus.State.DELETED Status.DOWNLOADING, Status.PAUSED -> DownloadStatus.State.RUNNING else -> throw Exception() - } + }, + download.error.throwable ) } catch (e: Exception) { - null + DownloadStatus(null, null, DownloadStatus.State.DELETED, null) } } } diff --git a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadIdentifier.kt b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadIdentifier.kt index 164cddeb1..9ce59c658 100644 --- a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadIdentifier.kt +++ b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadIdentifier.kt @@ -2,6 +2,19 @@ package de.xikolo.download.filedownload import de.xikolo.download.DownloadIdentifier +/** + * The DownloadIdentifier class for file downloads. + * + * @param id The internal download identifier supplied by Fetch. + */ data class FileDownloadIdentifier( - val id: Int -) : DownloadIdentifier + private val id: Int +) : DownloadIdentifier { + + /** + * Returns the internal identifier. + */ + fun get(): Int { + return id + } +} diff --git a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadItem.kt b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadItem.kt index 6c93fdce6..204d10c84 100644 --- a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadItem.kt @@ -1,83 +1,44 @@ package de.xikolo.download.filedownload -import android.util.Log import androidx.fragment.app.FragmentActivity import de.xikolo.App -import de.xikolo.download.DownloadItem +import de.xikolo.download.DownloadCategory +import de.xikolo.download.DownloadItemImpl import de.xikolo.download.DownloadStatus -import de.xikolo.extensions.observeOnce -import de.xikolo.managers.PermissionManager -import de.xikolo.models.DownloadAsset import de.xikolo.models.Storage -import de.xikolo.states.PermissionStateLiveData -import de.xikolo.utils.LanalyticsUtil -import de.xikolo.utils.extensions.buildWriteErrorMessage -import de.xikolo.utils.extensions.createIfNotExists import de.xikolo.utils.extensions.internalStorage import de.xikolo.utils.extensions.open import de.xikolo.utils.extensions.preferredStorage import de.xikolo.utils.extensions.sdcardStorage -import de.xikolo.utils.extensions.showToast import java.io.File +/** + * DownloadItem class for file downloads. + * + * @param url The URL of the file to download. + * @param category The download category. + * @param fileName The file name for the download. + * @param storage The storage location for the download. + */ open class FileDownloadItem( val url: String?, + val category: DownloadCategory, open val fileName: String, - var storage: Storage = App.instance.preferredStorage -) : DownloadItem() { + storage: Storage = App.instance.preferredStorage +) : DownloadItemImpl(storage) { - companion object { - val TAG: String? = DownloadAsset::class.simpleName - } - - protected open fun getFileFolder(): String { - return storage.file.absolutePath - } - - val filePath: String - get() = getFileFolder() + File.separator + fileName - - protected open val size: Long = 0L - - protected open val mimeType = "application/pdf" - - protected open val showNotification = true - - protected open val secondaryDownloadItems = setOf() - - protected open val deleteSecondaryDownloadItemPredicate: (FileDownloadItem) -> Boolean = - { _ -> true } - - private var downloader: FileDownloadHandler = FileDownloadHandler - - private val request - get() = FileDownloadRequest( - url!!, - File("$filePath.tmp"), - title, - showNotification - ) - - private var downloadIdentifier: FileDownloadIdentifier? = null + final override val downloader = FileDownloadHandler - private fun getDownloadIdentifier(): FileDownloadIdentifier { - return downloadIdentifier ?: FileDownloadIdentifier(request.buildRequest().id) - } - - override val isDownloadable: Boolean + final override val downloadable: Boolean get() = url != null override val title: String get() = fileName - override val downloadSize: Long - get() { - var total = size - secondaryDownloadItems.forEach { total += it.downloadSize } - return total - } + override val size: Long + get() = download?.length() ?: 0L - override val download: File? + final override val download: File? get() { val originalStorage: Storage = storage @@ -88,9 +49,8 @@ open class FileDownloadItem( return internalFile } - val sdcardStorage: Storage? = App.instance.sdcardStorage - if (sdcardStorage != null) { - storage = sdcardStorage + App.instance.sdcardStorage?.let { + storage = it val sdcardFile = File(filePath) if (sdcardFile.exists() && sdcardFile.isFile) { storage = originalStorage @@ -107,191 +67,48 @@ open class FileDownloadItem( download?.open(activity, mimeType, false) } - override var stateListener: StateListener? = null - - override fun start(activity: FragmentActivity, callback: ((FileDownloadIdentifier?) -> Unit)?) { - performAction(activity) { - isDownloadRunning { isDownloadRunning -> - when { - isDownloadRunning || downloadExists -> callback?.invoke(null) - isDownloadable -> { - File( - filePath.substring( - 0, - filePath.lastIndexOf(File.separator) - ) - ).createIfNotExists() - - downloader.download( - request, - { status -> - when (status?.state) { - DownloadStatus.State.CANCELLED -> - cancel(activity) - DownloadStatus.State.SUCCESSFUL -> { - File("$filePath.tmp").renameTo(File(filePath)) - stateListener?.onCompleted() - } - DownloadStatus.State.FAILED -> - stateListener?.onCompleted() - } - }, - { identifier -> - if (identifier != null) { - downloadIdentifier = identifier - - if (this is DownloadAsset.Course.Item) { - LanalyticsUtil.trackDownloadedFile(this) - } - - secondaryDownloadItems.forEach { - it.start(activity) - } - - stateListener?.onStarted() - callback?.invoke(identifier) - } else { - callback?.invoke(null) - } - } - ) - } - else -> callback?.invoke(null) - } - } - } - } - - override fun cancel(activity: FragmentActivity, callback: ((Boolean) -> Unit)?) { - performAction(activity) { - downloader.cancel(getDownloadIdentifier()) { success -> - delete(activity) - secondaryDownloadItems.forEach { - it.cancel(activity) - } + final override val request + get() = FileDownloadRequest( + url!!, + File("$filePath.tmp"), + title, + showNotification, + category + ) - callback?.invoke(success) + override fun onStatusChanged(newStatus: DownloadStatus) { + when (newStatus.state) { + DownloadStatus.State.DOWNLOADED -> { + File("$filePath.tmp").renameTo(File(filePath)) } - } - } - - override fun delete(activity: FragmentActivity, callback: ((Boolean) -> Unit)?) { - performAction(activity) { - downloader.cancel(getDownloadIdentifier()) { - if (!downloadExists) { - File(filePath).parentFile?.let { - Storage(it).clean() - } - - stateListener?.onDeleted() - callback?.invoke(false) - } else { - if (download?.delete() == true) { - secondaryDownloadItems.forEach { - if (deleteSecondaryDownloadItemPredicate(it)) { - it.delete(activity) - } - } - } - - stateListener?.onDeleted() - callback?.invoke(true) - } + DownloadStatus.State.DELETED -> { + File(filePath).delete() } } + super.onStatusChanged(newStatus) } - override fun getProgress(callback: (Pair) -> Unit) { - status { - callback(it?.downloadedBytes to it?.totalBytes) - } - } - - override fun isDownloadRunning(callback: (Boolean) -> Unit) { - if (!isDownloadable) { - callback(false) - return - } - status { - callback( - if (it != null) { - it.state == DownloadStatus.State.RUNNING || - it.state == DownloadStatus.State.PENDING - } else false - ) - } + /** + * Returns the folder the file is stored in. + * This is an absolute path also based on [storage]. + */ + protected open fun getFileFolder(): String { + return storage.file.absolutePath } - private fun status(callback: (DownloadStatus?) -> Unit) { - var totalBytes = 0L - var writtenBytes = 0L - var state = DownloadStatus.State.SUCCESSFUL - - downloader.status(getDownloadIdentifier()) { mainDownload -> - if (mainDownload != null && mainDownload.totalBytes > 0L) { - totalBytes += mainDownload.totalBytes - writtenBytes += mainDownload.downloadedBytes - state = state.and(mainDownload.state) - } else { - totalBytes += size - } - - secondaryDownloadItems.forEach { - it.status { status -> - if (status != null) { - totalBytes += status.totalBytes - writtenBytes += status.downloadedBytes - state = state.and(status.state) - - callback( - DownloadStatus( - totalBytes, - writtenBytes, - state - ) - ) - } - } - } + /** + * The absolute file path of the download. + */ + val filePath: String + get() = getFileFolder() + File.separator + fileName - callback( - DownloadStatus( - totalBytes, - writtenBytes, - state - ) - ) - } - } + /** + * The MIME-type of the download. + */ + protected open val mimeType = "application/pdf" - private fun performAction(activity: FragmentActivity, action: () -> Unit): Boolean { - return if (storage.isWritable) { - if ( - PermissionManager(activity).requestPermission( - PermissionManager.WRITE_EXTERNAL_STORAGE - ) == 1 - ) { - action() - true - } else { - App.instance.state.permission.of( - PermissionManager.REQUEST_CODE_WRITE_EXTERNAL_STORAGE - ) - .observeOnce(activity) { state -> - return@observeOnce if ( - state == PermissionStateLiveData.PermissionStateCode.GRANTED - ) { - performAction(activity, action) - true - } else false - } - false - } - } else { - val msg = App.instance.buildWriteErrorMessage() - Log.w(TAG, msg) - activity.showToast(msg) - false - } - } + /** + * Whether to show a notification while downloading. + */ + protected open val showNotification = true } diff --git a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadRequest.kt b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadRequest.kt index c896d3a44..f06978204 100644 --- a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadRequest.kt +++ b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadRequest.kt @@ -1,64 +1,22 @@ package de.xikolo.download.filedownload -import androidx.core.net.toUri -import com.tonyodev.fetch2.NetworkType -import com.tonyodev.fetch2.Request -import com.tonyodev.fetch2core.Extras -import de.xikolo.App -import de.xikolo.R -import de.xikolo.config.Config +import de.xikolo.download.DownloadCategory import de.xikolo.download.DownloadRequest -import de.xikolo.managers.UserManager -import de.xikolo.storages.ApplicationPreferences import java.io.File -import java.util.Locale +/** + * DownloadRequest class for file downloads + * + * @param url The URL of the file to download. + * @param localFile The local File object to download to. + * @param title The title of the download. + * @param showNotification Whether to show a notification while downloading. + * @param category The download category. + */ data class FileDownloadRequest( val url: String, val localFile: File, override val title: String, - override val showNotification: Boolean -) : DownloadRequest { - - companion object { - const val REQUEST_EXTRA_TITLE = "title" - const val REQUEST_EXTRA_SHOW_NOTIFICATION = "showNotification" - } - - fun buildRequest(): Request { - return Request(url, localFile.toUri()).apply { - networkType = - if (ApplicationPreferences().isDownloadNetworkLimitedOnMobile) { - NetworkType.WIFI_ONLY - } else { - NetworkType.ALL - } - - extras = Extras( - mapOf( - REQUEST_EXTRA_TITLE to title, - REQUEST_EXTRA_SHOW_NOTIFICATION to showNotification.toString() - ) - ) - groupId = 0 - - addHeader(Config.HEADER_USER_AGENT, Config.HEADER_USER_AGENT_VALUE) - addHeader( - Config.HEADER_ACCEPT, - Config.MEDIA_TYPE_JSON_API + "; xikolo-version=" + Config.XIKOLO_API_VERSION - ) - addHeader(Config.HEADER_CONTENT_TYPE, Config.MEDIA_TYPE_JSON_API) - addHeader(Config.HEADER_USER_PLATFORM, Config.HEADER_USER_PLATFORM_VALUE) - addHeader(Config.HEADER_ACCEPT_LANGUAGE, Locale.getDefault().language) - - if (url.toUri().host == App.instance.getString(R.string.app_host) && - UserManager.isAuthorized - ) { - addHeader( - Config.HEADER_AUTH, - Config.HEADER_AUTH_VALUE_PREFIX_JSON_API + UserManager.token!! - ) - } - } - } -} + override val showNotification: Boolean, + override val category: DownloadCategory +) : DownloadRequest diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt new file mode 100644 index 000000000..5c3aaa059 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt @@ -0,0 +1,493 @@ +package de.xikolo.download.hlsvideodownload + +import android.content.Context +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.google.android.exoplayer2.DefaultRenderersFactory +import com.google.android.exoplayer2.Format +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.database.DatabaseProvider +import com.google.android.exoplayer2.database.ExoDatabaseProvider +import com.google.android.exoplayer2.offline.Download +import com.google.android.exoplayer2.offline.DownloadHelper +import com.google.android.exoplayer2.offline.DownloadManager +import com.google.android.exoplayer2.offline.DownloadService +import com.google.android.exoplayer2.scheduler.Requirements +import com.google.android.exoplayer2.source.hls.HlsManifest +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory +import com.google.android.exoplayer2.upstream.cache.Cache +import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor +import com.google.android.exoplayer2.upstream.cache.SimpleCache +import com.google.gson.Gson +import de.xikolo.App +import de.xikolo.config.Config +import de.xikolo.download.DownloadCategory +import de.xikolo.download.DownloadHandler +import de.xikolo.download.DownloadStatus +import de.xikolo.download.hlsvideodownload.services.HlsVideoDownloadForegroundService +import de.xikolo.download.hlsvideodownload.services.HlsVideoDownloadInternalStorageBackgroundService +import de.xikolo.download.hlsvideodownload.services.HlsVideoDownloadInternalStorageForegroundService +import de.xikolo.download.hlsvideodownload.services.HlsVideoDownloadSdcardStorageBackgroundService +import de.xikolo.download.hlsvideodownload.services.HlsVideoDownloadSdcardStorageForegroundService +import de.xikolo.models.Storage +import de.xikolo.storages.ApplicationPreferences +import de.xikolo.utils.NotificationUtil +import de.xikolo.utils.extensions.internalStorage +import de.xikolo.utils.extensions.sdcardStorage +import java.io.File +import java.io.IOException +import java.util.concurrent.Executors +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * DownloadHandler class for ExoPlayer HLS video downloads. + */ +object HlsVideoDownloadHandler : + DownloadHandler { + + val TAG: String = HlsVideoDownloadHandler::class.java.simpleName + + private val context: Context + get() = App.instance + + private val downloads: MutableMap = mutableMapOf() + private val listeners: MutableMap Unit)?> = + mutableMapOf() + + private var databaseProvider: DatabaseProvider? = null + + private var internalCache: Cache? = null + private var sdcardCache: Cache? = null + private var managers: MutableMap = mutableMapOf() + + private var notifierThreadsRunning: MutableMap = mutableMapOf() + + val dataSourceFactory = DefaultHttpDataSourceFactory(Config.HEADER_USER_AGENT_VALUE) + + init { + Log.i(TAG, "Starting $TAG") + DownloadService.startForeground( + context, + HlsVideoDownloadForegroundService::class.java + ) + } + + fun getDatabaseProvider(context: Context): DatabaseProvider { + return databaseProvider ?: synchronized(this) { + databaseProvider ?: ExoDatabaseProvider(context).also { + databaseProvider = it + } + } + } + + fun getInternalStorageCache(context: Context): Cache { + return internalCache ?: synchronized(this) { + internalCache ?: SimpleCache( + File(context.internalStorage.file.absolutePath + File.separator + "Videos"), + NoOpCacheEvictor(), + getDatabaseProvider(context) + ).also { + internalCache = it + } + } + } + + fun getSdcardStorageCache(context: Context): Cache? { + return context.sdcardStorage?.let { + sdcardCache ?: synchronized(this) { + sdcardCache ?: SimpleCache( + File(it.file.absolutePath + File.separator + "Videos"), + NoOpCacheEvictor(), + getDatabaseProvider(context) + ).also { + sdcardCache = it + } + } + } + } + + fun getManager(context: Context, cache: Cache): DownloadManager { + return ( + managers[cache.uid] + ?: synchronized(this) { + managers[cache.uid] ?: DownloadManager( + context, + getDatabaseProvider(context), + cache, + dataSourceFactory, + Executors.newCachedThreadPool() + ).apply { + maxParallelDownloads = 5 + minRetryCount = 1 + + addListener( + object : DownloadManager.Listener { + override fun onDownloadChanged( + downloadManager: DownloadManager, + download: Download, + finalException: java.lang.Exception? + ) { + val identifier = download.request.id + downloads[identifier] = download + notifyStatus(identifier) + + val args = ArgumentWrapper.decode(download.request.data) + if (download.state == Download.STATE_COMPLETED && + args.showNotification + ) { + NotificationUtil.getInstance(context) + .showDownloadCompletedNotification( + args.title + ) + } + + startNotifierThread(downloadManager) + } + + override fun onIdle(downloadManager: DownloadManager) { + stopNotifierThread(downloadManager) + } + + override fun onDownloadRemoved( + downloadManager: DownloadManager, + download: Download + ) { + Log.d( + TAG, + "Download successfully removed: ${download.request.id}" + ) + val identifier = download.request.id + val listener = listeners[identifier] + listeners.remove(identifier) + downloads.remove(identifier) + listener?.invoke( + getDownloadStatus(null) + ) + } + } + ) + }.also { + managers[cache.uid] = it + } + } + ) + .apply { + if (ApplicationPreferences().isDownloadNetworkLimitedOnMobile) { + requirements = Requirements(Requirements.NETWORK_UNMETERED) + } + } + } + + override fun isDownloadingAnything(callback: (Boolean) -> Unit) { + callback.invoke( + downloads.values.map { getDownloadStatus(it) }.any { + it.state == DownloadStatus.State.RUNNING || + it.state == DownloadStatus.State.PENDING + } + ) + } + + override fun identify(request: HlsVideoDownloadRequest): HlsVideoDownloadIdentifier { + return HlsVideoDownloadIdentifier(request.url, request.quality) + } + + override fun download(request: HlsVideoDownloadRequest, callback: ((Boolean) -> Unit)?) { + Log.i(TAG, "Received download request: ${request.url}") + val helper = DownloadHelper.forMediaItem( + context, + MediaItem.Builder().setUri(Uri.parse(request.url)).build(), + DefaultRenderersFactory(context), + dataSourceFactory + ) + helper.prepare( + object : DownloadHelper.Callback { + override fun onPrepared(helper: DownloadHelper) { + val manifest = helper.manifest as HlsManifest + + val formats = manifest.masterPlaylist.variants.map { it.format } + val lowestBitrate = formats.minOfOrNull { it.bitrate } ?: 0 + val highestBitrate = formats.maxOfOrNull { it.bitrate } ?: 0 + val targetBitrate = lowestBitrate + request.quality * + (highestBitrate - lowestBitrate) + val closestFormat = formats.minByOrNull { + abs(it.bitrate - targetBitrate).roundToInt() + } + val closestBitrate = closestFormat?.bitrate + + val estimatedSize = ( + closestFormat?.averageBitrate + ?.takeUnless { it == Format.NO_VALUE } + ?: closestBitrate + ) + ?.times(manifest.mediaPlaylist.durationUs) + ?.div(8000000) // to bytes and seconds + + val subtitles = manifest.masterPlaylist.subtitles + .mapNotNull { + it.format.language + } + .toTypedArray() + + helper.clearTrackSelections(0) + helper.addTrackSelection( + 0, + DefaultTrackSelector.ParametersBuilder(context) + .apply { + if (closestBitrate != null) { + setMinVideoBitrate(closestBitrate - 1) + setMaxVideoBitrate(closestBitrate + 1) + } + } + .build() + ) + helper.addTextLanguagesToSelection( + true, + *subtitles + ) + + val downloadRequest = helper.getDownloadRequest( + identify(request).get(), + ArgumentWrapper( + request.title, + request.showNotification, + request.category, + estimatedSize + ).encode() + ) + downloadRequest.customCacheKey + helper.release() + + val identifier = downloadRequest.id + downloads[identifier] = Download( + downloadRequest, + Download.STATE_QUEUED, + 0, + 0, + -1, + Download.STOP_REASON_NONE, + Download.FAILURE_REASON_NONE + ) + try { + val service = + if (request.storage == context.internalStorage) { + Log.i( + TAG, + "Starting downloading to internal storage: " + + "${request.url} aka $identifier" + ) + HlsVideoDownloadInternalStorageForegroundService::class.java + } else if (request.storage == context.sdcardStorage && + getSdcardStorageCache(context) != null + ) { + Log.i( + TAG, + "Starting downloading to sdcard storage: " + + "${request.url} aka $identifier" + ) + HlsVideoDownloadSdcardStorageForegroundService::class.java + } else { + throw Exception("Error during storage selection") + } + + DownloadService.sendAddDownload( + context, + service, + downloadRequest, + true + ) + + callback?.invoke(true) + } catch (e: Exception) { + Log.e( + TAG, + "Starting downloading failed: ${request.url} aka $identifier ($e)" + ) + downloads[identifier] = Download( + downloadRequest, + Download.STATE_FAILED, + 0, + 0, + -1, + Download.STOP_REASON_NONE, + Download.FAILURE_REASON_UNKNOWN + ) + notifyStatus(identifier) + callback?.invoke(false) + } + } + + override fun onPrepareError(helper: DownloadHelper, e: IOException) { + Log.e( + TAG, + "Starting downloading failed in helper preparation: ${request.url} ($e)" + ) + callback?.invoke(false) + helper.release() + } + } + ) + } + + override fun delete(identifier: HlsVideoDownloadIdentifier, callback: ((Boolean) -> Unit)?) { + Log.i(TAG, "Received deletion request: $identifier") + DownloadService.sendRemoveDownload( + context, + HlsVideoDownloadInternalStorageBackgroundService::class.java, + identifier.get(), + false + ) + + if (getSdcardStorageCache(context) != null) { + DownloadService.sendRemoveDownload( + context, + HlsVideoDownloadSdcardStorageBackgroundService::class.java, + identifier.get(), + false + ) + } + + callback?.invoke( + true + ) + } + + override fun listen( + identifier: HlsVideoDownloadIdentifier, + listener: ((DownloadStatus) -> Unit)? + ) { + Log.i(TAG, "Registering listener $listener for $identifier") + listeners[identifier.get()] = listener + listener?.invoke( + getDownloadStatus( + getManager(context, getInternalStorageCache(context)) + .downloadIndex.getDownload(identifier.get()) + ?: getSdcardStorageCache(context)?.let { sdcardCache -> + getManager(context, sdcardCache) + .downloadIndex.getDownload(identifier.get()) + } + ) + ) + } + + override fun getDownloads( + storage: Storage, + callback: (Map>) -> Unit + ) { + Log.i( + TAG, + "Querying all downloads in ${storage.file.absolutePath}" + ) + val sdcardStorageCache = getSdcardStorageCache(context) + val cache = + if (storage == context.internalStorage) { + getInternalStorageCache(context) + } else if (storage == context.sdcardStorage && sdcardStorageCache != null) { + sdcardStorageCache + } else { + Log.w(TAG, "Storage ${storage.file.absolutePath} not supported") + callback.invoke(mapOf()) + return + } + + callback.invoke( + getManager(context, cache).downloadIndex.getDownloads(Download.STATE_COMPLETED).let { + val map = mutableMapOf>() + it.moveToFirst() + while (!it.isAfterLast) { + map[ + HlsVideoDownloadIdentifier.from(it.download.request.id) + ] = getDownloadStatus(it.download) to ArgumentWrapper + .decode(it.download.request.data).category + it.moveToNext() + } + map + } + ) + } + + private fun getDownloadStatus(download: Download?): DownloadStatus { + if (download == null) { + Log.w( + TAG, + "getDownloadStatus(): Download not found, default status is generated: " + + "${download?.request?.id}" + ) + return DownloadStatus(null, null, DownloadStatus.State.DELETED, null) + } + + val totalSize = download.contentLength.takeUnless { it <= 0 } + ?: if (download.state == Download.STATE_COMPLETED) { + download.bytesDownloaded + } else { + ArgumentWrapper.decode(download.request.data).estimatedSize + ?: download.bytesDownloaded * 100 / download.percentDownloaded + }.toLong() + val state = when (download.state) { + Download.STATE_QUEUED, + Download.STATE_RESTARTING, + Download.STATE_REMOVING -> DownloadStatus.State.PENDING + Download.STATE_DOWNLOADING -> DownloadStatus.State.RUNNING + Download.STATE_COMPLETED -> DownloadStatus.State.DOWNLOADED + else -> DownloadStatus.State.DELETED + } + val downloaded = if (state == DownloadStatus.State.DELETED) 0 else download.bytesDownloaded + val error = if (download.state == Download.STATE_FAILED) { + Exception("Download failed with reason ${download.failureReason}") + } else null + + Log.d( + TAG, + "getDownloadStatus(): Generated download status [${state.name}]" + + "$downloaded/$totalSize B (error: $error) for ${download.request.id}" + ) + return DownloadStatus(totalSize, downloaded, state, error) + } + + private fun notifyStatus(identifier: String) { + Log.d(TAG, "Notifying of new status: $identifier") + listeners[identifier]?.invoke( + getDownloadStatus(downloads[identifier]) + ) ?: Log.w(TAG, "Listener is null: $identifier") + } + + private fun startNotifierThread(manager: DownloadManager) { + if (notifierThreadsRunning[manager] == false) { + notifierThreadsRunning[manager] = true + val handler = Handler(Looper.getMainLooper()) + val runnable = object : Runnable { + override fun run() { + manager.currentDownloads.forEach { + notifyStatus(it.request.id) + } + if (notifierThreadsRunning[manager] == true) { + handler.postDelayed(this, 1000) + } + } + } + runnable.run() + } + } + + private fun stopNotifierThread(manager: DownloadManager) { + notifierThreadsRunning[manager] = false + } + + internal data class ArgumentWrapper( + val title: String, + val showNotification: Boolean, + val category: DownloadCategory, + val estimatedSize: Long? + ) { + companion object { + fun decode(data: ByteArray): ArgumentWrapper = + Gson().fromJson(data.toString(Charsets.UTF_8), ArgumentWrapper::class.java) + } + + fun encode(): ByteArray = Gson().toJson(this).toByteArray(Charsets.UTF_8) + } +} diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadIdentifier.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadIdentifier.kt new file mode 100644 index 000000000..ea79520d5 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadIdentifier.kt @@ -0,0 +1,35 @@ +package de.xikolo.download.hlsvideodownload + +import de.xikolo.download.DownloadIdentifier + +/** + * DownloadIdentifier class for HLS video downloads. + * + * @param url The HLS master playlist URL. + * @param quality The selected quality as in [VideoSettingsHelper.VideoQuality] bitratePercent. + */ +data class HlsVideoDownloadIdentifier( + private val url: String, + private val quality: Float +) : DownloadIdentifier { + + companion object { + + /** + * Constructs a HlsVideoDownloadIdentifier object from an internal identifier string. + * + * @param identifier The internal identifier. + */ + fun from(identifier: String): HlsVideoDownloadIdentifier { + val parts = identifier.split(";", limit = 2) + return HlsVideoDownloadIdentifier(parts[1], parts[0].toInt() / 100.0f) + } + } + + /** + * Returns the internal identifier. + */ + fun get(): String { + return "${(quality * 100).toInt()};$url" + } +} diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt new file mode 100644 index 000000000..2c38c18b6 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt @@ -0,0 +1,97 @@ +package de.xikolo.download.hlsvideodownload + +import android.net.Uri +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.offline.Download +import com.google.android.exoplayer2.source.SingleSampleMediaSource +import com.google.android.exoplayer2.source.hls.HlsMediaSource +import com.google.android.exoplayer2.upstream.cache.Cache +import com.google.android.exoplayer2.upstream.cache.CacheDataSource +import com.google.android.exoplayer2.util.MimeTypes +import de.xikolo.App +import de.xikolo.download.DownloadCategory +import de.xikolo.download.DownloadItemImpl +import de.xikolo.models.Storage +import de.xikolo.utils.extensions.internalStorage +import de.xikolo.utils.extensions.preferredStorage +import de.xikolo.utils.extensions.sdcardStorage + +open class HlsVideoDownloadItem( + val url: String?, + val category: DownloadCategory, + val quality: Float, + val subtitles: Map, + storage: Storage = App.instance.preferredStorage +) : DownloadItemImpl>, + HlsVideoDownloadIdentifier, HlsVideoDownloadRequest>(storage) { + + final override val downloader = HlsVideoDownloadHandler + + final override val downloadable: Boolean + get() = url != null + + override val title: String + get() = url ?: "" + + private fun getCache(storage: Storage): Cache { + return if (storage == context.sdcardStorage) { + HlsVideoDownloadHandler.getSdcardStorageCache(context)!! + } else { + HlsVideoDownloadHandler.getInternalStorageCache(context) + } + } + + private fun getIndexEntry(storage: Storage): Download? { + return HlsVideoDownloadHandler.getManager(context, getCache(storage)) + .downloadIndex + .getDownload( + identifier.get() + ) + } + + private fun getMediaSource(storage: Storage): + Pair>? { + return getIndexEntry(storage)?.let { indexEntry -> + HlsMediaSource.Factory( + CacheDataSource.Factory() + .setCache(getCache(storage)) + .setCacheWriteDataSinkFactory(null) + ).createMediaSource( + indexEntry.request.toMediaItem() + ) to subtitles.mapValues { (language, url) -> + SingleSampleMediaSource.Factory( + CacheDataSource.Factory() + .setCache(getCache(storage)) + .setCacheWriteDataSinkFactory(null) + ).createMediaSource( + MediaItem.Subtitle( + Uri.parse(url), // requires that the HLS playlist links to the API resource + MimeTypes.TEXT_VTT, + language, + C.SELECTION_FLAG_DEFAULT + ), + C.TIME_UNSET + ) + } + } + } + + override val size: Long + get() = getIndexEntry(storage)?.bytesDownloaded ?: 0L + + final override val download: Pair>? + get() = getMediaSource(context.internalStorage) + ?: context.sdcardStorage?.let { getMediaSource(it) } + + final override val request + get() = HlsVideoDownloadRequest( + url!!, + quality, + storage, + title, + true, + category + ) +} + diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt new file mode 100644 index 000000000..73baeff80 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt @@ -0,0 +1,29 @@ +package de.xikolo.download.hlsvideodownload + +import de.xikolo.download.DownloadCategory +import de.xikolo.download.DownloadRequest +import de.xikolo.models.Storage + +/** + * DownloadRequest class for HLS video downloads. + * Video, audio and subtitle tracks are downloaded. + * + * @param url The HLS master playlist URL. + * @param quality The desired percentage of the maximum available bitrate of the video as in + * [VideoSettingsHelper.VideoQuality.qualityFraction]. + * This does not necessarily need to correspond to the bitrates in the master playlist. + * Based on this parameter, the track with the closest calculated target bitrate in the master + * playlist is selected. + * @param storage The storage location for the download. + * @param title The title of the download. + * @param showNotification Whether to show a notification while downloading. + * @param category The download category. + */ +class HlsVideoDownloadRequest( + val url: String, + val quality: Float, + val storage: Storage, + override val title: String, + override val showNotification: Boolean, + override val category: DownloadCategory +) : DownloadRequest diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadBackgroundService.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadBackgroundService.kt new file mode 100644 index 000000000..86eb998e8 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadBackgroundService.kt @@ -0,0 +1,34 @@ +package de.xikolo.download.hlsvideodownload.services + +import android.app.Notification +import androidx.core.app.NotificationCompat +import com.google.android.exoplayer2.offline.Download +import com.google.android.exoplayer2.offline.DownloadManager +import com.google.android.exoplayer2.offline.DownloadService +import com.google.android.exoplayer2.scheduler.PlatformScheduler +import com.google.android.exoplayer2.upstream.cache.Cache +import de.xikolo.App +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadHandler + +abstract class HlsVideoDownloadBackgroundService : DownloadService( + FOREGROUND_NOTIFICATION_ID_NONE, + 0, + null, + 0, + 0 +) { + + abstract val cache: Cache + + override fun getDownloadManager(): DownloadManager { + return HlsVideoDownloadHandler.getManager(applicationContext, cache) + } + + override fun getForegroundNotification(downloads: MutableList): Notification { + return NotificationCompat.Builder(App.instance, "").build() + } + + override fun getScheduler(): PlatformScheduler? { + return null + } +} diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadForegroundService.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadForegroundService.kt new file mode 100644 index 000000000..7f4e874a1 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadForegroundService.kt @@ -0,0 +1,61 @@ +package de.xikolo.download.hlsvideodownload.services + +import android.app.Notification +import android.app.PendingIntent +import com.google.android.exoplayer2.offline.Download +import com.google.android.exoplayer2.offline.DownloadManager +import com.google.android.exoplayer2.offline.DownloadService +import com.google.android.exoplayer2.scheduler.PlatformScheduler +import com.google.android.exoplayer2.upstream.cache.Cache +import de.xikolo.R +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadHandler +import de.xikolo.utils.NotificationUtil + +abstract class HlsVideoDownloadForegroundService : DownloadService( + NotificationUtil.DOWNLOAD_RUNNING_NOTIFICATION_ID, + DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL, + NotificationUtil.DOWNLOADS_CHANNEL_ID, + R.string.notification_channel_downloads, + 0 +) { + + abstract val cache: Cache + + override fun getDownloadManager(): DownloadManager { + return HlsVideoDownloadHandler.getManager(applicationContext, cache) + } + + override fun getForegroundNotification(downloads: List): Notification { + val notificationUtil = NotificationUtil.getInstance(applicationContext) + val notifications = downloads.mapNotNull { + val args = HlsVideoDownloadHandler.ArgumentWrapper.decode(it.request.data) + if (args.showNotification && it.state != Download.STATE_REMOVING) { + args.hashCode() to notificationUtil.updateDownloadRunningNotification( + notificationUtil.getDownloadRunningNotification(), + args.title, + it.percentDownloaded.toInt(), + PendingIntent.getService( + this, + 0, + buildRemoveDownloadIntent( + applicationContext, + this::class.java, + it.request.id, + true + ), + PendingIntent.FLAG_UPDATE_CURRENT + ) + ).build() + } else null + } + + notifications.forEach { + notificationUtil.notify(it.first, it.second) + } + return notificationUtil.getDownloadRunningGroupNotification(this, downloads.size) + } + + override fun getScheduler(): PlatformScheduler? { + return null + } +} diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadInternalStorageBackgroundService.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadInternalStorageBackgroundService.kt new file mode 100644 index 000000000..6c7558ad4 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadInternalStorageBackgroundService.kt @@ -0,0 +1,10 @@ +package de.xikolo.download.hlsvideodownload.services + +import com.google.android.exoplayer2.upstream.cache.Cache +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadHandler + +class HlsVideoDownloadInternalStorageBackgroundService : HlsVideoDownloadBackgroundService() { + + override val cache: Cache + get() = HlsVideoDownloadHandler.getInternalStorageCache(applicationContext) +} diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadInternalStorageForegroundService.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadInternalStorageForegroundService.kt new file mode 100644 index 000000000..1cedb9267 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadInternalStorageForegroundService.kt @@ -0,0 +1,10 @@ +package de.xikolo.download.hlsvideodownload.services + +import com.google.android.exoplayer2.upstream.cache.Cache +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadHandler + +class HlsVideoDownloadInternalStorageForegroundService : HlsVideoDownloadForegroundService() { + + override val cache: Cache + get() = HlsVideoDownloadHandler.getInternalStorageCache(applicationContext) +} diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadSdcardStorageBackgroundService.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadSdcardStorageBackgroundService.kt new file mode 100644 index 000000000..0598c802f --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadSdcardStorageBackgroundService.kt @@ -0,0 +1,10 @@ +package de.xikolo.download.hlsvideodownload.services + +import com.google.android.exoplayer2.upstream.cache.Cache +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadHandler + +class HlsVideoDownloadSdcardStorageBackgroundService : HlsVideoDownloadBackgroundService() { + + override val cache: Cache + get() = HlsVideoDownloadHandler.getSdcardStorageCache(applicationContext)!! +} diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadSdcardStorageForegroundService.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadSdcardStorageForegroundService.kt new file mode 100644 index 000000000..69d6b9860 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadSdcardStorageForegroundService.kt @@ -0,0 +1,10 @@ +package de.xikolo.download.hlsvideodownload.services + +import com.google.android.exoplayer2.upstream.cache.Cache +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadHandler + +class HlsVideoDownloadSdcardStorageForegroundService : HlsVideoDownloadForegroundService() { + + override val cache: Cache + get() = HlsVideoDownloadHandler.getSdcardStorageCache(applicationContext)!! +} diff --git a/app/src/main/java/de/xikolo/models/DownloadAsset.kt b/app/src/main/java/de/xikolo/models/DownloadAsset.kt index 919a84717..87806ad78 100644 --- a/app/src/main/java/de/xikolo/models/DownloadAsset.kt +++ b/app/src/main/java/de/xikolo/models/DownloadAsset.kt @@ -2,7 +2,10 @@ package de.xikolo.models import de.xikolo.App import de.xikolo.R +import de.xikolo.controllers.helper.VideoSettingsHelper +import de.xikolo.download.DownloadCategory import de.xikolo.download.filedownload.FileDownloadItem +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadItem import de.xikolo.utils.extensions.asEscapedFileName import java.io.File @@ -13,6 +16,7 @@ object DownloadAsset { documentLocalization: DocumentLocalization ) : FileDownloadItem( documentLocalization.fileUrl, + DownloadCategory.Documents, documentLocalization.language + "_" + documentLocalization.revision + "_" + documentLocalization.id + ".pdf" @@ -29,7 +33,7 @@ object DownloadAsset { } sealed class Certificate(url: String?, fileName: String, val course: de.xikolo.models.Course) : - FileDownloadItem(url, fileName) { + FileDownloadItem(url, DownloadCategory.Certificates, fileName) { override fun getFileFolder(): String { return super.getFileFolder() + File.separator + "Certificates" + File.separator + @@ -63,7 +67,7 @@ object DownloadAsset { url: String?, override val fileName: String, val course: de.xikolo.models.Course - ) : FileDownloadItem(url, fileName) { + ) : FileDownloadItem(url, DownloadCategory.Course(course.id), fileName) { override fun getFileFolder(): String { return super.getFileFolder() + File.separator + "Courses" + File.separator + @@ -93,40 +97,21 @@ object DownloadAsset { override val size = video.transcriptSize.toLong() } - class VideoSD(item: de.xikolo.models.Item, val video: Video) : - Item(video.streamToPlay?.sdUrl, "video_sd_${item.id}.mp4", item) { - override val title = "Video (SD): " + item.title - override val mimeType = "video/mp4" - override val size = video.streamToPlay?.sdSize?.toLong() ?: 0L - - override val secondaryDownloadItems: Set - get() { - return video.subtitles.map { - Subtitles(it, item) - }.toSet() + class VideoHLS( + item: de.xikolo.models.Item, + video: Video, + quality: VideoSettingsHelper.VideoQuality + ) : + HlsVideoDownloadItem( + video.streamToPlay?.hlsUrl, + DownloadCategory.Course(item.courseId), + quality.qualityFraction, + video.subtitles.associate { + it.language to it.vttUrl } - override val deleteSecondaryDownloadItemPredicate: (FileDownloadItem) -> Boolean = - { _ -> - !VideoSD(item, video).downloadExists && !VideoHD(item, video).downloadExists - } - } + ) { - class VideoHD(item: de.xikolo.models.Item, val video: Video) : - Item(video.streamToPlay?.hdUrl, "video_hd_${item.id}.mp4", item) { - override val title = "Video (HD): " + item.title - override val mimeType = "video/mp4" - override val size = video.streamToPlay?.hdSize?.toLong() ?: 0L - - override val secondaryDownloadItems: Set - get() { - return video.subtitles.map { - Subtitles(it, item) - }.toSet() - } - override val deleteSecondaryDownloadItemPredicate: (FileDownloadItem) -> Boolean = - { _ -> - !VideoSD(item, video).downloadExists && !VideoHD(item, video).downloadExists - } + override val title = App.instance.getString(R.string.video) + ": " + item.title } class Audio(item: de.xikolo.models.Item, video: Video) : @@ -135,19 +120,6 @@ object DownloadAsset { override val mimeType = "audio/mpeg" override val size = video.audioSize.toLong() } - - class Subtitles(videoSubtitles: VideoSubtitles, item: de.xikolo.models.Item) : Item( - videoSubtitles.vttUrl, - "subtitles_${videoSubtitles.language}_${item.id}.vtt", - item - ) { - override fun getFileFolder(): String { - return super.getFileFolder() + File.separator + "Subtitles" - } - - override val showNotification = false - override val mimeType = "text/vtt" - } } } } diff --git a/app/src/main/java/de/xikolo/models/Storage.kt b/app/src/main/java/de/xikolo/models/Storage.kt index 9d52a76f1..320fb146a 100644 --- a/app/src/main/java/de/xikolo/models/Storage.kt +++ b/app/src/main/java/de/xikolo/models/Storage.kt @@ -1,11 +1,9 @@ package de.xikolo.models import android.os.Environment -import de.xikolo.utils.extensions.fileCount import java.io.File -import java.io.IOException -class Storage(val file: File) { +data class Storage(val file: File) { enum class Type { INTERNAL, SDCARD @@ -16,85 +14,4 @@ class Storage(val file: File) { val state = Environment.getExternalStorageState(file) return state == Environment.MEDIA_MOUNTED } - - // removes empty folder structures and temporary files as well as old item files - fun clean(file: File = this.file) { - if (file.isDirectory && file.listFiles() != null) { - val children = file.listFiles() - if (children != null) { - for (child in children) { - clean(child) - } - } - if (file.listFiles().isEmpty()) { - file.delete() - } - } else { - if (file.name.endsWith("slides.pdf") || - file.name.endsWith("transcript.pdf") || - file.name.endsWith("video_hd.mp4") || - file.name.endsWith("video_sd.mp4") || - file.name.endsWith("audio.mp3") || - file.name.endsWith(".tmp") - ) file.delete() - } - } - - // moves the contents of the folder 'from' to the folder 'to' - fun migrateTo(to: Storage?, callback: MigrationCallback) { - if (to == null) { - callback.onCompleted(false) - return - } - - Thread(Runnable { - if (file.exists() && file.listFiles() != null) { - callback.onProgressChanged(0) - val totalFiles = file.fileCount - var copiedFiles = 0 - for (file in file.listFiles()) { - copiedFiles += move( - file, - File(to.file.absolutePath + File.separator + file.name), - callback - ) - } - callback.onCompleted(copiedFiles == totalFiles) - } else { - callback.onCompleted(false) - } - }).start() - } - - private fun move(sourceFile: File, destFile: File, callback: MigrationCallback): Int { - var count = 0 - if (sourceFile.isDirectory && sourceFile.listFiles() != null) { - for (file in sourceFile.listFiles()) { - count += move( - file, - File(destFile.absolutePath + File.separator + file.name), - callback - ) - callback.onProgressChanged(count) - } - } else { - try { - destFile.mkdirs() - sourceFile.copyTo(destFile, true) - count++ - } catch (e: IOException) { - e.printStackTrace() - } - } - sourceFile.delete() - - return count - } - - interface MigrationCallback { - - fun onProgressChanged(count: Int) - - fun onCompleted(success: Boolean) - } } diff --git a/app/src/main/java/de/xikolo/models/Video.kt b/app/src/main/java/de/xikolo/models/Video.kt index 2ed639d53..949eec5d5 100644 --- a/app/src/main/java/de/xikolo/models/Video.kt +++ b/app/src/main/java/de/xikolo/models/Video.kt @@ -104,9 +104,19 @@ open class Video : RealmObject() { val streamToPlay: VideoStream? get() { - return if (singleStream != null && (singleStream?.hdUrl != null || singleStream?.sdUrl != null)) { + return if (singleStream != null && ( + singleStream?.hlsUrl != null || + singleStream?.hdUrl != null || + singleStream?.sdUrl != null + ) + ) { singleStream - } else if (lecturerStream != null && (lecturerStream?.hdUrl != null || lecturerStream?.sdUrl != null)) { + } else if (lecturerStream != null && ( + lecturerStream?.hlsUrl != null || + lecturerStream?.hdUrl != null || + lecturerStream?.sdUrl != null + ) + ) { lecturerStream } else slidesStream } diff --git a/app/src/main/java/de/xikolo/receivers/NotificationDeletedReceiver.kt b/app/src/main/java/de/xikolo/receivers/NotificationDeletedReceiver.kt index 6bf1edc44..da8efb07a 100644 --- a/app/src/main/java/de/xikolo/receivers/NotificationDeletedReceiver.kt +++ b/app/src/main/java/de/xikolo/receivers/NotificationDeletedReceiver.kt @@ -16,7 +16,7 @@ class NotificationDeletedReceiver : BroadcastReceiver() { val action = intent.action if (INTENT_ACTION_NOTIFICATION_DELETED == action) { - NotificationUtil.deleteDownloadNotificationsFromIntent(intent) + NotificationUtil.getInstance(context).deleteDownloadNotificationsFromIntent(intent) } } diff --git a/app/src/main/java/de/xikolo/states/base/LiveDataState.kt b/app/src/main/java/de/xikolo/states/base/LiveDataState.kt index 84c3a5fe1..eb393dd35 100644 --- a/app/src/main/java/de/xikolo/states/base/LiveDataState.kt +++ b/app/src/main/java/de/xikolo/states/base/LiveDataState.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -abstract class LiveDataState(initialState: T? = null) : LiveData() { +open class LiveDataState(initialState: T? = null) : LiveData() { init { initialState?.let { diff --git a/app/src/main/java/de/xikolo/storages/ApplicationPreferences.kt b/app/src/main/java/de/xikolo/storages/ApplicationPreferences.kt index 85017c07d..d5aa5218d 100644 --- a/app/src/main/java/de/xikolo/storages/ApplicationPreferences.kt +++ b/app/src/main/java/de/xikolo/storages/ApplicationPreferences.kt @@ -14,8 +14,11 @@ class ApplicationPreferences { private val context: Context = App.instance - var storage: String? - get() = getString(context.getString(R.string.preference_storage), context.getString(R.string.settings_default_value_storage)) + var storage: String + get() = getString( + context.getString(R.string.preference_storage), + context.getString(R.string.settings_default_value_storage) + ) set(value) = putString(context.getString(R.string.preference_storage), value) var isVideoQualityLimitedOnMobile: Boolean @@ -26,12 +29,30 @@ class ApplicationPreferences { get() = getBoolean(context.getString(R.string.preference_download_network)) set(value) = putBoolean(context.getString(R.string.preference_download_network), value) + var videoDownloadQuality: VideoSettingsHelper.VideoQuality + get() = VideoSettingsHelper.VideoQuality.get( + context, + getString( + context.getString(R.string.preference_video_download_quality), + context.getString(R.string.settings_default_value_video_download_quality) + ) + ) + set(value) = putString( + context.getString(R.string.preference_video_download_quality), + value.toString(context) + ) + var videoPlaybackSpeed: VideoSettingsHelper.PlaybackSpeed - get() = VideoSettingsHelper.PlaybackSpeed.get(getString( + get() = VideoSettingsHelper.PlaybackSpeed.get( + getString( + context.getString(R.string.preference_video_playback_speed), + context.getString(R.string.settings_default_value_video_playback_speed) + ) + ) + set(speed) = putString( context.getString(R.string.preference_video_playback_speed), - context.getString(R.string.settings_default_value_video_playback_speed) - )) - set(speed) = putString(context.getString(R.string.preference_video_playback_speed), speed.toString()) + speed.toString() + ) var videoSubtitlesLanguage: String? get() { @@ -63,21 +84,50 @@ class ApplicationPreferences { var confirmOpenExternalContentLti: Boolean get() = getBoolean(context.getString(R.string.preference_confirm_open_external_content_lti)) - set(value) = putBoolean(context.getString(R.string.preference_confirm_open_external_content_lti), value) + set(value) = putBoolean( + context.getString(R.string.preference_confirm_open_external_content_lti), + value + ) var confirmOpenExternalContentPeer: Boolean get() = getBoolean(context.getString(R.string.preference_confirm_open_external_content_peer)) - set(value) = putBoolean(context.getString(R.string.preference_confirm_open_external_content_peer), value) + set(value) = putBoolean( + context.getString(R.string.preference_confirm_open_external_content_peer), + value + ) + + var videoDownloadQualityHintShown: Boolean + get() = getBoolean( + context.getString(R.string.preference_video_download_quality_hint), + false + ) + set(value) = putBoolean( + context.getString(R.string.preference_video_download_quality_hint), + value + ) var firstAndroid4DeprecationWarningShown: Boolean - get() = getBoolean(context.getString(R.string.preference_first_android_4_deprecation_dialog), false) - set(value) = putBoolean(context.getString(R.string.preference_first_android_4_deprecation_dialog), value) + get() = getBoolean( + context.getString(R.string.preference_first_android_4_deprecation_dialog), + false + ) + set(value) = putBoolean( + context.getString(R.string.preference_first_android_4_deprecation_dialog), + value + ) var secondAndroid4DeprecationWarningShown: Boolean - get() = getBoolean(context.getString(R.string.preference_second_android_4_deprecation_dialog), false) - set(value) = putBoolean(context.getString(R.string.preference_second_android_4_deprecation_dialog), value) + get() = getBoolean( + context.getString(R.string.preference_second_android_4_deprecation_dialog), + false + ) + set(value) = putBoolean( + context.getString(R.string.preference_second_android_4_deprecation_dialog), + value + ) - private fun getBoolean(key: String, defValue: Boolean = true) = preferences.getBoolean(key, defValue) + private fun getBoolean(key: String, defValue: Boolean = true) = + preferences.getBoolean(key, defValue) private fun putBoolean(key: String, value: Boolean) { val editor = preferences.edit() @@ -85,7 +135,8 @@ class ApplicationPreferences { editor.apply() } - private fun getString(key: String, defValue: String? = null): String? = preferences.getString(key, defValue) + private fun getString(key: String, defValue: String): String = + preferences.getString(key, defValue) ?: defValue private fun putString(key: String, value: String?) { val editor = preferences.edit() diff --git a/app/src/main/java/de/xikolo/utils/LanalyticsUtil.kt b/app/src/main/java/de/xikolo/utils/LanalyticsUtil.kt index 6e2e57d1c..95a42ed54 100644 --- a/app/src/main/java/de/xikolo/utils/LanalyticsUtil.kt +++ b/app/src/main/java/de/xikolo/utils/LanalyticsUtil.kt @@ -134,12 +134,9 @@ object LanalyticsUtil { @JvmStatic fun trackDownloadedFile(itemDownloadAsset: DownloadAsset.Course.Item) { val verb: String = when (itemDownloadAsset) { - is DownloadAsset.Course.Item.VideoHD -> "DOWNLOADED_HD_VIDEO" - is DownloadAsset.Course.Item.VideoSD -> "DOWNLOADED_SD_VIDEO" is DownloadAsset.Course.Item.Slides -> "DOWNLOADED_SLIDES" is DownloadAsset.Course.Item.Transcript -> "DOWNLOADED_TRANSCRIPT" is DownloadAsset.Course.Item.Audio -> "DOWNLOADED_AUDIO" - is DownloadAsset.Course.Item.Subtitles -> "DOWNLOADED_SUBTITLES" } createEventBuilder() diff --git a/app/src/main/java/de/xikolo/utils/NotificationUtil.kt b/app/src/main/java/de/xikolo/utils/NotificationUtil.kt index 36203b26c..c1ce87a44 100644 --- a/app/src/main/java/de/xikolo/utils/NotificationUtil.kt +++ b/app/src/main/java/de/xikolo/utils/NotificationUtil.kt @@ -7,6 +7,7 @@ import android.app.PendingIntent import android.content.Context import android.content.ContextWrapper import android.content.Intent +import android.graphics.BitmapFactory import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.TaskStackBuilder @@ -14,43 +15,32 @@ import androidx.core.content.ContextCompat import de.xikolo.BuildConfig import de.xikolo.R import de.xikolo.controllers.downloads.DownloadsActivity +import de.xikolo.controllers.main.MainActivity import de.xikolo.receivers.NotificationDeletedReceiver import de.xikolo.storages.NotificationStorage -class NotificationUtil(base: Context) : ContextWrapper(base) { +class NotificationUtil private constructor(base: Context) : ContextWrapper(base) { companion object { const val DOWNLOADS_CHANNEL_ID = BuildConfig.APPLICATION_ID + ".downloads" const val DOWNLOAD_RUNNING_NOTIFICATION_ID = 1000 const val DOWNLOAD_COMPLETED_SUMMARY_NOTIFICATION_ID = 1001 + const val DOWNLOAD_RUNNING_NOTIFICATION_GROUP = "download_running" const val DOWNLOAD_COMPLETED_NOTIFICATION_GROUP = "download_completed" const val NOTIFICATION_DELETED_KEY_DOWNLOAD_TITLE = "key_notification_deleted_title" const val NOTIFICATION_DELETED_KEY_DOWNLOAD_ALL = "key_notification_deleted_all" - fun deleteDownloadNotificationsFromIntent(intent: Intent) { - val notificationStorage = NotificationStorage() + private var instance: NotificationUtil? = null - val title = intent.getStringExtra(NOTIFICATION_DELETED_KEY_DOWNLOAD_TITLE) - if (title != null) { - notificationStorage.deleteDownloadNotification(title) - } else if (intent.getStringExtra(NOTIFICATION_DELETED_KEY_DOWNLOAD_ALL) != null) { - notificationStorage.delete() + fun getInstance(context: Context): NotificationUtil = + instance ?: synchronized(this) { + instance ?: NotificationUtil(context).also { instance = it } } - } } - private var notificationManager: NotificationManager? = null - - private fun getManager(): NotificationManager? { - if (notificationManager == null) { - synchronized(NotificationUtil::class.java) { - if (notificationManager == null) { - notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - } - } - } - return notificationManager + private val manager: NotificationManager by lazy { + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } init { @@ -58,11 +48,89 @@ class NotificationUtil(base: Context) : ContextWrapper(base) { } fun notify(id: Int, notification: Notification?) { - getManager()?.notify(id, notification) + manager.notify(id, notification) } fun cancel(id: Int) { - getManager()?.cancel(id) + manager.cancel(id) + } + + fun deleteDownloadNotificationsFromIntent(intent: Intent) { + val notificationStorage = NotificationStorage() + + val title = intent.getStringExtra(NOTIFICATION_DELETED_KEY_DOWNLOAD_TITLE) + if (title != null) { + notificationStorage.deleteDownloadNotification(title) + } else if (intent.getStringExtra(NOTIFICATION_DELETED_KEY_DOWNLOAD_ALL) != null) { + notificationStorage.delete() + } + } + + private val notificationCountMap: MutableMap = mutableMapOf() + + fun getDownloadRunningGroupNotification(scope: Any, count: Int): Notification { + notificationCountMap[scope] = count + return NotificationCompat.Builder(this, DOWNLOADS_CHANNEL_ID) + .setGroupSummary(true) + .setGroup(DOWNLOAD_RUNNING_NOTIFICATION_GROUP) + .setColor(ContextCompat.getColor(this, R.color.apptheme_primary)) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setLargeIcon( + BitmapFactory.decodeResource( + resources, + android.R.drawable.stat_sys_download + ) + ) + .setContentText( + getString( + R.string.notification_multiple_downloads_running, + notificationCountMap.values.sum() + ) + ) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + fun getDownloadRunningNotification(): NotificationCompat.Builder { + return NotificationCompat.Builder(this, DOWNLOADS_CHANNEL_ID) + .setColor(ContextCompat.getColor(this, R.color.apptheme_primary)) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setLargeIcon( + BitmapFactory.decodeResource( + resources, + android.R.drawable.stat_sys_download + ) + ) + .setContentIntent( + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + 0 + ) + ) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setGroup(DOWNLOAD_RUNNING_NOTIFICATION_GROUP) + } + + fun updateDownloadRunningNotification( + notificationBuilder: NotificationCompat.Builder, + title: String, + progress: Int, + cancelIntent: PendingIntent + ): NotificationCompat.Builder { + return notificationBuilder + .setProgress( + 100, + progress, + false + ) + .addAction( + android.R.drawable.ic_menu_close_clear_cancel, + getString(R.string.notification_downloads_cancel), + cancelIntent + ) + .setContentTitle(title) } fun showDownloadCompletedNotification(title: String) { @@ -98,16 +166,25 @@ class NotificationUtil(base: Context) : ContextWrapper(base) { title ) ) + .setPriority(NotificationCompat.PRIORITY_LOW) } private fun showDownloadSummaryNotification(notifications: List) { - notify(DOWNLOAD_COMPLETED_SUMMARY_NOTIFICATION_ID, getDownloadSummaryNotification(notifications).build()) + notify( + DOWNLOAD_COMPLETED_SUMMARY_NOTIFICATION_ID, + getDownloadSummaryNotification(notifications).build() + ) } - private fun getDownloadSummaryNotification(notifications: List): NotificationCompat.Builder { + private fun getDownloadSummaryNotification( + notifications: List + ): NotificationCompat.Builder { val title: String if (notifications.size > 1) { - title = String.format(getString(R.string.notification_multiple_downloads_completed), notifications.size) + title = String.format( + getString(R.string.notification_multiple_downloads_completed), + notifications.size + ) } else { title = getString(R.string.notification_download_completed) } @@ -128,8 +205,19 @@ class NotificationUtil(base: Context) : ContextWrapper(base) { .setGroup(DOWNLOAD_COMPLETED_NOTIFICATION_GROUP) .setGroupSummary(true) .setStyle(inboxStyle) - .setContentIntent(createDownloadCompletedContentIntent(DownloadsActivity::class.java, NOTIFICATION_DELETED_KEY_DOWNLOAD_ALL, "true")) - .setDeleteIntent(createDownloadCompletedDeleteIntent(NOTIFICATION_DELETED_KEY_DOWNLOAD_ALL, "true")) + .setContentIntent( + createDownloadCompletedContentIntent( + DownloadsActivity::class.java, + NOTIFICATION_DELETED_KEY_DOWNLOAD_ALL, + "true" + ) + ) + .setDeleteIntent( + createDownloadCompletedDeleteIntent( + NOTIFICATION_DELETED_KEY_DOWNLOAD_ALL, + "true" + ) + ) } private fun createChannels() { @@ -141,11 +229,15 @@ class NotificationUtil(base: Context) : ContextWrapper(base) { ) downloadsChannel.setShowBadge(false) - getManager()?.createNotificationChannel(downloadsChannel) + manager.createNotificationChannel(downloadsChannel) } } - private fun createDownloadCompletedContentIntent(parentActivityClass: Class<*>, extraKey: String, extraValue: String): PendingIntent? { + private fun createDownloadCompletedContentIntent( + parentActivityClass: Class<*>, + extraKey: String, + extraValue: String + ): PendingIntent? { // Creates an explicit intent for an Activity in your app val resultIntent = Intent(this, parentActivityClass) @@ -166,11 +258,13 @@ class NotificationUtil(base: Context) : ContextWrapper(base) { ) } - private fun createDownloadCompletedDeleteIntent(extraKey: String, extraValue: String): PendingIntent { + private fun createDownloadCompletedDeleteIntent( + extraKey: String, + extraValue: String + ): PendingIntent { val deleteIntent = Intent(this, NotificationDeletedReceiver::class.java) deleteIntent.action = NotificationDeletedReceiver.INTENT_ACTION_NOTIFICATION_DELETED deleteIntent.putExtra(extraKey, extraValue) return PendingIntent.getBroadcast(this, 0, deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT) } - } diff --git a/app/src/main/java/de/xikolo/utils/extensions/CastExtensions.kt b/app/src/main/java/de/xikolo/utils/extensions/CastExtensions.kt index 1b31a197c..892811ad1 100644 --- a/app/src/main/java/de/xikolo/utils/extensions/CastExtensions.kt +++ b/app/src/main/java/de/xikolo/utils/extensions/CastExtensions.kt @@ -112,6 +112,13 @@ fun T.cast(activity: Activity, autoPlay: Boolean): PendingResult T.fileSize: Long return length } -val T.folderSize: Long - get() { - var length: Long = 0 - if (this != null && listFiles() != null) { - for (file in listFiles()) { - length += if (file.isFile) { - file.length() - } else { - file.folderSize - } - } - } - return length - } - val Long.asFormattedFileSize: String get() { if (this <= 0) - return "0" + return "0 MB" val units = arrayOf("B", "KB", "MB", "GB", "TB") val digitGroups = (log10(this.toDouble()) / log10(1024.0)).toInt() return DecimalFormat("#,##0.#").format(this / 1024.0.pow(digitGroups.toDouble())) + " " + units[digitGroups] } -val T.foldersWithFiles: List - get() { - val folders = ArrayList() - - if (isDirectory) { - val files = listFiles() ?: arrayOf() - for (file in files) { - if (file.isDirectory) { - folders.add(file.absolutePath) - } - } - } - - return folders - } - val T.fileCount: Int get() { var files = 0 @@ -78,25 +46,6 @@ val T.fileCount: Int return files } -fun T.deleteAll() { - if (isDirectory) { - if (list().isEmpty()) { - delete() - } else { - val files = list() - for (temp in files) { - val fileDelete = File(this, temp) - fileDelete.deleteAll() - } - if (list().isEmpty()) { - this.delete() - } - } - } else { - this.delete() - } -} - fun T.createIfNotExists() { var folder: File = this if (!exists()) { diff --git a/app/src/main/java/de/xikolo/utils/extensions/StorageExtensions.kt b/app/src/main/java/de/xikolo/utils/extensions/StorageExtensions.kt index 9b9024639..5e27cf983 100644 --- a/app/src/main/java/de/xikolo/utils/extensions/StorageExtensions.kt +++ b/app/src/main/java/de/xikolo/utils/extensions/StorageExtensions.kt @@ -49,19 +49,6 @@ val T.preferredStorage: Storage } } -fun T.buildMigrationMessage(from: Storage.Type): String { - val currentStorage = storagePreference - var current = getString(R.string.settings_title_storage_internal) - if (currentStorage == Storage.Type.INTERNAL) - current = getString(R.string.settings_title_storage_external) - - var destination = getString(R.string.settings_title_storage_external) - if (from == Storage.Type.SDCARD) - destination = getString(R.string.settings_title_storage_internal) - - return getString(R.string.dialog_storage_migration, current, destination) -} - fun T.buildWriteErrorMessage(): String { var storage = getString(R.string.settings_title_storage_internal) if (storagePreference == Storage.Type.SDCARD) @@ -71,5 +58,5 @@ fun T.buildWriteErrorMessage(): String { private val storagePreference: Storage.Type get() { - return ApplicationPreferences().storage!!.asStorageType + return ApplicationPreferences().storage.asStorageType } diff --git a/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt b/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt index 9c3d94b49..3dc294c09 100644 --- a/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt +++ b/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt @@ -11,14 +11,13 @@ import com.google.android.exoplayer2.ExoPlaybackException import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.PlaybackParameters import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.Renderer import com.google.android.exoplayer2.SimpleExoPlayer -import com.google.android.exoplayer2.Timeline import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.MergingMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.SingleSampleMediaSource import com.google.android.exoplayer2.source.TrackGroupArray +import com.google.android.exoplayer2.source.hls.HlsManifest import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection import com.google.android.exoplayer2.trackselection.DefaultTrackSelector @@ -30,6 +29,8 @@ import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory import com.google.android.exoplayer2.util.MimeTypes import com.google.android.exoplayer2.util.Util import com.google.android.exoplayer2.video.VideoListener +import kotlin.math.abs +import kotlin.math.roundToInt open class ExoPlayerVideoView : PlayerView { @@ -37,8 +38,10 @@ open class ExoPlayerVideoView : PlayerView { private lateinit var exoplayer: SimpleExoPlayer private lateinit var dataSourceFactory: DefaultDataSourceFactory private lateinit var bandwidthMeter: DefaultBandwidthMeter + private lateinit var trackSelector: DefaultTrackSelector private var videoMediaSource: MediaSource? = null + private var subtitleMediaSources: Map? = null private var mergedMediaSource: MediaSource? = null private var mediaMetadataRetriever: MediaMetadataRetriever? = null @@ -57,9 +60,6 @@ open class ExoPlayerVideoView : PlayerView { var aspectRatio: Float = 16.0f / 9.0f - var uri: Uri? = null - private set - var previewAvailable = false private set @@ -94,15 +94,14 @@ open class ExoPlayerVideoView : PlayerView { Util.getUserAgent(playerContext, playerContext.packageName), bandwidthMeter ) + trackSelector = DefaultTrackSelector( + context, + AdaptiveTrackSelection.Factory() + ) - exoplayer = SimpleExoPlayer.Builder( - context - ).setTrackSelector( - DefaultTrackSelector( - context, - AdaptiveTrackSelection.Factory() - ) - ).build() + exoplayer = SimpleExoPlayer.Builder(context) + .setTrackSelector(trackSelector) + .build() exoplayer.addListener( object : Player.EventListener { @@ -155,9 +154,6 @@ open class ExoPlayerVideoView : PlayerView { override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { } - - override fun onTimelineChanged(timeline: Timeline, reason: Int) { - } } ) @@ -198,24 +194,51 @@ open class ExoPlayerVideoView : PlayerView { ) } - fun setVideoURI(uri: Uri, isHls: Boolean) { - this.uri = uri - videoMediaSource = - if (isHls) { - HlsMediaSource.Factory(dataSourceFactory) - .setAllowChunklessPreparation(true) - .createMediaSource( - MediaItem.fromUri(uri) - ) - } else { - ProgressiveMediaSource.Factory(dataSourceFactory) + fun setProgressiveVideoUri(uri: Uri) { + setVideoSource( + ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource( + MediaItem.fromUri(uri) + ) + ) + } + + fun setHLSVideoUri(uri: Uri) { + setVideoSource( + HlsMediaSource.Factory(dataSourceFactory) + .setAllowChunklessPreparation(true) + .createMediaSource( + MediaItem.fromUri(uri) + ) + ) + } + + fun setSubtitleUris(uris: Map) { + setSubtitleSources( + uris.entries.associate { + it.key to SingleSampleMediaSource.Factory(dataSourceFactory) .createMediaSource( - MediaItem.fromUri(uri) + MediaItem.Subtitle( + it.value, + MimeTypes.TEXT_VTT, + it.key, + C.SELECTION_FLAG_DEFAULT + ), + C.TIME_UNSET ) } + ) + } + + fun setVideoSource(source: MediaSource) { + videoMediaSource = source mergedMediaSource = videoMediaSource } + fun setSubtitleSources(sources: Map) { + subtitleMediaSources = sources + } + fun setPreviewUri(uri: Uri) { previewPrepareThread = Thread { mediaMetadataRetriever = MediaMetadataRetriever() @@ -238,26 +261,19 @@ open class ExoPlayerVideoView : PlayerView { false } } + } finally { + mediaMetadataRetriever?.release() } } previewPrepareThread.start() } - fun showSubtitles(uri: String, language: String) { - val subtitleMediaSource = SingleSampleMediaSource.Factory(dataSourceFactory) - .createMediaSource( - MediaItem.Subtitle( - Uri.parse(uri), - MimeTypes.TEXT_VTT, - language, - C.SELECTION_FLAG_DEFAULT - ), - C.TIME_UNSET - ) - - mergedMediaSource = videoMediaSource?.let { - MergingMediaSource(it, subtitleMediaSource) - } + fun showSubtitles(language: String) { + mergedMediaSource = subtitleMediaSources?.get(language)?.let { subtitles -> + videoMediaSource?.let { video -> + MergingMediaSource(video, subtitles) + } + } ?: videoMediaSource } fun removeSubtitles() { @@ -271,14 +287,38 @@ open class ExoPlayerVideoView : PlayerView { return null } + fun setDesiredQuality(quality: Float?) { + trackSelector.setParameters( + DefaultTrackSelector.ParametersBuilder(context) + .apply { + if (quality != null) { + val manifest = exoplayer.currentManifest as? HlsManifest + val formats = manifest?.masterPlaylist?.variants?.map { it.format } + if (formats?.isNotEmpty() == true) { + val lowestBitrate = formats.minOf { it.bitrate } + val highestBitrate = formats.maxOf { it.bitrate } + val targetBitrate = lowestBitrate + quality * + (highestBitrate - lowestBitrate) + val closestBitrate = formats.minByOrNull { + abs(it.bitrate - targetBitrate).roundToInt() + }!!.bitrate + + setMaxVideoBitrate(closestBitrate + 1) + setMinVideoBitrate(closestBitrate - 1) + } + } + } + ) + } + fun scaleToFill() { - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM - exoplayer.videoScalingMode = Renderer.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + exoplayer.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL } fun scaleToFit() { + exoplayer.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT - exoplayer.videoScalingMode = Renderer.VIDEO_SCALING_MODE_SCALE_TO_FIT } fun prepare() { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1a214c682..8a31ede59 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -95,12 +95,6 @@ Bitte warten Sie während die App vorbereitet wird. - Im Moment sind Dateien in %1$s gespeichert, sollen diese nach %2$s verschoben werden? - Verschieben - Speichermigration - Verschieben der Dateien läuft. Bitte warten Sie bis dies abgeschlossen ist. Entfernen Sie auf keinen Fall das Speichermedium. - Vorgang erfolgreich abgeschlossen! - %1$s aktualisieren %1$s wird nach dem %2$s nur ausgeführt, wenn du die App aktualisierst. Aktualisieren @@ -146,6 +140,11 @@ Versuchen im Dateimanager zu öffnen Kein Dateimanager gefunden + Downloadqualität einstellen + Die standardmäßige Downloadqualität für Videos kann in den Einstellungen unter \"%1$s\" geändert werden. + Schließen + Einstellungen öffnen + Module @@ -171,8 +170,8 @@ Bitte QR Code scannen Präsentationsfolien als PDF Transcript als PDF - Video (HD) als MP4 - Video (SD) als MP4 + Video + Video (Qualität: %1$s) Herunterladen Öffnen Abspielen @@ -312,7 +311,7 @@ %1$s: %2$s frei. (aktuelles Speichermedium) - Mobile Datennutzung + Datennutzung Datennutzung für Videos beschränken HD Videos nur über WLAN streamen @@ -320,6 +319,14 @@ Datennutzung für Downloads beschränken Downloads nur über WLAN laden + Downloadqualität für Videos + Standardqualität: %s + + Niedrig + Mittel + Hoch + Beste + Video Wiedergabegeschwindigkeit von Videos diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 95e89a6a9..52888bfc0 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -95,12 +95,6 @@ Por favor, espere mientras se prepara la aplicación. - Actualmente hay archivos guardados en %1$s, ¿quiere moverlos a %2$s? - Mover - Mover almacenamiento - Moviendo. Por favor, espere hasta que se complete el proceso. No extraiga el medio de almacenamiento. - ¡El proceso se completó con éxito! - Actualizar %1$s %1$s ya no funcionará después de %2$s a menos que actualice la aplicación. Actualizar @@ -146,6 +140,11 @@ Intenta abrir con un gestor de archivos No se encontró ningún gestor de archivos + Cerrar + La calidad de la descarga de vídeo predeterminada puede cambiarse en los ajustes de \"%1$s\". + Descargar ajustes de calidad + Ir a la configuración + Módulo @@ -171,8 +170,8 @@ Por favor, escanee el código QR Las diapositivas de presentación como PDF La transcripción como PDF - Vídeo (HD) como MP4 - Vídeo (SD) como MP4 + Vídeo + Vídeo (Calidad: %1$s) Descargar Abrir Reproducir @@ -305,7 +304,7 @@ Confirmar antes de abrir ejercicios externos Confirmar antes de abrir evaluaciones entre pares - Uso de datos de móviles + Uso de datos Limitar el uso de datos para los videos Solo transmitir los videos HD con Wi-Fi @@ -313,6 +312,9 @@ Limitar el uso de datos para las descargas Solo descargar los archivos con Wi-Fi + Calidad de la descarga de video + Calidad predeterminada: %s + Video Velocidad de reproducción del video @@ -337,6 +339,11 @@ %1$s: %2$s gratis. (seleccionado actualmente) + Bajo + Medio + Alto + Mejor + No hay documentos disponibles para este curso No hay anuncios disponibles diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index baa079697..970ff6828 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -95,11 +95,6 @@ Veuillez patienter pendant que l\'application est en cours de préparation. - Migrer - Migration du contenu de la mémoire - Migration en cours. Veuillez attendre la fin du processus. Ne retirez pas le support de stockage. - Succès ! Migration terminée ! - Mettre à jour %1$s %1$s ne relancera %2$s plus l\'exécution à moins que vous ne mettiez l\'application à jour. Mettre à jour @@ -145,6 +140,11 @@ Essayez d\'ouvrir avec un gestionnaire de fichiers Aucun gestionnaire de fichiers trouvé + Fermer + La qualité de téléchargement vidéo par défaut peut être modifiée dans les paramètres sous \"%1$s\". + Télécharger les paramètres de qualité + Aller aux réglages + Module @@ -170,8 +170,8 @@ Veuillez numériser le code QR Diapositives de présentation en format PDF Transcription en format PDF - Vidéo (HD) en MP4 - Vidéo (SD) en MP4 + Vidéo + Vidéo (Qualité: %1$s) Télécharger Ouvrir Lire @@ -304,7 +304,7 @@ Confirmer avant d\'ouvrir les exercices externes Confirmer avant d\'ouvrir les évaluations par les pairs - Utilisation des données mobiles + Utilisation des données Limiter l\'utilisation des données pour les vidéos Autoriser la lecture des vidéos en HD uniquement sur le réseau Wi-Fi @@ -312,6 +312,9 @@ Limiter l\'utilisation des données pour les téléchargements Télécharger les fichiers uniquement sur le réseau Wi-Fi + Qualité du téléchargement des vidéos + Qualité par défaut: %s + Vidéo Vitesse de lecture @@ -336,6 +339,11 @@ %1$s : %2$slibre. (Actuellement sélectionné) + Faible + Moyen + Élevé + Meilleur + Pas de documents disponibles pour ce cours Pas d\'annonces disponibles diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 074a66b5e..dd2b909b8 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -95,12 +95,6 @@ Aguarde enquanto o aplicativo é preparado. - No momento há arquivos armazenados em %1$s, deseja movê-los para %2$s? - Migrar - Migração de armazenamento - Migração em andamento. Aguarde até o processo ser concluído. Não remova a meio de armazenamento. - Migração concluída com sucesso! - Atualização %1$s %1$s não funcionará depois de %2$s a menos que o aplicativo seja atualizado. Atualização @@ -146,6 +140,11 @@ Tente abrir com um gestor de ficheiros Não foi encontrado nenhum gestor de ficheiros + Fechar + A qualidade padrão de descarga de vídeo pode ser alterada nas definições em \"%1$s\". + Descarregar definições de qualidade + Ir para as configurações + Módulo @@ -171,8 +170,8 @@ Efetue a leitura do QR Code Apresentação de slides em PDF Transcrição em PDF - Vídeo (HD) em MP4 - Vídeo (SD) em MP4 + Vídeo + Vídeo (Qualidade: %1$s) Baixar Abrir Play @@ -305,7 +304,7 @@ Confirmar antes de abrir exercícios externos Confirmar antes de abrir as avaliações de pares - Uso de Dados Móveis + Uso de Dados Limitar o uso de dados para vídeos Transmitir apenas vídeos HD com Wi-Fi @@ -313,6 +312,9 @@ Limitar o uso de dados para downloads Baixar arquivos apenas com Wi-Fi + Qualidade do vídeo descarregado + Qualidade por defeito: %s + Vídeo Velocidade de reprodução de vídeo @@ -337,6 +339,11 @@ %1$s: %2$s livre. (Atualmente selecionado) + Baixo + Médio + Alto + Melhor + Nenhum documento disponível para este curso Nenhum comunicado disponível diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index b9219b296..5c2c616e2 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -95,12 +95,6 @@ 请稍等,正在准备应用程序。 - 当前有文件存储在%1$s,您是否要将其移动到%2$s? - 迁移 - 储存体迁移 - 迁移进行中。请等待过程结束。请勿移除该储存体媒介。 - 迁移成功! - 更新%1$s 除非您更新应用程序,否则%1$s将不\会在%2$s后运行。 更新 @@ -146,6 +140,11 @@ 尝试用文件管理器打开 没有找到文件管理器 + 关闭 + 默认的视频下载质量可以在 \"%1$s \"的设置中更改。123的设置中更改。 + 下载质量设置 + 进入设置 + 单元 @@ -171,8 +170,8 @@ 请扫二维码 PDF版课件 PDF版成绩单 - MP4视频(高清) - MP4视频(标清) + 视频 + 视频 (质量: %1$s) 下载 打开 播放 @@ -305,7 +304,7 @@ 开启外部练习前先确认 开启同行评估前先确认 - 移动数据使用 + 数据使用 限制视频的数据使用量 仅用Wi-Fi串流高清视频 @@ -313,6 +312,9 @@ 限制下载的数据使用量 仅用Wi-Fi下载档案 + 视频下载质量 + 默认质量: %s + 视频 视频播放速度 @@ -337,6 +339,11 @@ %1$s: %2$s 可用。 (当前选择) + + + + 最佳 + 没有此课程的文件 没有公告 diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index eeaef42ed..20cea95bd 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -2,8 +2,7 @@ - @string/video_hd_as_mp4 - @string/video_sd_as_mp4 + @string/video @string/slides_as_pdf @@ -13,6 +12,7 @@ confirm_delete video_quality download_network + video_download_quality video_playback_speed video_subtitles_language video_immersive @@ -24,6 +24,8 @@ category_video_playback_speed category_info + video_download_quality_hint + first_android_4_deprecation_dialog second_android_4_deprecation_dialog @@ -43,6 +45,24 @@ x2.0 + @string/settings_video_download_quality_high_value + + @string/settings_video_download_quality_low + @string/settings_video_download_quality_medium + @string/settings_video_download_quality_high + @string/settings_video_download_quality_best + + low + medium + high + best + + @string/settings_video_download_quality_low_value + @string/settings_video_download_quality_medium_value + @string/settings_video_download_quality_high_value + @string/settings_video_download_quality_best_value + + @string/settings_title_storage_internal @string/settings_title_storage_internal diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31b487097..5a652fef3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -96,12 +96,6 @@ Please wait while the app is being prepared. - Currently there are files stored in %1$s, do you want to move them to %2$s? - Migrate - Storage migration - Migration in progress. Please wait until the process is finished. Do not remove the storage medium. - Migration completed successfully! - Update %1$s %1$s won\'t run after %2$s anymore unless you update the app. Update @@ -147,6 +141,11 @@ Try to open with a file manager No file manager found + Download quality settings + The default video download quality can be changed in settings under \"%1$s\". + Close + Go to settings + Module @@ -172,8 +171,8 @@ Please scan the QR Code Presentation slides as PDF Transcript as PDF - Video (HD) as MP4 - Video (SD) as MP4 + Video + Video (Quality: %1$s) %1$s / %2$s Download Open @@ -311,7 +310,7 @@ Confirm before opening external exercises Confirm before opening peer assessments - Mobile Data Usage + Data Usage Limit data usage for videos Only stream HD videos on Wi-Fi @@ -319,6 +318,9 @@ Limit data usage for downloads Only download files on Wi-Fi + Video Download Quality + Default quality: %s + Video Video playback speed @@ -343,6 +345,11 @@ %1$s: %2$s free. (currently selected) + Low + Medium + High + Best + No documents available for this course No announcements available diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml index 5fd6a7fb2..d03d59332 100644 --- a/app/src/main/res/xml/settings.xml +++ b/app/src/main/res/xml/settings.xml @@ -25,6 +25,7 @@ android:entries="@array/settings_values_storages" android:entryValues="@array/settings_values_storages" android:key="@string/preference_storage" + android:summary="%s" android:title="@string/settings_title_storage" /> @@ -43,6 +44,14 @@ android:summary="@string/settings_summary_download_network" android:title="@string/settings_title_download_network" /> + +