From d43aa98dcece757739c3ff8e2e4ecef557337e34 Mon Sep 17 00:00:00 2001 From: Ben Engelhaupt Date: Thu, 14 Jan 2021 20:04:55 +0100 Subject: [PATCH 01/15] Merge master --- app/src/main/AndroidManifest.xml | 36 ++ app/src/main/java/de/xikolo/App.kt | 10 +- app/src/main/java/de/xikolo/config/Feature.kt | 3 - .../channels/ChannelCourseListAdapter.kt | 12 +- .../channels/ChannelDetailsActivity.kt | 5 +- .../controllers/course/DescriptionFragment.kt | 4 +- .../dialogs/ModuleDownloadDialog.kt | 14 +- .../dialogs/StorageMigrationDialog.kt | 41 -- .../controllers/downloads/DownloadsAdapter.kt | 70 ++-- .../downloads/DownloadsFragment.kt | 359 ++++++++++-------- .../controllers/helper/DownloadViewHelper.kt | 157 ++++---- .../helper/SectionDownloadHelper.kt | 63 +-- .../controllers/helper/VideoSettingsHelper.kt | 214 +++++++---- .../xikolo/controllers/main/SplashActivity.kt | 45 +-- .../section/VideoPreviewFragment.kt | 126 +++--- .../controllers/settings/SettingsFragment.kt | 97 +---- .../video/VideoItemPlayerFragment.kt | 168 +++++--- .../video/VideoStreamPlayerFragment.kt | 306 ++++++++------- .../de/xikolo/download/DownloadHandler.kt | 14 +- .../java/de/xikolo/download/DownloadItem.kt | 38 +- .../de/xikolo/download/DownloadItemImpl.kt | 131 +++++++ .../de/xikolo/download/DownloadRequest.kt | 4 + .../java/de/xikolo/download/DownloadStatus.kt | 52 ++- .../java/de/xikolo/download/Downloaders.kt | 64 ++++ .../filedownload/FileDownloadHandler.kt | 148 ++++++-- .../filedownload/FileDownloadIdentifier.kt | 9 +- .../download/filedownload/FileDownloadItem.kt | 271 ++----------- .../filedownload/FileDownloadRequest.kt | 17 +- .../HlsVideoDownloadHandler.kt | 348 +++++++++++++++++ .../HlsVideoDownloadIdentifier.kt | 20 + .../hlsvideodownload/HlsVideoDownloadItem.kt | 127 +++++++ .../HlsVideoDownloadRequest.kt | 102 +++++ .../HlsVideoDownloadBackgroundService.kt | 34 ++ .../HlsVideoDownloadForegroundService.kt | 62 +++ ...ownloadInternalStorageBackgroundService.kt | 10 + ...ownloadInternalStorageForegroundService.kt | 10 + ...oDownloadSdcardStorageBackgroundService.kt | 10 + ...oDownloadSdcardStorageForegroundService.kt | 10 + .../java/de/xikolo/models/DownloadAsset.kt | 67 +--- app/src/main/java/de/xikolo/models/Storage.kt | 85 +---- app/src/main/java/de/xikolo/models/Video.kt | 12 +- .../de/xikolo/states/base/LiveDataState.kt | 2 +- .../xikolo/storages/ApplicationPreferences.kt | 59 ++- .../java/de/xikolo/utils/LanalyticsUtil.kt | 3 - .../java/de/xikolo/utils/NotificationUtil.kt | 125 ++++-- .../xikolo/utils/extensions/CastExtensions.kt | 2 +- .../xikolo/utils/extensions/FileExtensions.kt | 53 +-- .../utils/extensions/StorageExtensions.kt | 15 +- .../de/xikolo/views/ExoPlayerVideoView.kt | 106 ++++-- app/src/main/res/values-de/strings.xml | 20 +- app/src/main/res/values-es/strings.xml | 20 +- app/src/main/res/values-fr/strings.xml | 19 +- app/src/main/res/values-pt/strings.xml | 20 +- app/src/main/res/values-zh/strings.xml | 20 +- app/src/main/res/values/keys.xml | 22 +- app/src/main/res/values/strings.xml | 20 +- app/src/main/res/xml/settings.xml | 9 + 57 files changed, 2431 insertions(+), 1459 deletions(-) delete mode 100644 app/src/main/java/de/xikolo/controllers/dialogs/StorageMigrationDialog.kt create mode 100644 app/src/main/java/de/xikolo/download/DownloadItemImpl.kt create mode 100644 app/src/main/java/de/xikolo/download/Downloaders.kt create mode 100644 app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt create mode 100644 app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadIdentifier.kt create mode 100644 app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt create mode 100644 app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt create mode 100644 app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadBackgroundService.kt create mode 100644 app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadForegroundService.kt create mode 100644 app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadInternalStorageBackgroundService.kt create mode 100644 app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadInternalStorageForegroundService.kt create mode 100644 app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadSdcardStorageBackgroundService.kt create mode 100644 app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadSdcardStorageForegroundService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 12e3be0da..7523cec31 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,6 +42,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + = 26 && App.instance.packageManager 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.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/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..f078ec7dc 100644 --- a/app/src/main/java/de/xikolo/controllers/downloads/DownloadsFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/downloads/DownloadsFragment.kt @@ -15,24 +15,26 @@ 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.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 + + const val CATEGORY_DOCUMENTS = "documents" + const val CATEGORY_CERTIFICATES = "certificates" } private var adapter: DownloadsAdapter? = null @@ -46,11 +48,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 +89,209 @@ 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 - - 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() + private fun buildItems( + internalStorageDownloads: Map>, + sdcardStorageDownloads: Map>? + ) { + var internalAddition = "" + var sdcardAddition = "" - 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 == CATEGORY_DOCUMENTS }, + { getString(R.string.settings_title_storage_internal) + internalAddition } + ).toMutableList() + .apply { + if (sdcardStorageDownloads != null) { + addAll( + buildCategories( + sdcardStorageDownloads, + { it == CATEGORY_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 == CATEGORY_CERTIFICATES }, + { getString(R.string.settings_title_storage_internal) + internalAddition } + ).toMutableList() + .apply { + if (sdcardStorageDownloads != null) { + addAll( + buildCategories( + sdcardStorageDownloads, + { it == CATEGORY_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 != CATEGORY_CERTIFICATES && it != CATEGORY_DOCUMENTS }, + { CourseDao.Unmanaged.find(it)?.title ?: "" } + ) + ) + + if (sdcardStorageDownloads != null) { + adapter?.addItem( + getString(R.string.courses) + + " (" + getString(R.string.settings_title_storage_external) + ")", + buildCategories( + sdcardStorageDownloads, + { it != CATEGORY_CERTIFICATES && it != CATEGORY_DOCUMENTS }, + { CourseDao.Unmanaged.find(it)?.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: (String) -> Boolean, + titleSelector: (String?) -> 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 +303,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..b08846794 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,32 @@ 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, + 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 @@ -106,8 +106,8 @@ class DownloadViewHelper( } } - buttonDownloadCancel.setOnClickListener { _ -> - download.cancel(activity) + buttonDownloadCancel.setOnClickListener { + download.delete(activity) showStartState() } @@ -116,17 +116,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 +143,8 @@ class DownloadViewHelper( textDescription.visibility = View.GONE } - if (!download.isDownloadable) { + if (!download.downloadable) { + showStartState() view.isEnabled = false buttonDownloadStart.isEnabled = false @@ -154,67 +155,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 +210,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 +251,40 @@ 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() - override fun onCompleted() { - if (download.downloadExists) { - showEndState() - } - } + val downloadedBytes = status.downloadedBytes ?: 0L + val totalBytes = status.totalBytes ?: 0L - override fun onDeleted() { - if (progressBarUpdaterRunning) { - showStartState() + 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 + ) } } + DownloadStatus.State.DOWNLOADED -> showEndState() + DownloadStatus.State.DELETED -> showStartState() } } - - 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..4ab0b5f35 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,60 @@ 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 +) { + + enum class VideoQuality(val bitrate: Int) { + LOW(200000), + MEDIUM(400000), + HIGH(5000000), + BEST(10000000); + + 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 +92,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 +102,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 +110,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 +136,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 +151,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 +166,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 +182,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 +195,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 +226,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 +242,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 +264,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 +300,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 +314,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 +349,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 +387,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 +399,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..500779d57 100644 --- a/app/src/main/java/de/xikolo/controllers/section/VideoPreviewFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/section/VideoPreviewFragment.kt @@ -11,17 +11,21 @@ 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.helper.DownloadViewHelper +import de.xikolo.controllers.helper.VideoSettingsHelper 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 +86,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 +152,93 @@ class VideoPreviewFragment : ViewModelFragment() { displayAvailableSubtitles() - linearLayoutDownloads.removeAllViews() - downloadViewHelpers.clear() + 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) - 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) - } + viewPlay.setOnClickListener { play() } - 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) - } + activity?.let { + updateDownloadViewHelpers(it, item, video) + } + } - if (video.slidesUrl != null) { - val dvh = DownloadViewHelper( - activity, - DownloadAsset.Course.Item.Slides(item, video), - activity.getText(R.string.slides_as_pdf) - ) - linearLayoutDownloads.addView(dvh.view) - downloadViewHelpers.add(dvh) + private fun updateDownloadViewHelpers(activity: FragmentActivity, item: Item, video: Video) { + linearLayoutDownloads.removeAllViews() + + 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.transcriptUrl != null) { - val dvh = DownloadViewHelper( + linearLayoutDownloads.addView( + DownloadViewHelper( activity, - DownloadAsset.Course.Item.Transcript(item, video), - activity.getText(R.string.transcript_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() } + ).view + ) + + (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() }, + 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..c8495c2ba 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,74 @@ 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)) { + playerView.setVideoSource(item.download!!) + item.subs?.let { + playerView.setSubtitleSources(it) + } + 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..18df44b75 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,115 @@ 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.setDesiredBitrate(null) + } + VideoSettingsHelper.PlaybackMode.LOW -> { + playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) + playerView.setDesiredBitrate(VideoSettingsHelper.VideoQuality.LOW.bitrate) + } + VideoSettingsHelper.PlaybackMode.MEDIUM -> { + playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) + playerView.setDesiredBitrate( + VideoSettingsHelper.VideoQuality.MEDIUM.bitrate + ) + } + VideoSettingsHelper.PlaybackMode.HIGH -> { + playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) + playerView.setDesiredBitrate(VideoSettingsHelper.VideoQuality.HIGH.bitrate) + } + VideoSettingsHelper.PlaybackMode.BEST -> { + playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) + playerView.setDesiredBitrate(VideoSettingsHelper.VideoQuality.BEST.bitrate) + } + 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 -> { warningContainer.visibility = View.VISIBLE warningText.text = getString(R.string.video_notification_no_offline_video) false @@ -593,13 +641,21 @@ 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() @@ -608,7 +664,10 @@ open class VideoStreamPlayerFragment : BaseFragment() { } } - 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() } @@ -767,7 +826,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,13 +835,14 @@ 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 { + // ToDo + /*playerView.uri?.let { playerView.setPreviewUri(it) - } + }*/ } else if (context.isOnline) { if (videoStream.sdUrl != null) { playerView.setPreviewUri(Uri.parse(videoStream.sdUrl)) @@ -795,27 +855,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 +868,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 +907,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 +918,4 @@ open class VideoStreamPlayerFragment : BaseFragment() { } } } - } diff --git a/app/src/main/java/de/xikolo/download/DownloadHandler.kt b/app/src/main/java/de/xikolo/download/DownloadHandler.kt index ca411e16b..5814ebb67 100644 --- a/app/src/main/java/de/xikolo/download/DownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/DownloadHandler.kt @@ -1,22 +1,28 @@ package de.xikolo.download +import de.xikolo.models.Storage + interface DownloadHandler { fun isDownloadingAnything(callback: (Boolean) -> Unit) fun download( request: R, - listener: ((DownloadStatus?) -> Unit)? = null, callback: ((I?) -> Unit)? = null ) - fun cancel( + fun delete( identifier: I, callback: ((Boolean) -> Unit)? = null ) - fun status( + fun listen( identifier: I, - callback: (DownloadStatus?) -> Unit + listener: ((DownloadStatus) -> Unit)? + ) + + fun getDownloads( + storage: Storage, + callback: (Map>) -> Unit ) } diff --git a/app/src/main/java/de/xikolo/download/DownloadItem.kt b/app/src/main/java/de/xikolo/download/DownloadItem.kt index 9035059d6..03f4ff15d 100644 --- a/app/src/main/java/de/xikolo/download/DownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/DownloadItem.kt @@ -2,41 +2,23 @@ package de.xikolo.download import androidx.fragment.app.FragmentActivity -abstract class DownloadItem { +interface DownloadItem { - abstract val isDownloadable: Boolean + val identifier: I - abstract val downloadSize: Long + val download: D? - abstract val title: String + val downloadable: Boolean - abstract val download: F? + val title: String - abstract val openAction: ((FragmentActivity) -> Unit)? + val openAction: ((FragmentActivity) -> Unit)? - abstract var stateListener: StateListener? + val size: Long - abstract fun start(activity: FragmentActivity, callback: ((I?) -> Unit)? = null) + val status: DownloadStatus.DownloadStatusLiveData - abstract fun cancel(activity: FragmentActivity, callback: ((Boolean) -> Unit)? = null) + fun start(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() - } + 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..03b64e41e --- /dev/null +++ b/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt @@ -0,0 +1,131 @@ +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 class DownloadItemImpl( + var storage: Storage +) : DownloadItem { + + companion object { + val TAG: String? = DownloadItemImpl::class.simpleName + } + + protected val context = App.instance + + abstract val downloader: DownloadHandler + + private var downloaderIdentifier: I? = null + final override val identifier: I + get() = downloaderIdentifier ?: itemIdentifier + + override val openAction: ((FragmentActivity) -> Unit)? = null + + abstract val request: R + + abstract val itemIdentifier: I + + private val statusCache: DownloadStatus.DownloadStatusLiveData by lazy { + DownloadStatus.DownloadStatusLiveData( + null, + null, + if (download != null) DownloadStatus.State.DOWNLOADED else DownloadStatus.State.DELETED + ) + } + + final override val status: DownloadStatus.DownloadStatusLiveData by lazy { + downloader.listen(identifier) { + onStatusChanged(it) + } + statusCache + } + + protected open fun onStatusChanged(newStatus: DownloadStatus) { + 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, + { identifier -> + if (identifier != null) { + downloaderIdentifier = identifier + + 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) + } + } + } + + 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..b778634d2 100644 --- a/app/src/main/java/de/xikolo/download/DownloadRequest.kt +++ b/app/src/main/java/de/xikolo/download/DownloadRequest.kt @@ -1,6 +1,10 @@ package de.xikolo.download interface DownloadRequest { + val title: String + val showNotification: Boolean + + val category: String? } diff --git a/app/src/main/java/de/xikolo/download/DownloadStatus.kt b/app/src/main/java/de/xikolo/download/DownloadStatus.kt index 3cb92b96f..a126c80c1 100644 --- a/app/src/main/java/de/xikolo/download/DownloadStatus.kt +++ b/app/src/main/java/de/xikolo/download/DownloadStatus.kt @@ -1,22 +1,60 @@ package de.xikolo.download -class DownloadStatus( - var totalBytes: Long, - var downloadedBytes: Long, +import androidx.lifecycle.LiveData + +data class DownloadStatus( + var totalBytes: Long?, + var downloadedBytes: Long?, var state: State ) { enum class State { - PENDING, RUNNING, SUCCESSFUL, CANCELLED, FAILED; + PENDING, RUNNING, DOWNLOADED, DELETED; // Determines what two states result in together. 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 } } } + + class DownloadStatusLiveData( + totalBytes: Long? = null, + downloadedBytes: Long? = null, + state: State = State.DELETED + ) : 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 } + } + + init { + super.setValue(DownloadStatus(totalBytes, downloadedBytes, state)) + } + } } 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..e28658313 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/Downloaders.kt @@ -0,0 +1,64 @@ +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 + +object Downloaders { + + private lateinit var fileDownloader: FileDownloadHandler + + private lateinit var hlsDownloader: HlsVideoDownloadHandler + + fun initialize() { + fileDownloader = FileDownloadHandler + hlsDownloader = HlsVideoDownloadHandler + } + + fun isDownloadingAnything(callback: (Boolean) -> Unit) { + fileDownloader.isDownloadingAnything { a -> + hlsDownloader.isDownloadingAnything { b -> + callback.invoke(a || b) + } + } + } + + fun getDownloads( + storage: Storage, + callback: (Map>) -> Unit + ) { + fileDownloader.getDownloads(storage) { a -> + hlsDownloader.getDownloads(storage) { b -> + callback.invoke(a + b) + } + } + } + + 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..a9ad4c216 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,8 @@ package de.xikolo.download.filedownload +import android.app.NotificationManager import android.content.Context +import androidx.core.app.NotificationCompat import com.tonyodev.fetch2.DefaultFetchNotificationManager import com.tonyodev.fetch2.Download import com.tonyodev.fetch2.DownloadNotification @@ -13,14 +15,20 @@ import com.tonyodev.fetch2core.DownloadBlock import de.xikolo.App import de.xikolo.download.DownloadHandler import de.xikolo.download.DownloadStatus +import de.xikolo.download.filedownload.FileDownloadRequest.Companion.REQUEST_EXTRA_CATEGORY import de.xikolo.download.filedownload.FileDownloadRequest.Companion.REQUEST_EXTRA_SHOW_NOTIFICATION import de.xikolo.download.filedownload.FileDownloadRequest.Companion.REQUEST_EXTRA_TITLE +import de.xikolo.models.Storage import de.xikolo.utils.NotificationUtil +import de.xikolo.utils.extensions.createIfNotExists +import java.io.File object FileDownloadHandler : DownloadHandler { + private val context = App.instance + private val disabledNotificationsConfiguration = - FetchConfiguration.Builder(App.instance) + FetchConfiguration.Builder(context) .setAutoRetryMaxAttempts(1) .setDownloadConcurrentLimit(5) .enableFileExistChecks(false) @@ -28,17 +36,64 @@ object FileDownloadHandler : DownloadHandler, + context: Context + ): Boolean { + val util = NotificationUtil(context) + if (downloadNotifications.isNotEmpty()) { + util.notify( + NotificationUtil.DOWNLOAD_RUNNING_NOTIFICATION_ID, + util.getDownloadRunningGroupNotification( + downloadNotifications.size + ) + ) + } else { + util.cancel(NotificationUtil.DOWNLOAD_RUNNING_NOTIFICATION_ID) + } + return false + } + + override fun updateNotification( + notificationBuilder: NotificationCompat.Builder, + downloadNotification: DownloadNotification, + context: Context + ) { + NotificationUtil(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 +101,19 @@ object FileDownloadHandler : DownloadHandler Unit)?> = mutableMapOf() + private val listeners: MutableMap Unit)?> = mutableMapOf() override fun isDownloadingAnything(callback: (Boolean) -> Unit) { disabledNotificationsManager.hasActiveDownloads(true) { a -> @@ -152,10 +210,10 @@ object FileDownloadHandler : DownloadHandler Unit)?, callback: ((FileDownloadIdentifier?) -> Unit)? ) { val req = request.buildRequest() + File(req.file).parentFile?.createIfNotExists() if (req.extras.getBoolean(REQUEST_EXTRA_SHOW_NOTIFICATION, true)) { enabledNotificationsManager @@ -170,21 +228,21 @@ object FileDownloadHandler : DownloadHandler Unit)?) { - enabledNotificationsManager.getDownload(identifier.id) { d1 -> + override fun delete(identifier: FileDownloadIdentifier, callback: ((Boolean) -> Unit)?) { + enabledNotificationsManager.getDownload(identifier.get()) { d1 -> if (d1 != null) { enabledNotificationsManager.cancel(d1.id) enabledNotificationsManager.delete(d1.id) + enabledNotificationsManager.remove(d1.id) callback?.invoke(true) } else { - disabledNotificationsManager.getDownload(identifier.id) { d2 -> + disabledNotificationsManager.getDownload(identifier.get()) { d2 -> if (d2 != null) { disabledNotificationsManager.cancel(d2.id) disabledNotificationsManager.delete(d2.id) + disabledNotificationsManager.remove(d2.id) callback?.invoke(true) } else { callback?.invoke(false) @@ -194,23 +252,61 @@ object FileDownloadHandler : DownloadHandler Unit) { - disabledNotificationsManager.getDownload(identifier.id) { a -> - enabledNotificationsManager.getDownload(identifier.id) { b -> - callback( + override fun listen( + identifier: FileDownloadIdentifier, + listener: ((DownloadStatus) -> Unit)? + ) { + listeners[identifier.get()] = listener + disabledNotificationsManager.getDownload(identifier.get()) { a -> + enabledNotificationsManager.getDownload(identifier.get()) { b -> + listener?.invoke( getDownloadStatus(a ?: b) ) } } } + override fun getDownloads( + storage: Storage, + callback: (Map>) -> Unit + ) { + disabledNotificationsManager.getDownloads { a -> + enabledNotificationsManager.getDownloads { b -> + callback.invoke( + a + .filter { it.file.contains(storage.file.absolutePath) } + .associate { + Pair( + FileDownloadIdentifier(it.id), + getDownloadStatus(it) to it.extras.getString( + REQUEST_EXTRA_CATEGORY, + "" + ).takeUnless { it.isEmpty() } + ) + } + + b + .filter { it.file.contains(storage.file.absolutePath) } + .associate { + Pair( + FileDownloadIdentifier(it.id), + getDownloadStatus(it) to it.extras.getString( + REQUEST_EXTRA_CATEGORY, + "" + ).takeUnless { it.isEmpty() } + ) + } + ) + } + } + } + private fun notifyStatus(download: Download) { listeners[download.id]?.invoke( getDownloadStatus(download) ) } - private fun getDownloadStatus(download: Download?): DownloadStatus? { + private fun getDownloadStatus(download: Download?): DownloadStatus { return try { download!! DownloadStatus( @@ -218,15 +314,15 @@ 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() } ) } catch (e: Exception) { - null + DownloadStatus(null, null, DownloadStatus.State.DELETED) } } } 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..81b25b781 100644 --- a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadIdentifier.kt +++ b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadIdentifier.kt @@ -3,5 +3,10 @@ package de.xikolo.download.filedownload import de.xikolo.download.DownloadIdentifier data class FileDownloadIdentifier( - val id: Int -) : DownloadIdentifier + private val id: Int +) : DownloadIdentifier { + + 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..c23b1b153 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,36 @@ 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.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.fileSize 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 open class FileDownloadItem( val url: String?, + val category: String?, 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 + final override val downloader = FileDownloadHandler - 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 - - 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?.fileSize ?: 0L - override val download: File? + final override val download: File? get() { val originalStorage: Storage = storage @@ -88,9 +41,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 +59,38 @@ 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) - } - } - } - } + final override val request + get() = FileDownloadRequest( + url!!, + File("$filePath.tmp"), + title, + showNotification, + category + ) - override fun cancel(activity: FragmentActivity, callback: ((Boolean) -> Unit)?) { - performAction(activity) { - downloader.cancel(getDownloadIdentifier()) { success -> - delete(activity) - secondaryDownloadItems.forEach { - it.cancel(activity) - } + final override val itemIdentifier: FileDownloadIdentifier + get() = FileDownloadIdentifier(request.buildRequest().id) - callback?.invoke(success) + override fun onStatusChanged(newStatus: DownloadStatus) { + super.onStatusChanged(newStatus) + 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() } } } - 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 - ) - } + 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 - ) - ) - } - } - } + val filePath: String + get() = getFileFolder() + File.separator + fileName - callback( - DownloadStatus( - totalBytes, - writtenBytes, - state - ) - ) - } - } + 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 - } - } + 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..cf56eb61b 100644 --- a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadRequest.kt +++ b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadRequest.kt @@ -17,12 +17,14 @@ data class FileDownloadRequest( val url: String, val localFile: File, override val title: String, - override val showNotification: Boolean + override val showNotification: Boolean, + override val category: String? ) : DownloadRequest { companion object { const val REQUEST_EXTRA_TITLE = "title" const val REQUEST_EXTRA_SHOW_NOTIFICATION = "showNotification" + const val REQUEST_EXTRA_CATEGORY = "category" } fun buildRequest(): Request { @@ -34,12 +36,17 @@ data class FileDownloadRequest( NetworkType.ALL } - extras = Extras( - mapOf( + val extrasMap = + mutableMapOf( REQUEST_EXTRA_TITLE to title, REQUEST_EXTRA_SHOW_NOTIFICATION to showNotification.toString() - ) - ) + ).apply { + if (category != null) { + put(REQUEST_EXTRA_CATEGORY, category) + } + } + + extras = Extras(extrasMap) groupId = 0 addHeader(Config.HEADER_USER_AGENT, Config.HEADER_USER_AGENT_VALUE) 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..05b0e8ac7 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt @@ -0,0 +1,348 @@ +package de.xikolo.download.hlsvideodownload + +import android.content.Context +import android.os.Handler +import android.os.Looper +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.DownloadManager +import com.google.android.exoplayer2.offline.DownloadService +import com.google.android.exoplayer2.scheduler.Requirements +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 de.xikolo.App +import de.xikolo.config.Config +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.util.concurrent.Executors + +object HlsVideoDownloadHandler : + DownloadHandler { + + 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) + + val context = App.instance + + init { + DownloadService.start( + 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.newSingleThreadExecutor() + ).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 = HlsVideoDownloadRequest.ArgumentWrapper.decode( + download.request.data + ) + if (download.state == Download.STATE_COMPLETED + && args.showNotification + ) { + NotificationUtil(context).showDownloadCompletedNotification( + args.title + ) + } + + startNotifierThread(downloadManager) + } + + override fun onIdle(downloadManager: DownloadManager) { + stopNotifierThread(downloadManager) + } + + override fun onDownloadRemoved( + downloadManager: DownloadManager, + download: Download + ) { + val identifier = download.request.id + notifyStatus(identifier) + listeners.remove(identifier) + downloads.remove(identifier) + } + } + ) + }.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 download( + request: HlsVideoDownloadRequest, + callback: ((HlsVideoDownloadIdentifier?) -> Unit)? + ) { + request.buildRequest(context) { downloadRequest -> + if (downloadRequest == null) { + callback?.invoke(null) + return@buildRequest + } + + 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) { + HlsVideoDownloadInternalStorageForegroundService::class.java + } else if (request.storage == context.sdcardStorage && + getSdcardStorageCache(context) != null + ) { + HlsVideoDownloadSdcardStorageForegroundService::class.java + } else { + throw Exception() + } + + DownloadService.sendAddDownload( + context, + service, + downloadRequest, + true + ) + + callback?.invoke(request.identifier) + } catch (e: Exception) { + downloads[identifier] = Download( + downloadRequest, + Download.STATE_FAILED, + 0, + 0, + -1, + Download.STOP_REASON_NONE, + Download.FAILURE_REASON_UNKNOWN + ) + notifyStatus(identifier) + callback?.invoke(null) + } + } + } + + override fun delete(identifier: HlsVideoDownloadIdentifier, callback: ((Boolean) -> Unit)?) { + 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)? + ) { + 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 + ) { + val sdcardStorageCache = getSdcardStorageCache(context) + val cache = + if (storage == context.internalStorage) { + getInternalStorageCache(context) + } else if (storage == context.sdcardStorage && sdcardStorageCache != null) { + sdcardStorageCache + } else { + callback.invoke(mapOf()) + return + } + + callback.invoke( + getManager(context, cache).downloadIndex.getDownloads().let { + val map = mutableMapOf>() + it.moveToFirst() + while (!it.isAfterLast) { + map[HlsVideoDownloadIdentifier.from(it.download.request.id)] = + getDownloadStatus(it.download) to + HlsVideoDownloadRequest.ArgumentWrapper.decode( + it.download.request.data + ).category + it.moveToNext() + } + map + } + ) + } + + private fun getDownloadStatus(download: Download?): DownloadStatus { + if (download == null) { + return DownloadStatus(null, null, DownloadStatus.State.DELETED) + } + + val totalSize = download.contentLength.takeUnless { it < 0 } + ?: if (download.state == Download.STATE_COMPLETED) { + download.bytesDownloaded + } else { + download.bytesDownloaded * 100 / download.percentDownloaded + }.toLong() + + return DownloadStatus( + totalSize, + download.bytesDownloaded, + when (download.state) { + Download.STATE_QUEUED, Download.STATE_RESTARTING -> DownloadStatus.State.PENDING + Download.STATE_DOWNLOADING -> DownloadStatus.State.RUNNING + Download.STATE_COMPLETED -> DownloadStatus.State.DOWNLOADED + else -> DownloadStatus.State.DELETED + } + ) + } + + private fun notifyStatus(identifier: String) { + listeners[identifier]?.invoke( + getDownloadStatus(downloads[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 + } +} 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..d4cbca010 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadIdentifier.kt @@ -0,0 +1,20 @@ +package de.xikolo.download.hlsvideodownload + +import de.xikolo.download.DownloadIdentifier + +data class HlsVideoDownloadIdentifier( + private val url: String, + private val quality: Int +) : DownloadIdentifier { + + companion object { + fun from(identifier: String): HlsVideoDownloadIdentifier { + val parts = identifier.split(";", limit = 2) + return HlsVideoDownloadIdentifier(parts[1], parts[0].toInt()) + } + } + + fun get(): String { + return "$quality;$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..209e00b77 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt @@ -0,0 +1,127 @@ +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.MediaSource +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.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: String?, + val quality: Int, + val subtitles: Map?, + storage: Storage = App.instance.preferredStorage +) : DownloadItemImpl(storage) { + + final override val downloader = HlsVideoDownloadHandler + + final override val downloadable: Boolean + get() = url != null + + override val title: String + get() = url!! + + private val cache: Cache + get() = if (storage == context.sdcardStorage) { + HlsVideoDownloadHandler.getSdcardStorageCache(context)!! + } else { + HlsVideoDownloadHandler.getInternalStorageCache(context) + } + + private val indexEntry: Download? + get() = HlsVideoDownloadHandler.getManager(context, cache) + .downloadIndex + .getDownload(identifier.get()) + + private val mediaSource: MediaSource + get() = HlsMediaSource.Factory( + CacheDataSource.Factory() + .setCache(cache) + ).createMediaSource(request.mediaItem) + + override val size: Long + get() = indexEntry?.bytesDownloaded ?: 0L + + final override val download: MediaSource? + get() { + val originalStorage = storage + return try { + storage = context.internalStorage + indexEntry!! + mediaSource + } catch (e: Exception) { + try { + storage = context.sdcardStorage!! + indexEntry!! + mediaSource + } catch (e: Exception) { + null + } + } finally { + storage = originalStorage + } + } + + val subs: Map? + get() { + fun getMediaSource(language: String, url: String): MediaSource = + SingleSampleMediaSource.Factory( + CacheDataSource.Factory() + .setCache(cache) + ) + .createMediaSource( + MediaItem.Subtitle( + Uri.parse(url), + MimeTypes.TEXT_VTT, + language, + C.SELECTION_FLAG_DEFAULT + ), + C.TIME_UNSET + ) + + val originalStorage = storage + return try { + storage = context.internalStorage + indexEntry!! + subtitles?.entries?.associate { + it.key to getMediaSource(it.key, it.value) + } + } catch (e: Exception) { + storage = context.sdcardStorage!! + indexEntry!! + subtitles?.entries?.associate { + it.key to getMediaSource(it.key, it.value) + } + } finally { + storage = originalStorage + } + } + + final override val request + get() = HlsVideoDownloadRequest( + url!!, + quality, + subtitles, + identifier, + storage, + title, + true, + category + ) + + final override val itemIdentifier: HlsVideoDownloadIdentifier + get() = HlsVideoDownloadIdentifier(url!!, quality) +} + 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..969e98536 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt @@ -0,0 +1,102 @@ +package de.xikolo.download.hlsvideodownload + +import android.content.Context +import android.net.Uri +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.DefaultRenderersFactory +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.offline.DownloadHelper +import com.google.android.exoplayer2.offline.StreamKey +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import com.google.android.exoplayer2.util.MimeTypes +import com.google.gson.Gson +import de.xikolo.download.DownloadRequest +import de.xikolo.models.Storage +import java.io.IOException + +class HlsVideoDownloadRequest( + val url: String, + val desiredBitrate: Int, + val subtitles: Map?, + val identifier: HlsVideoDownloadIdentifier, + val storage: Storage, + override val title: String, + override val showNotification: Boolean, + override val category: String? +) : DownloadRequest { + + val mediaItem = MediaItem.Builder() + .setUri(Uri.parse(url)) + .setSubtitles( + subtitles?.map { + MediaItem.Subtitle( + Uri.parse(it.value), + MimeTypes.TEXT_VTT, + it.key, + C.SELECTION_FLAG_DEFAULT + ) + } + ) + .setStreamKeys( + listOf( + StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 1), + StreamKey(HlsMasterPlaylist.GROUP_INDEX_AUDIO, 1), + StreamKey(HlsMasterPlaylist.GROUP_INDEX_SUBTITLE, 1) + ) + ) + .build() + + fun buildRequest( + context: Context, + callback: (com.google.android.exoplayer2.offline.DownloadRequest?) -> Unit + ) { + val helper = DownloadHelper.forMediaItem( + context, + mediaItem, + DefaultRenderersFactory(context), + HlsVideoDownloadHandler.dataSourceFactory + ) + helper.prepare( + object : DownloadHelper.Callback { + override fun onPrepared(helper: DownloadHelper) { + helper.clearTrackSelections(0) + helper.addTrackSelection( + 0, + DefaultTrackSelector.ParametersBuilder(context) + .setForceHighestSupportedBitrate(true) + .build() + ) + helper.addTextLanguagesToSelection( + true, + *(subtitles?.keys?.toTypedArray() ?: arrayOf()) + ) + val request = helper.getDownloadRequest( + identifier.get(), + ArgumentWrapper(title, showNotification, category).encode() + ) + helper.release() + callback(request) + } + + override fun onPrepareError(helper: DownloadHelper, e: IOException) { + callback(null) + helper.release() + } + } + ) + } + + internal data class ArgumentWrapper( + val title: String, + val showNotification: Boolean, + val category: String? + ) { + 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/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..ad8609743 --- /dev/null +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadForegroundService.kt @@ -0,0 +1,62 @@ +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.download.hlsvideodownload.HlsVideoDownloadRequest +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(applicationContext) + val notifications = downloads.mapNotNull { + val args = HlsVideoDownloadRequest.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, + HlsVideoDownloadForegroundService::class.java, + it.request.id, + true + ), + PendingIntent.FLAG_UPDATE_CURRENT + ) + ).build() + } else null + } + + notifications.forEach { + notificationUtil.notify(it.first, it.second) + } + return notificationUtil.getDownloadRunningGroupNotification(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..d83093a95 100644 --- a/app/src/main/java/de/xikolo/models/DownloadAsset.kt +++ b/app/src/main/java/de/xikolo/models/DownloadAsset.kt @@ -2,7 +2,11 @@ package de.xikolo.models import de.xikolo.App import de.xikolo.R +import de.xikolo.controllers.downloads.DownloadsFragment.Companion.CATEGORY_CERTIFICATES +import de.xikolo.controllers.downloads.DownloadsFragment.Companion.CATEGORY_DOCUMENTS +import de.xikolo.controllers.helper.VideoSettingsHelper import de.xikolo.download.filedownload.FileDownloadItem +import de.xikolo.download.hlsvideodownload.HlsVideoDownloadItem import de.xikolo.utils.extensions.asEscapedFileName import java.io.File @@ -13,6 +17,7 @@ object DownloadAsset { documentLocalization: DocumentLocalization ) : FileDownloadItem( documentLocalization.fileUrl, + CATEGORY_DOCUMENTS, documentLocalization.language + "_" + documentLocalization.revision + "_" + documentLocalization.id + ".pdf" @@ -29,7 +34,7 @@ object DownloadAsset { } sealed class Certificate(url: String?, fileName: String, val course: de.xikolo.models.Course) : - FileDownloadItem(url, fileName) { + FileDownloadItem(url, CATEGORY_CERTIFICATES, fileName) { override fun getFileFolder(): String { return super.getFileFolder() + File.separator + "Certificates" + File.separator + @@ -63,7 +68,7 @@ object DownloadAsset { url: String?, override val fileName: String, val course: de.xikolo.models.Course - ) : FileDownloadItem(url, fileName) { + ) : FileDownloadItem(url, course.id, fileName) { override fun getFileFolder(): String { return super.getFileFolder() + File.separator + "Courses" + File.separator + @@ -93,40 +98,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, + item.courseId, + quality.bitrate, + 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 +121,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..5fef6942c 100644 --- a/app/src/main/java/de/xikolo/models/Video.kt +++ b/app/src/main/java/de/xikolo/models/Video.kt @@ -104,9 +104,17 @@ 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/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..3b7155a5f 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() { @@ -70,14 +91,27 @@ class ApplicationPreferences { set(value) = putBoolean(context.getString(R.string.preference_confirm_open_external_content_peer), 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 +119,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..8a14e8186 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,6 +15,7 @@ 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 @@ -23,6 +25,7 @@ class NotificationUtil(base: Context) : ContextWrapper(base) { 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" @@ -40,17 +43,8 @@ class NotificationUtil(base: Context) : ContextWrapper(base) { } } - 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 +52,75 @@ 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 getDownloadRunningGroupNotification(count: Int): Notification { + 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, + count + ) + ) + .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 +156,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 +195,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 +219,13 @@ 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 +246,12 @@ 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..d2147ce4f 100644 --- a/app/src/main/java/de/xikolo/utils/extensions/CastExtensions.kt +++ b/app/src/main/java/de/xikolo/utils/extensions/CastExtensions.kt @@ -114,7 +114,7 @@ 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..3e64fc40d 100644 --- a/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt +++ b/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt @@ -37,8 +37,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 +59,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 +93,16 @@ 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() + ) + .setTrackSelector(trackSelector) + .build() exoplayer.addListener( object : Player.EventListener { @@ -198,24 +198,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() @@ -243,21 +270,12 @@ open class ExoPlayerVideoView : PlayerView { 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 +289,26 @@ open class ExoPlayerVideoView : PlayerView { return null } + fun setDesiredBitrate(bitrate: Int?) { + trackSelector.setParameters( + DefaultTrackSelector.ParametersBuilder(context) + .apply { + if (bitrate != null) { + setMaxVideoBitrate(bitrate) + setMinVideoBitrate(bitrate) + } + } + ) + } + fun scaleToFill() { - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM exoplayer.videoScalingMode = Renderer.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL } fun scaleToFit() { + exoplayer.videoScalingMode = Renderer.VIDEO_SCALING_MODE_DEFAULT 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..0dc6b4a42 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 @@ -171,8 +165,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 +306,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 +314,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..e22b8946f 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 @@ -171,8 +165,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 +299,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 +307,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 +334,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..5c37281b6 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 @@ -170,8 +165,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 +299,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 +307,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 +334,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..ce4f14bd7 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 @@ -171,8 +165,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 +299,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 +307,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 +334,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..2c85182ef 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后运行。 更新 @@ -171,8 +165,8 @@ 请扫二维码 PDF版课件 PDF版成绩单 - MP4视频(高清) - MP4视频(标清) + 视频 + 视频 (质量: %1$s) 下载 打开 播放 @@ -305,7 +299,7 @@ 开启外部练习前先确认 开启同行评估前先确认 - 移动数据使用 + 数据使用 限制视频的数据使用量 仅用Wi-Fi串流高清视频 @@ -313,6 +307,9 @@ 限制下载的数据使用量 仅用Wi-Fi下载档案 + 视频下载质量 + 默认质量: %s + 视频 视频播放速度 @@ -337,6 +334,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..4403ec472 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 @@ -43,6 +43,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..43bc3981b 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 @@ -172,8 +166,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 +305,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 +313,9 @@ Limit data usage for downloads Only download files on Wi-Fi + Video Download Quality + Default quality: %s + Video Video playback speed @@ -343,6 +340,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" /> + + Date: Thu, 14 Jan 2021 21:55:35 +0100 Subject: [PATCH 02/15] Add hint dialog --- .../dialogs/VideoDownloadQualityHintDialog.kt | 54 +++++++++++++++++++ .../controllers/helper/DownloadViewHelper.kt | 2 + .../section/VideoPreviewFragment.kt | 27 +++++++++- .../video/VideoStreamPlayerFragment.kt | 3 ++ .../xikolo/storages/ApplicationPreferences.kt | 20 ++++++- app/src/main/res/values-de/strings.xml | 5 ++ app/src/main/res/values-es/strings.xml | 5 ++ app/src/main/res/values-fr/strings.xml | 5 ++ app/src/main/res/values-pt/strings.xml | 5 ++ app/src/main/res/values-zh/strings.xml | 5 ++ app/src/main/res/values/keys.xml | 2 + app/src/main/res/values/strings.xml | 5 ++ 12 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/de/xikolo/controllers/dialogs/VideoDownloadQualityHintDialog.kt 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/helper/DownloadViewHelper.kt b/app/src/main/java/de/xikolo/controllers/helper/DownloadViewHelper.kt index b08846794..9cbcfb478 100644 --- a/app/src/main/java/de/xikolo/controllers/helper/DownloadViewHelper.kt +++ b/app/src/main/java/de/xikolo/controllers/helper/DownloadViewHelper.kt @@ -34,6 +34,7 @@ class DownloadViewHelper( urlNotAvailableMessage: CharSequence? = null, openText: CharSequence? = null, openClick: (() -> Unit)? = null, + downloadClick: (() -> Unit)? = null, private val onDeleted: (() -> Unit)? = null ) { @@ -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() 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 500779d57..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 @@ -17,8 +18,10 @@ 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 @@ -196,13 +199,34 @@ class VideoPreviewFragment : ViewModelFragment() { VideoSettingsHelper.VideoQuality.BEST -> videoBest } + 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) + } + } + linearLayoutDownloads.addView( DownloadViewHelper( activity, videoDefault.first, getString(R.string.video_with_quality).format(videoDefault.second), openText = getString(R.string.play), - openClick = { play() } + openClick = { play() }, + downloadClick = { showDownloadQualityHint() } ).view ) @@ -215,6 +239,7 @@ class VideoPreviewFragment : ViewModelFragment() { getString(R.string.video_with_quality).format(it.second), openText = getString(R.string.play), openClick = { play() }, + downloadClick = { showDownloadQualityHint() }, onDeleted = { updateDownloadViewHelpers(activity, item, video) } ).view ) 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 18df44b75..f30610eb4 100644 --- a/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt @@ -634,6 +634,7 @@ open class VideoStreamPlayerFragment : BaseFragment() { true } else -> { + playerView.pause() warningContainer.visibility = View.VISIBLE warningText.text = getString(R.string.video_notification_no_offline_video) false @@ -660,6 +661,7 @@ open class VideoStreamPlayerFragment : BaseFragment() { if (updateVideo()) { prepare() } else { + pause(false) showError() } } @@ -677,6 +679,7 @@ open class VideoStreamPlayerFragment : BaseFragment() { private fun showError() { saveCurrentPosition() + hideProgress() warningContainer.visibility = View.VISIBLE warningText.text = getString(R.string.error_plain) } diff --git a/app/src/main/java/de/xikolo/storages/ApplicationPreferences.kt b/app/src/main/java/de/xikolo/storages/ApplicationPreferences.kt index 3b7155a5f..d5aa5218d 100644 --- a/app/src/main/java/de/xikolo/storages/ApplicationPreferences.kt +++ b/app/src/main/java/de/xikolo/storages/ApplicationPreferences.kt @@ -84,11 +84,27 @@ 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( diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0dc6b4a42..8a31ede59 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -140,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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e22b8946f..52888bfc0 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -140,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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5c37281b6..970ff6828 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -140,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 diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index ce4f14bd7..dd2b909b8 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -140,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 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 2c85182ef..5c2c616e2 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -140,6 +140,11 @@ 尝试用文件管理器打开 没有找到文件管理器 + 关闭 + 默认的视频下载质量可以在 \"%1$s \"的设置中更改。123的设置中更改。 + 下载质量设置 + 进入设置 + 单元 diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index 4403ec472..20cea95bd 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -24,6 +24,8 @@ category_video_playback_speed category_info + video_download_quality_hint + first_android_4_deprecation_dialog second_android_4_deprecation_dialog diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43bc3981b..5a652fef3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -141,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 From bccfd231f8edb2040ba586c7485ae40cd889ec14 Mon Sep 17 00:00:00 2001 From: Ben Engelhaupt Date: Tue, 26 Jan 2021 17:57:38 +0100 Subject: [PATCH 03/15] fix linter issues --- .../controllers/course/DescriptionFragment.kt | 8 ++++---- .../controllers/downloads/DownloadsFragment.kt | 5 ++--- .../video/VideoStreamPlayerFragment.kt | 3 ++- .../java/de/xikolo/download/DownloadItemImpl.kt | 6 +++--- .../hlsvideodownload/HlsVideoDownloadHandler.kt | 17 +++++++---------- app/src/main/java/de/xikolo/models/Video.kt | 12 ++++-------- .../java/de/xikolo/utils/NotificationUtil.kt | 7 +++++-- 7 files changed, 27 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/de/xikolo/controllers/course/DescriptionFragment.kt b/app/src/main/java/de/xikolo/controllers/course/DescriptionFragment.kt index c6b8b341e..f414eff64 100644 --- a/app/src/main/java/de/xikolo/controllers/course/DescriptionFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/course/DescriptionFragment.kt @@ -86,10 +86,10 @@ class DescriptionFragment : ViewModelFragment() { private fun showDescription(course: Course) { when { - course.teaserStream != null - && (course.teaserStream.hlsUrl != 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/downloads/DownloadsFragment.kt b/app/src/main/java/de/xikolo/controllers/downloads/DownloadsFragment.kt index f078ec7dc..8a89d1d62 100644 --- a/app/src/main/java/de/xikolo/controllers/downloads/DownloadsFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/downloads/DownloadsFragment.kt @@ -280,9 +280,8 @@ class DownloadsFragment : private fun fetchItems() { activity?.let { activity -> adapter?.clear() - if (permissionManager?.requestPermission( - PermissionManager.WRITE_EXTERNAL_STORAGE - ) == 1 + if ( + permissionManager?.requestPermission(PermissionManager.WRITE_EXTERNAL_STORAGE) == 1 ) { Downloaders.getDownloads(activity.internalStorage) { val internalStorageDownloads = it 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 f30610eb4..08537ec44 100644 --- a/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt @@ -667,7 +667,8 @@ open class VideoStreamPlayerFragment : BaseFragment() { } protected open fun changePlaybackSpeed( - oldSpeed: VideoSettingsHelper.PlaybackSpeed, newSpeed: VideoSettingsHelper.PlaybackSpeed, + oldSpeed: VideoSettingsHelper.PlaybackSpeed, + newSpeed: VideoSettingsHelper.PlaybackSpeed, fromUser: Boolean ) { updatePlaybackSpeed() diff --git a/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt b/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt index 03b64e41e..df211c039 100644 --- a/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt +++ b/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt @@ -101,9 +101,9 @@ abstract class DownloadItemImpl Unit): Boolean { return if (storage.isWritable) { - if (PermissionManager(activity).requestPermission( - PermissionManager.WRITE_EXTERNAL_STORAGE - ) == 1 + if ( + PermissionManager(activity) + .requestPermission(PermissionManager.WRITE_EXTERNAL_STORAGE) == 1 ) { action() true diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt index 05b0e8ac7..7fbdef28f 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt @@ -118,8 +118,8 @@ object HlsVideoDownloadHandler : val args = HlsVideoDownloadRequest.ArgumentWrapper.decode( download.request.data ) - if (download.state == Download.STATE_COMPLETED - && args.showNotification + if (download.state == Download.STATE_COMPLETED && + args.showNotification ) { NotificationUtil(context).showDownloadCompletedNotification( args.title @@ -148,11 +148,10 @@ object HlsVideoDownloadHandler : managers[cache.uid] = it } } - ).apply { + ) + .apply { if (ApplicationPreferences().isDownloadNetworkLimitedOnMobile) { - requirements = Requirements( - Requirements.NETWORK_UNMETERED - ) + requirements = Requirements(Requirements.NETWORK_UNMETERED) } } } @@ -283,10 +282,8 @@ object HlsVideoDownloadHandler : it.moveToFirst() while (!it.isAfterLast) { map[HlsVideoDownloadIdentifier.from(it.download.request.id)] = - getDownloadStatus(it.download) to - HlsVideoDownloadRequest.ArgumentWrapper.decode( - it.download.request.data - ).category + getDownloadStatus(it.download) to HlsVideoDownloadRequest.ArgumentWrapper + .decode(it.download.request.data).category it.moveToNext() } map diff --git a/app/src/main/java/de/xikolo/models/Video.kt b/app/src/main/java/de/xikolo/models/Video.kt index 5fef6942c..b7222de47 100644 --- a/app/src/main/java/de/xikolo/models/Video.kt +++ b/app/src/main/java/de/xikolo/models/Video.kt @@ -104,16 +104,12 @@ open class Video : RealmObject() { val streamToPlay: VideoStream? get() { - return if (singleStream != null && - (singleStream?.hlsUrl != 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?.hlsUrl != 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/utils/NotificationUtil.kt b/app/src/main/java/de/xikolo/utils/NotificationUtil.kt index 8a14e8186..1a21596d3 100644 --- a/app/src/main/java/de/xikolo/utils/NotificationUtil.kt +++ b/app/src/main/java/de/xikolo/utils/NotificationUtil.kt @@ -224,7 +224,9 @@ class NotificationUtil(base: Context) : ContextWrapper(base) { } private fun createDownloadCompletedContentIntent( - parentActivityClass: Class<*>, extraKey: String, extraValue: String + parentActivityClass: Class<*>, + extraKey: String, + extraValue: String ): PendingIntent? { // Creates an explicit intent for an Activity in your app val resultIntent = Intent(this, parentActivityClass) @@ -247,7 +249,8 @@ class NotificationUtil(base: Context) : ContextWrapper(base) { } private fun createDownloadCompletedDeleteIntent( - extraKey: String, extraValue: String + extraKey: String, + extraValue: String ): PendingIntent { val deleteIntent = Intent(this, NotificationDeletedReceiver::class.java) deleteIntent.action = NotificationDeletedReceiver.INTENT_ACTION_NOTIFICATION_DELETED From b6a32be6d3969984968aacbefca03f6d1f64d334 Mon Sep 17 00:00:00 2001 From: Ben Engelhaupt Date: Tue, 26 Jan 2021 21:33:08 +0100 Subject: [PATCH 04/15] add documentation and refactor download categories --- .../controllers/course/DescriptionFragment.kt | 7 +- .../downloads/DownloadsFragment.kt | 34 ++++----- .../de/xikolo/download/DownloadCategory.kt | 74 +++++++++++++++++++ .../de/xikolo/download/DownloadHandler.kt | 49 +++++++++++- .../de/xikolo/download/DownloadIdentifier.kt | 3 + .../java/de/xikolo/download/DownloadItem.kt | 45 +++++++++++ .../de/xikolo/download/DownloadItemImpl.kt | 36 +++++++++ .../de/xikolo/download/DownloadRequest.kt | 14 +++- .../java/de/xikolo/download/DownloadStatus.kt | 42 ++++++++++- .../java/de/xikolo/download/Downloaders.kt | 30 +++++++- .../filedownload/FileDownloadHandler.kt | 26 ++++--- .../download/filedownload/FileDownloadItem.kt | 3 +- .../filedownload/FileDownloadRequest.kt | 8 +- .../HlsVideoDownloadHandler.kt | 13 ++-- .../hlsvideodownload/HlsVideoDownloadItem.kt | 3 +- .../HlsVideoDownloadRequest.kt | 5 +- .../java/de/xikolo/models/DownloadAsset.kt | 11 ++- app/src/main/java/de/xikolo/models/Video.kt | 12 ++- 18 files changed, 357 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/de/xikolo/download/DownloadCategory.kt diff --git a/app/src/main/java/de/xikolo/controllers/course/DescriptionFragment.kt b/app/src/main/java/de/xikolo/controllers/course/DescriptionFragment.kt index f414eff64..d92403489 100644 --- a/app/src/main/java/de/xikolo/controllers/course/DescriptionFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/course/DescriptionFragment.kt @@ -86,10 +86,11 @@ class DescriptionFragment : ViewModelFragment() { private fun showDescription(course: Course) { when { - course.teaserStream != null && - (course.teaserStream.hlsUrl != null || + course.teaserStream != null && ( + course.teaserStream.hlsUrl != null || course.teaserStream.hdUrl != null || - course.teaserStream.sdUrl != null) -> { + course.teaserStream.sdUrl != null + ) -> { courseImage.visibility = View.GONE videoPreview.visibility = View.VISIBLE 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 8a89d1d62..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,6 +15,7 @@ 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 @@ -32,9 +33,6 @@ class DownloadsFragment : companion object { val TAG: String = DownloadsFragment::class.java.simpleName - - const val CATEGORY_DOCUMENTS = "documents" - const val CATEGORY_CERTIFICATES = "certificates" } private var adapter: DownloadsAdapter? = null @@ -90,8 +88,8 @@ class DownloadsFragment : } private fun buildItems( - internalStorageDownloads: Map>, - sdcardStorageDownloads: Map>? + internalStorageDownloads: Map>, + sdcardStorageDownloads: Map>? ) { var internalAddition = "" var sdcardAddition = "" @@ -128,7 +126,7 @@ class DownloadsFragment : getString(R.string.tab_documents), buildCategories( internalStorageDownloads, - { it == CATEGORY_DOCUMENTS }, + { it is DownloadCategory.Documents }, { getString(R.string.settings_title_storage_internal) + internalAddition } ).toMutableList() .apply { @@ -136,7 +134,7 @@ class DownloadsFragment : addAll( buildCategories( sdcardStorageDownloads, - { it == CATEGORY_DOCUMENTS }, + { it is DownloadCategory.Documents }, { getString(R.string.settings_title_storage_external) + sdcardAddition @@ -152,7 +150,7 @@ class DownloadsFragment : getString(R.string.tab_certificates), buildCategories( internalStorageDownloads, - { it == CATEGORY_CERTIFICATES }, + { it is DownloadCategory.Certificates }, { getString(R.string.settings_title_storage_internal) + internalAddition } ).toMutableList() .apply { @@ -160,7 +158,7 @@ class DownloadsFragment : addAll( buildCategories( sdcardStorageDownloads, - { it == CATEGORY_CERTIFICATES }, + { it is DownloadCategory.Certificates }, { getString(R.string.settings_title_storage_external) + sdcardAddition @@ -177,8 +175,8 @@ class DownloadsFragment : } else "", buildCategories( internalStorageDownloads, - { it != CATEGORY_CERTIFICATES && it != CATEGORY_DOCUMENTS }, - { CourseDao.Unmanaged.find(it)?.title ?: "" } + { it is DownloadCategory.Course }, + { CourseDao.Unmanaged.find((it as DownloadCategory.Course).id)?.title ?: "" } ) ) @@ -188,8 +186,8 @@ class DownloadsFragment : " (" + getString(R.string.settings_title_storage_external) + ")", buildCategories( sdcardStorageDownloads, - { it != CATEGORY_CERTIFICATES && it != CATEGORY_DOCUMENTS }, - { CourseDao.Unmanaged.find(it)?.title ?: "" } + { it is DownloadCategory.Course }, + { CourseDao.Unmanaged.find((it as DownloadCategory.Course).id)?.title ?: "" } ) ) } @@ -198,7 +196,7 @@ class DownloadsFragment : } private fun buildSummary( - downloads: Map>, + downloads: Map>, title: String ): DownloadsAdapter.DownloadCategory { return DownloadsAdapter.DownloadCategory( @@ -211,9 +209,9 @@ class DownloadsFragment : } private fun buildCategories( - downloads: Map>, - categoryFilter: (String) -> Boolean, - titleSelector: (String?) -> String + downloads: Map>, + categoryFilter: (DownloadCategory) -> Boolean, + titleSelector: (DownloadCategory) -> String ): List { val downloadCategories: MutableList = mutableListOf() @@ -221,7 +219,7 @@ class DownloadsFragment : .entries .filter { it.value.first.state == DownloadStatus.State.DOWNLOADED && - it.value.second?.takeIf { categoryFilter(it) } != null + it.value.second.takeIf { categoryFilter(it) } != null } .groupBy { it.value.second } .mapValues { it.value.map { Pair(it.key, it.value.first) } } 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..7cdd9fbeb --- /dev/null +++ b/app/src/main/java/de/xikolo/download/DownloadCategory.kt @@ -0,0 +1,74 @@ +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. + */ + 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("category") + 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() + val category = when (input?.nextString()) { + "other" -> Other + "documents" -> Documents + "certificates" -> Certificates + "course" -> 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 5814ebb67..f978f4e2b 100644 --- a/app/src/main/java/de/xikolo/download/DownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/DownloadHandler.kt @@ -2,27 +2,74 @@ 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) + /** + * 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 the [DownloadIdentifier] for the download when it was initiated successfully, + * otherwise this identifier is null. + * This callback is always invoked if not null. + */ fun download( request: R, callback: ((I?) -> Unit)? = null ) + /** + * 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 was deleted successfully, otherwise false. + * This callback is always invoked if not null. + */ fun delete( identifier: I, callback: ((Boolean) -> Unit)? = null ) + /** + * Registers a listener for a download that notifies when the download status changes. + * Overrides the previous listener. + * + * @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, 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 + 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 03f4ff15d..e84a60123 100644 --- a/app/src/main/java/de/xikolo/download/DownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/DownloadItem.kt @@ -2,23 +2,68 @@ package de.xikolo.download import androidx.fragment.app.FragmentActivity +/** + * 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. + */ 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 occured. + */ 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. + */ 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 was deleted 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 index df211c039..647be5fb1 100644 --- a/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt +++ b/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt @@ -12,6 +12,15 @@ 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 { @@ -22,19 +31,37 @@ abstract class DownloadItemImpl private var downloaderIdentifier: I? = null + + /** + * The identifier of the download. + * + * Must not be accessed when [downloadable] is false. + * Equal to [itemIdentifier] before [start] has been called. + * After [start] has been called, this is the identifier returned from the download handler. + */ final override val identifier: I get() = downloaderIdentifier ?: itemIdentifier override val openAction: ((FragmentActivity) -> Unit)? = null + /** + * The download request. + */ abstract val request: R + /** + * The identifier of the download before [start] hjas been called. + */ abstract val itemIdentifier: I 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, @@ -43,6 +70,8 @@ abstract class DownloadItemImpl Unit): Boolean { return if (storage.isWritable) { if ( diff --git a/app/src/main/java/de/xikolo/download/DownloadRequest.kt b/app/src/main/java/de/xikolo/download/DownloadRequest.kt index b778634d2..e6c3e3c8b 100644 --- a/app/src/main/java/de/xikolo/download/DownloadRequest.kt +++ b/app/src/main/java/de/xikolo/download/DownloadRequest.kt @@ -1,10 +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 - val category: String? + /** + * 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 a126c80c1..6cb7026bb 100644 --- a/app/src/main/java/de/xikolo/download/DownloadStatus.kt +++ b/app/src/main/java/de/xikolo/download/DownloadStatus.kt @@ -2,15 +2,50 @@ package de.xikolo.download import androidx.lifecycle.LiveData +/** + * Represents the status of a download. + * + * @param totalBytes The total size of the download data in bytes. + * This value is null when [state] is [DELETED]. + * + * @param downloadedBytes The currently downloaded number of bytes. + * Equal to [totalBytes] when [state] is [DOWNLOADED] and null when [state] is [DELETED]. + * + * @param state The current state of the download process. + */ data class DownloadStatus( var totalBytes: Long?, var downloadedBytes: Long?, var state: State ) { + /** + * Represents the state a download is in. + */ enum class State { - PENDING, RUNNING, DOWNLOADED, DELETED; - // 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 == DELETED || other == DELETED -> DELETED @@ -21,6 +56,9 @@ data class DownloadStatus( } } + /** + * [LiveData] wrapper for a [DownloadStatus]. + */ class DownloadStatusLiveData( totalBytes: Long? = null, downloadedBytes: Long? = null, diff --git a/app/src/main/java/de/xikolo/download/Downloaders.kt b/app/src/main/java/de/xikolo/download/Downloaders.kt index e28658313..4321e4d20 100644 --- a/app/src/main/java/de/xikolo/download/Downloaders.kt +++ b/app/src/main/java/de/xikolo/download/Downloaders.kt @@ -7,17 +7,29 @@ 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 -> @@ -26,9 +38,17 @@ object Downloaders { } } + /** + * 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 + callback: (Map>) -> Unit ) { fileDownloader.getDownloads(storage) { a -> hlsDownloader.getDownloads(storage) { b -> @@ -37,6 +57,14 @@ object Downloaders { } } + /** + * 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 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 a9ad4c216..4f5d3868e 100644 --- a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadHandler.kt @@ -3,6 +3,7 @@ package de.xikolo.download.filedownload import android.app.NotificationManager import android.content.Context import androidx.core.app.NotificationCompat +import com.google.gson.Gson import com.tonyodev.fetch2.DefaultFetchNotificationManager import com.tonyodev.fetch2.Download import com.tonyodev.fetch2.DownloadNotification @@ -13,6 +14,7 @@ import com.tonyodev.fetch2.FetchListener import com.tonyodev.fetch2.Status import com.tonyodev.fetch2core.DownloadBlock import de.xikolo.App +import de.xikolo.download.DownloadCategory import de.xikolo.download.DownloadHandler import de.xikolo.download.DownloadStatus import de.xikolo.download.filedownload.FileDownloadRequest.Companion.REQUEST_EXTRA_CATEGORY @@ -268,7 +270,7 @@ object FileDownloadHandler : DownloadHandler>) -> Unit + callback: (Map>) -> Unit ) { disabledNotificationsManager.getDownloads { a -> enabledNotificationsManager.getDownloads { b -> @@ -278,10 +280,13 @@ object FileDownloadHandler : DownloadHandler(storage) { 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 cf56eb61b..81a3aa8a8 100644 --- a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadRequest.kt +++ b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadRequest.kt @@ -1,12 +1,14 @@ package de.xikolo.download.filedownload import androidx.core.net.toUri +import com.google.gson.Gson 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 @@ -18,7 +20,7 @@ data class FileDownloadRequest( val localFile: File, override val title: String, override val showNotification: Boolean, - override val category: String? + override val category: DownloadCategory ) : DownloadRequest { companion object { @@ -41,9 +43,7 @@ data class FileDownloadRequest( REQUEST_EXTRA_TITLE to title, REQUEST_EXTRA_SHOW_NOTIFICATION to showNotification.toString() ).apply { - if (category != null) { - put(REQUEST_EXTRA_CATEGORY, category) - } + put(REQUEST_EXTRA_CATEGORY, Gson().toJson(category)) } extras = Extras(extrasMap) diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt index 7fbdef28f..250f15605 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt @@ -15,6 +15,7 @@ import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor import com.google.android.exoplayer2.upstream.cache.SimpleCache 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 @@ -263,7 +264,7 @@ object HlsVideoDownloadHandler : override fun getDownloads( storage: Storage, - callback: (Map>) -> Unit + callback: (Map>) -> Unit ) { val sdcardStorageCache = getSdcardStorageCache(context) val cache = @@ -278,12 +279,14 @@ object HlsVideoDownloadHandler : callback.invoke( getManager(context, cache).downloadIndex.getDownloads().let { - val map = mutableMapOf>() + val map = mutableMapOf>() it.moveToFirst() while (!it.isAfterLast) { - map[HlsVideoDownloadIdentifier.from(it.download.request.id)] = - getDownloadStatus(it.download) to HlsVideoDownloadRequest.ArgumentWrapper - .decode(it.download.request.data).category + map[ + HlsVideoDownloadIdentifier.from(it.download.request.id) + ] = getDownloadStatus(it.download) to HlsVideoDownloadRequest.ArgumentWrapper + .decode(it.download.request.data).category it.moveToNext() } map diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt index 209e00b77..ce31e8698 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt @@ -11,6 +11,7 @@ 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 @@ -19,7 +20,7 @@ import de.xikolo.utils.extensions.sdcardStorage open class HlsVideoDownloadItem( val url: String?, - val category: String?, + val category: DownloadCategory, val quality: Int, val subtitles: Map?, storage: Storage = App.instance.preferredStorage diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt index 969e98536..b151b7450 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt @@ -11,6 +11,7 @@ import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import com.google.android.exoplayer2.util.MimeTypes import com.google.gson.Gson +import de.xikolo.download.DownloadCategory import de.xikolo.download.DownloadRequest import de.xikolo.models.Storage import java.io.IOException @@ -23,7 +24,7 @@ class HlsVideoDownloadRequest( val storage: Storage, override val title: String, override val showNotification: Boolean, - override val category: String? + override val category: DownloadCategory ) : DownloadRequest { val mediaItem = MediaItem.Builder() @@ -90,7 +91,7 @@ class HlsVideoDownloadRequest( internal data class ArgumentWrapper( val title: String, val showNotification: Boolean, - val category: String? + val category: DownloadCategory ) { companion object { fun decode(data: ByteArray): ArgumentWrapper = diff --git a/app/src/main/java/de/xikolo/models/DownloadAsset.kt b/app/src/main/java/de/xikolo/models/DownloadAsset.kt index d83093a95..09dd4f72c 100644 --- a/app/src/main/java/de/xikolo/models/DownloadAsset.kt +++ b/app/src/main/java/de/xikolo/models/DownloadAsset.kt @@ -2,9 +2,8 @@ package de.xikolo.models import de.xikolo.App import de.xikolo.R -import de.xikolo.controllers.downloads.DownloadsFragment.Companion.CATEGORY_CERTIFICATES -import de.xikolo.controllers.downloads.DownloadsFragment.Companion.CATEGORY_DOCUMENTS 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 @@ -17,7 +16,7 @@ object DownloadAsset { documentLocalization: DocumentLocalization ) : FileDownloadItem( documentLocalization.fileUrl, - CATEGORY_DOCUMENTS, + DownloadCategory.Documents, documentLocalization.language + "_" + documentLocalization.revision + "_" + documentLocalization.id + ".pdf" @@ -34,7 +33,7 @@ object DownloadAsset { } sealed class Certificate(url: String?, fileName: String, val course: de.xikolo.models.Course) : - FileDownloadItem(url, CATEGORY_CERTIFICATES, fileName) { + FileDownloadItem(url, DownloadCategory.Certificates, fileName) { override fun getFileFolder(): String { return super.getFileFolder() + File.separator + "Certificates" + File.separator + @@ -68,7 +67,7 @@ object DownloadAsset { url: String?, override val fileName: String, val course: de.xikolo.models.Course - ) : FileDownloadItem(url, course.id, fileName) { + ) : FileDownloadItem(url, DownloadCategory.Course(course.id), fileName) { override fun getFileFolder(): String { return super.getFileFolder() + File.separator + "Courses" + File.separator + @@ -105,7 +104,7 @@ object DownloadAsset { ) : HlsVideoDownloadItem( video.streamToPlay?.hlsUrl, - item.courseId, + DownloadCategory.Course(item.courseId), quality.bitrate, video.subtitles.associate { it.language to it.vttUrl diff --git a/app/src/main/java/de/xikolo/models/Video.kt b/app/src/main/java/de/xikolo/models/Video.kt index b7222de47..302e5ab6e 100644 --- a/app/src/main/java/de/xikolo/models/Video.kt +++ b/app/src/main/java/de/xikolo/models/Video.kt @@ -104,12 +104,16 @@ open class Video : RealmObject() { val streamToPlay: VideoStream? get() { - return if (singleStream != null && (singleStream?.hlsUrl != 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?.hlsUrl != null || - lecturerStream?.hdUrl != null || lecturerStream?.sdUrl != null) + } else if (lecturerStream != null && ( + lecturerStream?.hlsUrl != null || lecturerStream?.hdUrl != null || + lecturerStream?.sdUrl != null + ) ) { lecturerStream } else slidesStream From 61438e78d9a2f58d5b4aaaac08b8c900e83c2107 Mon Sep 17 00:00:00 2001 From: Ben Engelhaupt Date: Mon, 1 Feb 2021 14:43:07 +0100 Subject: [PATCH 05/15] fix linter issues --- app/src/main/java/de/xikolo/models/Video.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/de/xikolo/models/Video.kt b/app/src/main/java/de/xikolo/models/Video.kt index 302e5ab6e..a62b8f025 100644 --- a/app/src/main/java/de/xikolo/models/Video.kt +++ b/app/src/main/java/de/xikolo/models/Video.kt @@ -105,15 +105,15 @@ open class Video : RealmObject() { val streamToPlay: VideoStream? get() { return if (singleStream != null && ( - singleStream?.hlsUrl != null || singleStream?.hdUrl != null || - singleStream?.sdUrl != null - ) + singleStream?.hlsUrl != null || singleStream?.hdUrl != null || + singleStream?.sdUrl != null + ) ) { singleStream } else if (lecturerStream != null && ( - lecturerStream?.hlsUrl != null || lecturerStream?.hdUrl != null || - lecturerStream?.sdUrl != null - ) + lecturerStream?.hlsUrl != null || lecturerStream?.hdUrl != null || + lecturerStream?.sdUrl != null + ) ) { lecturerStream } else slidesStream From 24670a58b4605715b7176c61a305eb225a7fb9f4 Mon Sep 17 00:00:00 2001 From: Ben Engelhaupt Date: Tue, 6 Apr 2021 17:46:19 +0200 Subject: [PATCH 06/15] restructure code and add HlsDownloadHandler tests --- app/dependencies.gradle.kts | 1 + .../instrumented/unit/DownloadHandlerTest.kt | 336 +++++++++++------- .../instrumented/unit/DownloadItemTest.kt | 14 +- .../unit/FileDownloadHandlerTest.kt | 24 +- .../instrumented/unit/FileDownloadItemTest.kt | 13 +- .../unit/HlsVideoDownloadHandlerTest.kt | 78 ++++ app/src/main/AndroidManifest.xml | 1 - .../xikolo/controllers/base/BaseActivity.kt | 2 +- .../controllers/helper/DownloadViewHelper.kt | 7 +- .../controllers/helper/VideoSettingsHelper.kt | 17 +- .../video/VideoItemPlayerFragment.kt | 4 +- .../video/VideoStreamPlayerFragment.kt | 8 +- .../de/xikolo/download/DownloadCategory.kt | 12 +- .../de/xikolo/download/DownloadHandler.kt | 16 +- .../java/de/xikolo/download/DownloadItem.kt | 2 +- .../de/xikolo/download/DownloadItemImpl.kt | 19 +- .../java/de/xikolo/download/DownloadStatus.kt | 19 +- .../filedownload/FileDownloadHandler.kt | 94 ++++- .../filedownload/FileDownloadIdentifier.kt | 8 + .../download/filedownload/FileDownloadItem.kt | 24 +- .../filedownload/FileDownloadRequest.kt | 69 +--- .../HlsVideoDownloadHandler.kt | 182 +++++++--- .../HlsVideoDownloadIdentifier.kt | 15 + .../hlsvideodownload/HlsVideoDownloadItem.kt | 17 +- .../HlsVideoDownloadRequest.kt | 86 ++--- .../HlsVideoDownloadForegroundService.kt | 9 +- .../java/de/xikolo/models/DownloadAsset.kt | 4 +- .../receivers/NotificationDeletedReceiver.kt | 2 +- .../java/de/xikolo/utils/NotificationUtil.kt | 32 +- .../de/xikolo/views/ExoPlayerVideoView.kt | 4 +- 30 files changed, 682 insertions(+), 437 deletions(-) create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadHandlerTest.kt diff --git a/app/dependencies.gradle.kts b/app/dependencies.gradle.kts index cacf7a9c8..14ebd5c56 100644 --- a/app/dependencies.gradle.kts +++ b/app/dependencies.gradle.kts @@ -98,5 +98,6 @@ dependencies { androidTestImplementation("androidx.test.espresso:espresso-contrib:3.3.0") androidTestImplementation("androidx.test:runner:1.3.0") androidTestImplementation("androidx.test:rules:1.3.0") + androidTestImplementation("io.mockk:mockk-android:1.11.0") testImplementation("junit:junit:4.13.2") } 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 index 4e5131931..ed786339c 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt @@ -1,13 +1,21 @@ package de.xikolo.testing.instrumented.unit +import android.Manifest import androidx.test.rule.ActivityTestRule +import androidx.test.rule.GrantPermissionRule import de.xikolo.controllers.main.MainActivity +import de.xikolo.download.DownloadCategory 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 de.xikolo.utils.extensions.preferredStorage +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.Rule import org.junit.Test @@ -18,174 +26,256 @@ abstract class DownloadHandlerTest, @JvmField var activityTestRule = ActivityTestRule(MainActivity::class.java, false, true) + @Rule + @JvmField + var permissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE) + abstract var downloadHandler: T abstract var successfulTestRequest: R abstract var successfulTestRequest2: R abstract var failingTestRequest: R - abstract var invalidIdentifier: I + + @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) == false }, 3000) + downloadHandler.listen(identifier, null) + } + + deleteDownload(successfulTestRequest) + deleteDownload(successfulTestRequest2) + deleteDownload(failingTestRequest) + } @Test - fun testDownloadIdentifier() { - val identifier = downloadHandler.download(successfulTestRequest) - Assert.assertTrue(identifier.toString().isNotBlank()) + fun testDownloadIdentification() { + downloadHandler.identify(successfulTestRequest) + downloadHandler.identify(successfulTestRequest2) + downloadHandler.identify(failingTestRequest) } @Test - fun testInvalidIdentifier() { - downloadHandler.status(invalidIdentifier) { - Assert.assertNull(it) + fun testDownloadListenerRegistration() { + val identifier = downloadHandler.identify(successfulTestRequest) + + var called = false + // register listener + downloadHandler.listen(identifier) { + called = true } - downloadHandler.cancel(invalidIdentifier) { - Assert.assertFalse(it) + assertTrue(called) + + // reset `called` to false + called = false + // unregister listener + downloadHandler.listen(identifier, null) + // perform an action + downloadHandler.download(successfulTestRequest) + try { + // wait for listener to set `called` to true, if this is the case then fail + waitWhile({ !called }, 3000) + fail("Unregistered listener has been invoked") + } catch (e: Exception) { + // unregistered listener has not been invoked, which is expected behavior + } + } + + @Test + fun testDownloadStarting() { + var result = false + downloadHandler.download(successfulTestRequest) { + result = it } + // wait for `result` to become true + waitWhile({ !result }, 3000) } @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) - } + val identifier = downloadHandler.identify(successfulTestRequest) + + // register listener + var status: DownloadStatus? = null + downloadHandler.listen(identifier) { + status = it + } + // start download + downloadHandler.download(successfulTestRequest) + // wait for download to start + waitWhile({ + status?.state?.equals(DownloadStatus.State.DELETED) == true || + (status?.state?.equals(DownloadStatus.State.PENDING) == false && + status?.state?.equals(DownloadStatus.State.RUNNING) == false) + }, 10000) + assertNotNull(status!!.totalBytes) + assertNotNull(status!!.downloadedBytes) + if (status!!.totalBytes!! >= 0L) { + assertTrue( + status!!.downloadedBytes!! <= status!!.totalBytes!! + ) } } @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) - } + val identifier = downloadHandler.identify(successfulTestRequest) + + // register listener + var status: DownloadStatus? = null + downloadHandler.listen(identifier) { + status = it + } + + var result = false + // start download + downloadHandler.download(successfulTestRequest) { + // cancel running download and check status + downloadHandler.delete(identifier) { + result = true } - ) + } - waitWhile({ !called }) + // wait for `result` to become true + waitWhile({ !result }, 3000) + waitWhile({ status!!.state != DownloadStatus.State.DELETED }, 3000) } @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 - } - ) + val identifier = downloadHandler.identify(successfulTestRequest) - waitWhile({ !called }) - - downloadHandler.status(id!!) { - it!! - Assert.assertEquals(DownloadStatus.State.SUCCESSFUL, it.state) - Assert.assertEquals(it.downloadedBytes, it.totalBytes) + var status: DownloadStatus? = null + downloadHandler.listen(identifier) { + status = it } + // start download + downloadHandler.download(successfulTestRequest) + // wait for download to finish + waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) == false }) + assertNotNull(status!!.totalBytes) + assertNotNull(status!!.downloadedBytes) + assertEquals(status!!.totalBytes, status!!.downloadedBytes) } @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) - } - } - ) + fun testDownloadStatusAfterDelete() { + val identifier = downloadHandler.identify(successfulTestRequest) + + var status: DownloadStatus? = null + downloadHandler.listen(identifier) { + status = it + } + // start download + downloadHandler.download(successfulTestRequest) + // wait for download to finish + waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) == false }) + + var result = false + downloadHandler.delete(identifier) { + result = it + } + // wait for `result` to become true + waitWhile({ !result }, 3000) + waitWhile({ status!!.state != DownloadStatus.State.DELETED }, 3000) } @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 - } - ) + 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) == false }) + } + + @Test + fun testIsDownloadingAnything() { + var result = true + downloadHandler.isDownloadingAnything { + result = it + } + // assert the result is false + waitWhile({ result }, 1000) + + // register listener + var status: DownloadStatus? = null + downloadHandler.listen(downloadHandler.identify(successfulTestRequest)) { + status = it + } + downloadHandler.download(successfulTestRequest) + waitWhile({ + status?.state?.equals(DownloadStatus.State.DELETED) == true || + (status?.state?.equals(DownloadStatus.State.PENDING) == false && + status?.state?.equals(DownloadStatus.State.RUNNING) == false) + }, 10000) + + result = false + downloadHandler.isDownloadingAnything { + result = it + } + // assert the result is true + waitWhile({ !result }, 1000) + } + + @Test + fun testGettingDownloads() { + var result: Map>? = null + downloadHandler.getDownloads(context.preferredStorage) { + result = it + } + // wait for and check result + waitWhile({ result?.size?.equals(0) == false }, 1000) - waitWhile({ !called }) + 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) == false }) + + result = null + downloadHandler.getDownloads(context.preferredStorage) { + result = it + } + // wait for result + waitWhile({ result?.size?.equals(1) == false }, 1000) + } - downloadHandler.status(id!!) { - it!! - Assert.assertEquals(DownloadStatus.State.FAILED, it.state) - Assert.assertNotEquals(it.downloadedBytes, it.totalBytes) + @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 }, 3000) } - private fun waitWhile(condition: () -> Boolean, timeout: Long = 300000) { + protected fun waitWhile(condition: () -> Boolean, timeout: Long = 300000) { var waited = 0 while (condition()) { Thread.sleep(100) waited += 100 if (waited > timeout) { - throw Exception() + throw Exception("Condition timeout") } } } 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 index 1cb003fa1..48a29ed0e 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt @@ -1,23 +1,13 @@ 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 + /*@Rule @JvmField var activityTestRule = ActivityTestRule(MainActivity::class.java, false, true) @@ -154,5 +144,5 @@ abstract class DownloadItemTest, 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 index 07982c690..0753a4062 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadHandlerTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadHandlerTest.kt @@ -1,32 +1,40 @@ package de.xikolo.testing.instrumented.unit +import de.xikolo.download.DownloadCategory 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 +import io.mockk.every +import io.mockk.spyk class FileDownloadHandlerTest : DownloadHandlerTest() { - override var downloadHandler = FileDownloadHandler + override var downloadHandler = spyk(FileDownloadHandler, recordPrivateCalls = true) { + every { this@spyk getProperty "context" } answers { context } + } + override var successfulTestRequest = FileDownloadRequest( SampleMockData.mockVideoStreamSdUrl, createTempFile(directory = context.preferredStorage.file), - "TITLE", - true + "File 1", + true, + DownloadCategory.Other ) override var successfulTestRequest2 = FileDownloadRequest( SampleMockData.mockVideoStreamThumbnailUrl, createTempFile(directory = context.preferredStorage.file), - "TITLE", - true + "File 2", + true, + DownloadCategory.Other ) override var failingTestRequest = FileDownloadRequest( "https://www.example.com/notfoundfilehwqnqkdrzn42r.mp4", createTempFile(directory = context.preferredStorage.file), - "TITLE", - true + "Failing File", + true, + DownloadCategory.Other ) - 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 index 130d16a27..c1c91994e 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadItemTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadItemTest.kt @@ -1,22 +1,13 @@ 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 + /*private val storage = context.preferredStorage override var testDownloadItem = FileDownloadItem(SampleMockData.mockVideoStreamSdUrl, "sdvideo.mp4", storage) @@ -267,5 +258,5 @@ class FileDownloadItemTest : DownloadItemTest() { + + override var downloadHandler = spyk(HlsVideoDownloadHandler, recordPrivateCalls = true) { + every { this@spyk getProperty "context" } answers { context } + } + + override var successfulTestRequest = HlsVideoDownloadRequest( + SampleMockData.mockVideoStreamHlsUrl, + VideoSettingsHelper.VideoQuality.LOW.percent, + context.preferredStorage, + "Video 1", + true, + DownloadCategory.Other + ) + override var successfulTestRequest2 = HlsVideoDownloadRequest( + SampleMockData.mockVideoStreamHlsUrl, + VideoSettingsHelper.VideoQuality.BEST.percent, + context.preferredStorage, + "Video 2", + true, + DownloadCategory.Other + ) + override var failingTestRequest = HlsVideoDownloadRequest( + "https://www.example.com/notfoundfilehwqnqkdrzn42r.m3u8", + VideoSettingsHelper.VideoQuality.BEST.percent, + context.preferredStorage, + "Failing Video", + true, + DownloadCategory.Other + ) + + @Test + fun testDifferentQualities() { + // download 1 + var status1: DownloadStatus? = null + downloadHandler.listen(downloadHandler.identify(successfulTestRequest)) { + status1 = it + } + downloadHandler.download(successfulTestRequest) + + // download 2 + var status2: DownloadStatus? = null + downloadHandler.listen(downloadHandler.identify(successfulTestRequest2)) { + status2 = it + } + downloadHandler.download(successfulTestRequest2) + + // wait for downloads to finish + waitWhile({ + status1?.state?.equals(DownloadStatus.State.DOWNLOADED) == false && + status2?.state?.equals(DownloadStatus.State.DOWNLOADED) == false + }) + + assertNotNull(status1!!.totalBytes) + assertNotNull(status2!!.totalBytes) + assertNotNull(status1!!.downloadedBytes) + assertNotNull(status2!!.downloadedBytes) + assertNotEquals(status1!!.totalBytes, status2!!.totalBytes) + assertNotEquals(status1!!.downloadedBytes, status2!!.downloadedBytes) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7523cec31..0279505c1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,7 +8,6 @@ - showEndState() - DownloadStatus.State.DELETED -> showStartState() + DownloadStatus.State.DELETED -> { + showStartState() + if(status.error != null){ + activity.showToast(R.string.error) + } + } } } } 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 4ab0b5f35..89300097b 100644 --- a/app/src/main/java/de/xikolo/controllers/helper/VideoSettingsHelper.kt +++ b/app/src/main/java/de/xikolo/controllers/helper/VideoSettingsHelper.kt @@ -28,11 +28,18 @@ class VideoSettingsHelper( private val videoInfoCallback: VideoInfoCallback ) { - enum class VideoQuality(val bitrate: Int) { - LOW(200000), - MEDIUM(400000), - HIGH(5000000), - BEST(10000000); + // 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) * bitrateScale / 100 + */ + enum class VideoQuality(val percent: Int) { + LOW(0), + MEDIUM(33), + HIGH(66), + BEST(100); fun toString(context: Context): String { return when (this) { 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 c8495c2ba..3e0bbbc1d 100644 --- a/app/src/main/java/de/xikolo/controllers/video/VideoItemPlayerFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/video/VideoItemPlayerFragment.kt @@ -217,9 +217,9 @@ class VideoItemPlayerFragment : VideoStreamPlayerFragment() { if (item != null && videoDownloadPresent(item)) { playerView.setVideoSource(item.download!!) - item.subs?.let { + /*item.subs?.let { playerView.setSubtitleSources(it) - } + }*/ isOfflineVideo = true return true } 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 08537ec44..68404f206 100644 --- a/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt @@ -602,21 +602,21 @@ open class VideoStreamPlayerFragment : BaseFragment() { } VideoSettingsHelper.PlaybackMode.LOW -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) - playerView.setDesiredBitrate(VideoSettingsHelper.VideoQuality.LOW.bitrate) + playerView.setDesiredBitrate(VideoSettingsHelper.VideoQuality.LOW.percent) } VideoSettingsHelper.PlaybackMode.MEDIUM -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) playerView.setDesiredBitrate( - VideoSettingsHelper.VideoQuality.MEDIUM.bitrate + VideoSettingsHelper.VideoQuality.MEDIUM.percent ) } VideoSettingsHelper.PlaybackMode.HIGH -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) - playerView.setDesiredBitrate(VideoSettingsHelper.VideoQuality.HIGH.bitrate) + playerView.setDesiredBitrate(VideoSettingsHelper.VideoQuality.HIGH.percent) } VideoSettingsHelper.PlaybackMode.BEST -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) - playerView.setDesiredBitrate(VideoSettingsHelper.VideoQuality.BEST.bitrate) + playerView.setDesiredBitrate(VideoSettingsHelper.VideoQuality.BEST.percent) } VideoSettingsHelper.PlaybackMode.LEGACY_HD -> { playerView.setProgressiveVideoUri(Uri.parse(videoStream.hdUrl)) diff --git a/app/src/main/java/de/xikolo/download/DownloadCategory.kt b/app/src/main/java/de/xikolo/download/DownloadCategory.kt index 7cdd9fbeb..80898de25 100644 --- a/app/src/main/java/de/xikolo/download/DownloadCategory.kt +++ b/app/src/main/java/de/xikolo/download/DownloadCategory.kt @@ -33,7 +33,7 @@ sealed class DownloadCategory { * * @param id The course id. */ - class Course(val id: String) : DownloadCategory() + data class Course(val id: String) : DownloadCategory() companion object { @@ -43,7 +43,7 @@ sealed class DownloadCategory { class JsonAdapter : TypeAdapter() { override fun write(out: JsonWriter?, value: DownloadCategory?) { out?.beginObject() - out?.name("category") + out?.name("name") when (value) { is Other -> out?.value("other") is Documents -> out?.value("documents") @@ -57,13 +57,17 @@ sealed class DownloadCategory { out?.endObject() } - override fun read(input: JsonReader?): DownloadCategory { + override fun read(input: JsonReader?): DownloadCategory? { input?.beginObject() + input?.nextName() val category = when (input?.nextString()) { "other" -> Other "documents" -> Documents "certificates" -> Certificates - "course" -> Course(input.nextString()) + "course" -> { + input.nextName() + Course(input.nextString()) + } else -> throw JsonSyntaxException("unsupported category") } input.endObject() diff --git a/app/src/main/java/de/xikolo/download/DownloadHandler.kt b/app/src/main/java/de/xikolo/download/DownloadHandler.kt index f978f4e2b..035b027cd 100644 --- a/app/src/main/java/de/xikolo/download/DownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/DownloadHandler.kt @@ -19,18 +19,25 @@ interface DownloadHandler { */ 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 the [DownloadIdentifier] for the download when it was initiated successfully, - * otherwise this identifier is null. + * It returns true when the download was initiated successfully, otherwise false. * This callback is always invoked if not null. */ fun download( request: R, - callback: ((I?) -> Unit)? = null + callback: ((Boolean) -> Unit)? = null ) /** @@ -38,7 +45,7 @@ interface DownloadHandler { * * @param identifier The identifier of the download. * @param callback An asynchronous callback to deliver a return value. - * It returns true when the download was deleted successfully, otherwise false. + * It returns true when the download deletion was initiated successfully, otherwise false. * This callback is always invoked if not null. */ fun delete( @@ -49,6 +56,7 @@ interface DownloadHandler { /** * Registers a listener for a download that notifies when the download status changes. * Overrides the previous listener. + * The listener is always invoked immediately after registering it. * * @param identifier The identifier of the download. * @param listener An asynchronous callback that is invoked regularly with the most recent diff --git a/app/src/main/java/de/xikolo/download/DownloadItem.kt b/app/src/main/java/de/xikolo/download/DownloadItem.kt index e84a60123..d2b0c964c 100644 --- a/app/src/main/java/de/xikolo/download/DownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/DownloadItem.kt @@ -18,7 +18,7 @@ interface DownloadItem { /** * The download object. * Is null when the download is not available, e.g. it has not been downloaded, - * or when an error occured. + * or when an error occurred. */ val download: D? diff --git a/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt b/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt index 647be5fb1..864c4f2d3 100644 --- a/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt +++ b/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt @@ -36,30 +36,21 @@ abstract class DownloadItemImpl - private var downloaderIdentifier: I? = null - /** * The identifier of the download. - * * Must not be accessed when [downloadable] is false. - * Equal to [itemIdentifier] before [start] has been called. - * After [start] has been called, this is the identifier returned from the download handler. */ final override val identifier: I - get() = downloaderIdentifier ?: itemIdentifier + 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 - /** - * The identifier of the download before [start] hjas been called. - */ - abstract val itemIdentifier: I - private val statusCache: DownloadStatus.DownloadStatusLiveData by lazy { // this has to be lazy initialized because [download] might not be available at class init DownloadStatus.DownloadStatusLiveData( @@ -93,10 +84,8 @@ abstract class DownloadItemImpl { downloader.download( request, - { identifier -> - if (identifier != null) { - downloaderIdentifier = identifier - + { success -> + if (success) { if (this is DownloadAsset.Course.Item) { LanalyticsUtil.trackDownloadedFile(this) } diff --git a/app/src/main/java/de/xikolo/download/DownloadStatus.kt b/app/src/main/java/de/xikolo/download/DownloadStatus.kt index 6cb7026bb..3e52f0121 100644 --- a/app/src/main/java/de/xikolo/download/DownloadStatus.kt +++ b/app/src/main/java/de/xikolo/download/DownloadStatus.kt @@ -6,17 +6,19 @@ import androidx.lifecycle.LiveData * Represents the status of a download. * * @param totalBytes The total size of the download data in bytes. - * This value is null when [state] is [DELETED]. * * @param downloadedBytes The currently downloaded number of bytes. - * Equal to [totalBytes] when [state] is [DOWNLOADED] and null when [state] is [DELETED]. + * 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 state: State, + var error: Throwable? ) { /** * Represents the state a download is in. @@ -62,7 +64,8 @@ data class DownloadStatus( class DownloadStatusLiveData( totalBytes: Long? = null, downloadedBytes: Long? = null, - state: State = State.DELETED + state: State = State.DELETED, + error: Throwable? = null ) : LiveData() { public override fun setValue(value: DownloadStatus) { @@ -91,8 +94,14 @@ data class DownloadStatus( 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)) + super.setValue(DownloadStatus(totalBytes, downloadedBytes, state, error)) } } } 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 4f5d3868e..ee357f342 100644 --- a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadHandler.kt @@ -3,6 +3,7 @@ package de.xikolo.download.filedownload import android.app.NotificationManager import android.content.Context 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 @@ -11,23 +12,37 @@ 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_CATEGORY -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.io.File +import java.util.Locale +/** + * DownloadHandler for progressive downloads to a file. + * Based on [Fetch]. + */ object FileDownloadHandler : DownloadHandler { - private val context = App.instance + 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 disabledNotificationsConfiguration = FetchConfiguration.Builder(context) @@ -53,7 +68,8 @@ object FileDownloadHandler : DownloadHandler, context: Context ): Boolean { - val util = NotificationUtil(context) + val util = NotificationUtil.getInstance(context) if (downloadNotifications.isNotEmpty()) { util.notify( NotificationUtil.DOWNLOAD_RUNNING_NOTIFICATION_ID, util.getDownloadRunningGroupNotification( + this, downloadNotifications.size ) ) @@ -85,7 +102,7 @@ object FileDownloadHandler : DownloadHandler Unit)? - ) { - val req = request.buildRequest() + override fun identify(request: FileDownloadRequest): FileDownloadIdentifier { + return FileDownloadIdentifier(Request(request.url, request.localFile.toUri()).id) + } + + override fun download(request: FileDownloadRequest, callback: ((Boolean) -> Unit)?) { + 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!! + ) + } + } + File(req.file).parentFile?.createIfNotExists() if (req.extras.getBoolean(REQUEST_EXTRA_SHOW_NOTIFICATION, true)) { @@ -224,10 +281,10 @@ object FileDownloadHandler : DownloadHandler DownloadStatus.State.DELETED Status.DOWNLOADING, Status.PAUSED -> DownloadStatus.State.RUNNING else -> throw Exception() - } + }, + download.error.throwable ) } catch (e: Exception) { - DownloadStatus(null, null, DownloadStatus.State.DELETED) + 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 81b25b781..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,10 +2,18 @@ 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( 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 fbb967657..27cafed38 100644 --- a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadItem.kt @@ -13,6 +13,14 @@ import de.xikolo.utils.extensions.preferredStorage import de.xikolo.utils.extensions.sdcardStorage 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, @@ -69,9 +77,6 @@ open class FileDownloadItem( category ) - final override val itemIdentifier: FileDownloadIdentifier - get() = FileDownloadIdentifier(request.buildRequest().id) - override fun onStatusChanged(newStatus: DownloadStatus) { super.onStatusChanged(newStatus) when (newStatus.state) { @@ -84,14 +89,27 @@ open class FileDownloadItem( } } + /** + * 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 } + /** + * The absolute file path of the download. + */ val filePath: String get() = getFileFolder() + File.separator + fileName + /** + * The MIME-type of the download. + */ protected open val mimeType = "application/pdf" + /** + * 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 81a3aa8a8..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,71 +1,22 @@ package de.xikolo.download.filedownload -import androidx.core.net.toUri -import com.google.gson.Gson -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, override val category: DownloadCategory -) : DownloadRequest { - - companion object { - const val REQUEST_EXTRA_TITLE = "title" - const val REQUEST_EXTRA_SHOW_NOTIFICATION = "showNotification" - const val REQUEST_EXTRA_CATEGORY = "category" - } - - fun buildRequest(): Request { - return Request(url, localFile.toUri()).apply { - networkType = - if (ApplicationPreferences().isDownloadNetworkLimitedOnMobile) { - NetworkType.WIFI_ONLY - } else { - NetworkType.ALL - } - - val extrasMap = - mutableMapOf( - REQUEST_EXTRA_TITLE to title, - REQUEST_EXTRA_SHOW_NOTIFICATION to showNotification.toString() - ).apply { - put(REQUEST_EXTRA_CATEGORY, Gson().toJson(category)) - } - - 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!! - ) - } - } - } -} +) : 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 index 250f15605..7dfcd6b70 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt @@ -1,18 +1,25 @@ package de.xikolo.download.hlsvideodownload import android.content.Context +import android.net.Uri import android.os.Handler import android.os.Looper +import com.google.android.exoplayer2.DefaultRenderersFactory +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 @@ -29,11 +36,18 @@ 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 +/** + * DownloadHandler class for ExoPlayer HLS video downloads. + */ object HlsVideoDownloadHandler : DownloadHandler { + private val context: Context + get() = App.instance + private val downloads: MutableMap = mutableMapOf() private val listeners: MutableMap Unit)?> = mutableMapOf() @@ -48,8 +62,6 @@ object HlsVideoDownloadHandler : val dataSourceFactory = DefaultHttpDataSourceFactory(Config.HEADER_USER_AGENT_VALUE) - val context = App.instance - init { DownloadService.start( context, @@ -116,13 +128,11 @@ object HlsVideoDownloadHandler : downloads[identifier] = download notifyStatus(identifier) - val args = HlsVideoDownloadRequest.ArgumentWrapper.decode( - download.request.data - ) + val args = ArgumentWrapper.decode(download.request.data) if (download.state == Download.STATE_COMPLETED && args.showNotification ) { - NotificationUtil(context).showDownloadCompletedNotification( + NotificationUtil.getInstance(context).showDownloadCompletedNotification( args.title ) } @@ -166,61 +176,99 @@ object HlsVideoDownloadHandler : ) } - override fun download( - request: HlsVideoDownloadRequest, - callback: ((HlsVideoDownloadIdentifier?) -> Unit)? - ) { - request.buildRequest(context) { downloadRequest -> - if (downloadRequest == null) { - callback?.invoke(null) - return@buildRequest - } + override fun identify(request: HlsVideoDownloadRequest): HlsVideoDownloadIdentifier { + return HlsVideoDownloadIdentifier(request.url, request.quality) + } - val identifier = downloadRequest.id - downloads[identifier] = Download( - downloadRequest, - Download.STATE_QUEUED, - 0, - 0, - -1, - Download.STOP_REASON_NONE, - Download.FAILURE_REASON_NONE - ) + override fun download(request: HlsVideoDownloadRequest, callback: ((Boolean) -> Unit)?) { + 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 subtitles = manifest.masterPlaylist.subtitles.map { + it.format.language + } + helper.clearTrackSelections(0) + helper.addTrackSelection( + 0, + DefaultTrackSelector.ParametersBuilder(context) + .setForceHighestSupportedBitrate(true) + .build() + ) + helper.addTextLanguagesToSelection( + true, + //*(subtitles?.keys?.toTypedArray() ?: arrayOf()) + ) + val downloadRequest = helper.getDownloadRequest( + identify(request).get(), + ArgumentWrapper( + request.title, + request.showNotification, + request.category + ).encode() + ) + 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) { + HlsVideoDownloadInternalStorageForegroundService::class.java + } else if (request.storage == context.sdcardStorage && + getSdcardStorageCache(context) != null + ) { + HlsVideoDownloadSdcardStorageForegroundService::class.java + } else { + throw Exception() + } + + DownloadService.sendAddDownload( + context, + service, + downloadRequest, + true + ) - try { - val service = - if (request.storage == context.internalStorage) { - HlsVideoDownloadInternalStorageForegroundService::class.java - } else if (request.storage == context.sdcardStorage && - getSdcardStorageCache(context) != null - ) { - HlsVideoDownloadSdcardStorageForegroundService::class.java - } else { - throw Exception() + callback?.invoke(true) + } catch (e: Exception) { + downloads[identifier] = Download( + downloadRequest, + Download.STATE_FAILED, + 0, + 0, + -1, + Download.STOP_REASON_NONE, + Download.FAILURE_REASON_UNKNOWN + ) + notifyStatus(identifier) + callback?.invoke(false) } + } - DownloadService.sendAddDownload( - context, - service, - downloadRequest, - true - ) - - callback?.invoke(request.identifier) - } catch (e: Exception) { - downloads[identifier] = Download( - downloadRequest, - Download.STATE_FAILED, - 0, - 0, - -1, - Download.STOP_REASON_NONE, - Download.FAILURE_REASON_UNKNOWN - ) - notifyStatus(identifier) - callback?.invoke(null) + override fun onPrepareError(helper: DownloadHelper, e: IOException) { + callback?.invoke(false) + helper.release() + } } - } + ) } override fun delete(identifier: HlsVideoDownloadIdentifier, callback: ((Boolean) -> Unit)?) { @@ -285,7 +333,7 @@ object HlsVideoDownloadHandler : while (!it.isAfterLast) { map[ HlsVideoDownloadIdentifier.from(it.download.request.id) - ] = getDownloadStatus(it.download) to HlsVideoDownloadRequest.ArgumentWrapper + ] = getDownloadStatus(it.download) to ArgumentWrapper .decode(it.download.request.data).category it.moveToNext() } @@ -296,7 +344,7 @@ object HlsVideoDownloadHandler : private fun getDownloadStatus(download: Download?): DownloadStatus { if (download == null) { - return DownloadStatus(null, null, DownloadStatus.State.DELETED) + return DownloadStatus(null, null, DownloadStatus.State.DELETED, null) } val totalSize = download.contentLength.takeUnless { it < 0 } @@ -314,7 +362,10 @@ object HlsVideoDownloadHandler : Download.STATE_DOWNLOADING -> DownloadStatus.State.RUNNING Download.STATE_COMPLETED -> DownloadStatus.State.DOWNLOADED else -> DownloadStatus.State.DELETED - } + }, + if(download.state == Download.STATE_FAILED){ + Exception("Download failed with reason ${download.failureReason}") + } else null ) } @@ -345,4 +396,17 @@ object HlsVideoDownloadHandler : private fun stopNotifierThread(manager: DownloadManager) { notifierThreadsRunning[manager] = false } + + internal data class ArgumentWrapper( + val title: String, + val showNotification: Boolean, + val category: DownloadCategory + ) { + 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 index d4cbca010..f26c3be71 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadIdentifier.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadIdentifier.kt @@ -2,18 +2,33 @@ 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: Int ) : 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()) } } + /** + * Returns the internal identifier. + */ fun get(): String { return "$quality;$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 index ce31e8698..b8f613095 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt @@ -1,15 +1,10 @@ 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.MediaSource -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 @@ -22,7 +17,7 @@ open class HlsVideoDownloadItem( val url: String?, val category: DownloadCategory, val quality: Int, - val subtitles: Map?, + //val subtitles: Map?, storage: Storage = App.instance.preferredStorage ) : DownloadItemImpl(storage) { @@ -75,7 +70,7 @@ open class HlsVideoDownloadItem( } } - val subs: Map? + /*val subs: Map? get() { fun getMediaSource(language: String, url: String): MediaSource = SingleSampleMediaSource.Factory( @@ -108,21 +103,17 @@ open class HlsVideoDownloadItem( } finally { storage = originalStorage } - } + }*/ final override val request get() = HlsVideoDownloadRequest( url!!, quality, - subtitles, - identifier, + //subtitles, storage, title, true, category ) - - final override val itemIdentifier: HlsVideoDownloadIdentifier - get() = HlsVideoDownloadIdentifier(url!!, quality) } diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt index b151b7450..e2d623137 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt @@ -1,26 +1,33 @@ package de.xikolo.download.hlsvideodownload -import android.content.Context import android.net.Uri -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.DefaultRenderersFactory import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.offline.DownloadHelper import com.google.android.exoplayer2.offline.StreamKey import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector -import com.google.android.exoplayer2.util.MimeTypes -import com.google.gson.Gson import de.xikolo.download.DownloadCategory import de.xikolo.download.DownloadRequest import de.xikolo.models.Storage -import java.io.IOException +/** + * DownloadRequest class for HLS video downloads. + * Video, audio and subtitle tracks are downloaded. + * + * @param url The HLS master playlist URL. + * @param quality The desired bitrate of the video as in [VideoSettingsHelper.VideoQuality] + * bitratePercent. + * 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 subtitles The subtitles to download along the video. + * @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 desiredBitrate: Int, - val subtitles: Map?, - val identifier: HlsVideoDownloadIdentifier, + val quality: Int, + //val subtitles: Map?, val storage: Storage, override val title: String, override val showNotification: Boolean, @@ -29,7 +36,7 @@ class HlsVideoDownloadRequest( val mediaItem = MediaItem.Builder() .setUri(Uri.parse(url)) - .setSubtitles( + /*.setSubtitles( subtitles?.map { MediaItem.Subtitle( Uri.parse(it.value), @@ -38,7 +45,7 @@ class HlsVideoDownloadRequest( C.SELECTION_FLAG_DEFAULT ) } - ) + )*/ .setStreamKeys( listOf( StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 1), @@ -47,57 +54,4 @@ class HlsVideoDownloadRequest( ) ) .build() - - fun buildRequest( - context: Context, - callback: (com.google.android.exoplayer2.offline.DownloadRequest?) -> Unit - ) { - val helper = DownloadHelper.forMediaItem( - context, - mediaItem, - DefaultRenderersFactory(context), - HlsVideoDownloadHandler.dataSourceFactory - ) - helper.prepare( - object : DownloadHelper.Callback { - override fun onPrepared(helper: DownloadHelper) { - helper.clearTrackSelections(0) - helper.addTrackSelection( - 0, - DefaultTrackSelector.ParametersBuilder(context) - .setForceHighestSupportedBitrate(true) - .build() - ) - helper.addTextLanguagesToSelection( - true, - *(subtitles?.keys?.toTypedArray() ?: arrayOf()) - ) - val request = helper.getDownloadRequest( - identifier.get(), - ArgumentWrapper(title, showNotification, category).encode() - ) - helper.release() - callback(request) - } - - override fun onPrepareError(helper: DownloadHelper, e: IOException) { - callback(null) - helper.release() - } - } - ) - } - - internal data class ArgumentWrapper( - val title: String, - val showNotification: Boolean, - val category: DownloadCategory - ) { - 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/services/HlsVideoDownloadForegroundService.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadForegroundService.kt index ad8609743..7f4e874a1 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadForegroundService.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/services/HlsVideoDownloadForegroundService.kt @@ -9,7 +9,6 @@ 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.download.hlsvideodownload.HlsVideoDownloadRequest import de.xikolo.utils.NotificationUtil abstract class HlsVideoDownloadForegroundService : DownloadService( @@ -27,9 +26,9 @@ abstract class HlsVideoDownloadForegroundService : DownloadService( } override fun getForegroundNotification(downloads: List): Notification { - val notificationUtil = NotificationUtil(applicationContext) + val notificationUtil = NotificationUtil.getInstance(applicationContext) val notifications = downloads.mapNotNull { - val args = HlsVideoDownloadRequest.ArgumentWrapper.decode(it.request.data) + val args = HlsVideoDownloadHandler.ArgumentWrapper.decode(it.request.data) if (args.showNotification && it.state != Download.STATE_REMOVING) { args.hashCode() to notificationUtil.updateDownloadRunningNotification( notificationUtil.getDownloadRunningNotification(), @@ -40,7 +39,7 @@ abstract class HlsVideoDownloadForegroundService : DownloadService( 0, buildRemoveDownloadIntent( applicationContext, - HlsVideoDownloadForegroundService::class.java, + this::class.java, it.request.id, true ), @@ -53,7 +52,7 @@ abstract class HlsVideoDownloadForegroundService : DownloadService( notifications.forEach { notificationUtil.notify(it.first, it.second) } - return notificationUtil.getDownloadRunningGroupNotification(downloads.size) + return notificationUtil.getDownloadRunningGroupNotification(this, downloads.size) } override fun getScheduler(): PlatformScheduler? { diff --git a/app/src/main/java/de/xikolo/models/DownloadAsset.kt b/app/src/main/java/de/xikolo/models/DownloadAsset.kt index 09dd4f72c..898213b02 100644 --- a/app/src/main/java/de/xikolo/models/DownloadAsset.kt +++ b/app/src/main/java/de/xikolo/models/DownloadAsset.kt @@ -105,10 +105,10 @@ object DownloadAsset { HlsVideoDownloadItem( video.streamToPlay?.hlsUrl, DownloadCategory.Course(item.courseId), - quality.bitrate, + quality.percent/*, video.subtitles.associate { it.language to it.vttUrl - } + }*/ ) { override val title = App.instance.getString(R.string.video) + ": " + item.title 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/utils/NotificationUtil.kt b/app/src/main/java/de/xikolo/utils/NotificationUtil.kt index 1a21596d3..c1ce87a44 100644 --- a/app/src/main/java/de/xikolo/utils/NotificationUtil.kt +++ b/app/src/main/java/de/xikolo/utils/NotificationUtil.kt @@ -19,7 +19,7 @@ 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" @@ -31,16 +31,12 @@ class NotificationUtil(base: Context) : ContextWrapper(base) { 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 val manager: NotificationManager by lazy { @@ -59,7 +55,21 @@ class NotificationUtil(base: Context) : ContextWrapper(base) { manager.cancel(id) } - fun getDownloadRunningGroupNotification(count: Int): Notification { + 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) @@ -74,7 +84,7 @@ class NotificationUtil(base: Context) : ContextWrapper(base) { .setContentText( getString( R.string.notification_multiple_downloads_running, - count + notificationCountMap.values.sum() ) ) .setPriority(NotificationCompat.PRIORITY_LOW) diff --git a/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt b/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt index 3e64fc40d..b9c370f9d 100644 --- a/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt +++ b/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt @@ -98,9 +98,7 @@ open class ExoPlayerVideoView : PlayerView { AdaptiveTrackSelection.Factory() ) - exoplayer = SimpleExoPlayer.Builder( - context - ) + exoplayer = SimpleExoPlayer.Builder(context) .setTrackSelector(trackSelector) .build() From ad9cdc1d0038639d2d5d073df67c3a5250a3288b Mon Sep 17 00:00:00 2001 From: Ben Engelhaupt Date: Sun, 18 Apr 2021 20:26:14 +0200 Subject: [PATCH 07/15] add DownloadItem tests and implement bitrate selection --- .../instrumented/unit/DownloadHandlerTest.kt | 5 +- .../instrumented/unit/DownloadItemTest.kt | 163 +++++------ .../instrumented/unit/FileDownloadItemTest.kt | 276 ++++-------------- .../unit/HlsVideoDownloadHandlerTest.kt | 40 +-- .../unit/HlsVideoDownloadItemTest.kt | 71 +++++ .../controllers/helper/VideoSettingsHelper.kt | 12 +- .../video/VideoStreamPlayerFragment.kt | 12 +- .../java/de/xikolo/download/DownloadItem.kt | 3 +- .../de/xikolo/download/DownloadItemImpl.kt | 4 - .../HlsVideoDownloadHandler.kt | 45 ++- .../HlsVideoDownloadIdentifier.kt | 6 +- .../hlsvideodownload/HlsVideoDownloadItem.kt | 2 +- .../HlsVideoDownloadRequest.kt | 6 +- .../java/de/xikolo/models/DownloadAsset.kt | 2 +- .../xikolo/utils/extensions/CastExtensions.kt | 9 +- .../de/xikolo/views/ExoPlayerVideoView.kt | 8 +- 16 files changed, 268 insertions(+), 396 deletions(-) create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadItemTest.kt 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 index ed786339c..ebd448d2a 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt @@ -24,11 +24,12 @@ abstract class DownloadHandlerTest, @Rule @JvmField - var activityTestRule = ActivityTestRule(MainActivity::class.java, false, true) + var activityTestRule = + ActivityTestRule(MainActivity::class.java, false, true) @Rule @JvmField - var permissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE) + var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE) abstract var downloadHandler: T 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 index 48a29ed0e..ea44f3e91 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt @@ -1,24 +1,35 @@ package de.xikolo.testing.instrumented.unit +import android.Manifest +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.download.DownloadStatus 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 + @Rule @JvmField var activityTestRule = ActivityTestRule(MainActivity::class.java, false, true) @Rule @JvmField - var permissionRule = - GrantPermissionRule.grant(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE) - abstract var testDownloadItem: T - abstract var testDownloadItemNotDownloadable: T + abstract val testDownloadItem: T + abstract val testDownloadItemNotDownloadable: T @Before fun deleteItem() { @@ -26,113 +37,89 @@ abstract class DownloadItemTest, } @Test - fun testIsDownloadable() { - assertTrue(testDownloadItem.isDownloadable) - assertFalse(testDownloadItemNotDownloadable.isDownloadable) + fun testIdentifier() { + testDownloadItem.identifier } @Test - fun testStateBeforeDownload() { - testDownloadItem.isDownloadRunning { - assertFalse(it) - - assertNull(testDownloadItem.download) - assertFalse(testDownloadItem.downloadExists) - } + fun testDownload() { + testDownloadItem.download + assertNull(testDownloadItemNotDownloadable.download) } @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) + fun testDownloadable() { + assertTrue(testDownloadItem.downloadable) + assertFalse(testDownloadItemNotDownloadable.downloadable) + } - assertTrue(started) - testDownloadItem.isDownloadRunning { - assertTrue(it) + @Test + fun testTitle() { + testDownloadItem.title + testDownloadItemNotDownloadable.title + } - testDownloadItem.start(activityTestRule.activity) { - assertNull(it) + @Test + fun testOpenAction() { + testDownloadItem.openAction + testDownloadItemNotDownloadable.openAction + } - waitWhile({ !completed }) + @Test + fun testSize() { + testDownloadItem.size + testDownloadItemNotDownloadable.size + } - assertTrue(testDownloadItem.downloadExists) - assertNotNull(testDownloadItem.download) - } - } - } + @Test + fun testStatus() { + testDownloadItem.status + testDownloadItemNotDownloadable.status } @Test - fun testCancelDownload() { - testDownloadItem.start(activityTestRule.activity) { + fun testStatusBefore() { + testDownloadItem.status.observe(activityTestRule.activity){ assertNotNull(it) - - testDownloadItem.isDownloadRunning { - assertTrue(it) - - testDownloadItem.cancel(activityTestRule.activity) { - assertTrue(it) - - testDownloadItem.isDownloadRunning { - assertFalse(it) - - assertFalse(testDownloadItem.downloadExists) - assertNull(testDownloadItem.download) - } - } - } + 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 + fun testDownloadAndDelete() { + var downloaded = false + testDownloadItem.status.observe(activityTestRule.activity){ + if(it.state == DownloadStatus.State.DOWNLOADED) { + downloaded = true } } - testDownloadItem.delete(activityTestRule.activity) { - assertFalse(it) - - testDownloadItem.start(activityTestRule.activity) { - assertNotNull(it) - - waitWhile({ !completed }) + assertNull(testDownloadItem.download) - assertTrue(testDownloadItem.downloadExists) + var startResult = false + testDownloadItem.start(activityTestRule.activity){ + startResult = it + } - testDownloadItem.delete(activityTestRule.activity) { - assertTrue(it) + waitWhile({!startResult}, 3000) + waitWhile({!downloaded}) + assertNotNull(testDownloadItem.download) - assertTrue(deleted) - assertFalse(testDownloadItem.downloadExists) - assertNull(testDownloadItem.download) - } + var deleted = false + testDownloadItem.status.observe(activityTestRule.activity){ + if(it.state == DownloadStatus.State.DELETED) { + deleted = true } } + + var deleteResult = false + testDownloadItem.delete(activityTestRule.activity){ + deleteResult = it + } + + waitWhile({!deleteResult}, 3000) + waitWhile({!deleted}) + assertNull(testDownloadItem.download) } protected fun waitWhile(condition: () -> Boolean, timeout: Long = 300000) { @@ -141,8 +128,8 @@ abstract class DownloadItemTest, Thread.sleep(100) waited += 100 if (waited > timeout) { - throw Exception() + throw Exception("Condition timeout") } } - }*/ + } } 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 index c1c91994e..f79321caf 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadItemTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadItemTest.kt @@ -1,262 +1,84 @@ package de.xikolo.testing.instrumented.unit +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.testing.instrumented.mocking.SampleMockData +import de.xikolo.utils.extensions.preferredStorage +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test import java.io.File class FileDownloadItemTest : DownloadItemTest() { - /*private val storage = context.preferredStorage + 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", + override val testDownloadItem = FileDownloadItem( + SampleMockData.mockVideoStreamSdUrl, + DownloadCategory.Other, + "sdvideo.mp4", 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) + override val testDownloadItemNotDownloadable = FileDownloadItem( + null, + DownloadCategory.Other, + "null.null" + ) - testSecondaryItem.delete(activityTestRule.activity) + @Test + fun testFileName() { + testDownloadItem.fileName + testDownloadItemNotDownloadable.fileName } @Test - fun testDeletesTempFile() { - var completedMain = false - testDownloadItem.stateListener = object : DownloadItem.StateListener { - override fun onStarted() {} - - override fun onCompleted() { - completedMain = true + fun testFileHandling() { + var downloaded = false + testDownloadItem.status.observe(activityTestRule.activity) { + if (it.state == DownloadStatus.State.DOWNLOADED) { + downloaded = true } - - override fun onDeleted() {} } + testDownloadItem.start(activityTestRule.activity) { + assertFalse( + File(testDownloadItem.filePath).exists() + ) assertTrue( File(testDownloadItem.filePath + ".tmp").exists() ) - waitWhile({ !completedMain }) + waitWhile({ !downloaded }) + assertTrue( + File(testDownloadItem.filePath).exists() + ) assertFalse( File(testDownloadItem.filePath + ".tmp").exists() ) + assertTrue( + testDownloadItem.download?.exists() == true + ) - 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) - } + var deleted = false + testDownloadItem.status.observe(activityTestRule.activity) { + if (it.state == DownloadStatus.State.DELETED) { + deleted = true } } - } - } - - @Test - fun testDeleteDownloadWithSecondary() { - var completedMain = false - var deletedMain = false - testDownloadItemWithSecondary.stateListener = object : DownloadItem.StateListener { - override fun onStarted() {} - override fun onCompleted() { - completedMain = true - } + testDownloadItem.delete(activityTestRule.activity) + waitWhile({ !deleted }, 3000) - 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) - } - } + assertFalse( + File(testDownloadItem.filePath).exists() + ) + assertFalse( + testDownloadItem.download?.exists() == true + ) } } - - @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/HlsVideoDownloadHandlerTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadHandlerTest.kt index 3a797c638..10bb3c3b2 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadHandlerTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadHandlerTest.kt @@ -2,7 +2,6 @@ package de.xikolo.testing.instrumented.unit 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 @@ -10,9 +9,6 @@ import de.xikolo.testing.instrumented.mocking.SampleMockData import de.xikolo.utils.extensions.preferredStorage import io.mockk.every import io.mockk.spyk -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertNotNull -import org.junit.Test class HlsVideoDownloadHandlerTest : DownloadHandlerTest() { @@ -23,7 +19,7 @@ class HlsVideoDownloadHandlerTest : DownloadHandlerTest() { + + private val storage = context.preferredStorage + + override val testDownloadItem = HlsVideoDownloadItem( + SampleMockData.mockVideoStreamHlsUrl, + DownloadCategory.Other, + VideoSettingsHelper.VideoQuality.HIGH.qualityFraction, + storage + ) + override val testDownloadItemNotDownloadable = HlsVideoDownloadItem( + null, + DownloadCategory.Other, + 0.0f, + storage + ) + + @Test + fun testQualitySelection() { + val testDownloadItemOtherQuality = HlsVideoDownloadItem( + SampleMockData.mockVideoStreamHlsUrl, + DownloadCategory.Other, + VideoSettingsHelper.VideoQuality.LOW.qualityFraction, + storage + ) + + assertNotEquals(testDownloadItem.identifier, testDownloadItemOtherQuality.identifier) + + var downloaded = false + testDownloadItem.status.observe(activityTestRule.activity){ + if(it.state == DownloadStatus.State.DOWNLOADED){ + downloaded = true + } + } + var downloadedOther = false + testDownloadItemOtherQuality.status.observe(activityTestRule.activity){ + if(it.state == DownloadStatus.State.DOWNLOADED){ + downloadedOther = true + } + } + + testDownloadItem.start(activityTestRule.activity) + testDownloadItemOtherQuality.start(activityTestRule.activity) + + waitWhile({!downloaded && !downloadedOther}) + + assertNotEquals( + testDownloadItem.size, + testDownloadItemOtherQuality.size + ) + + assertNotEquals( + testDownloadItem.status.totalBytes, + testDownloadItemOtherQuality.status.totalBytes + ) + } +} 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 89300097b..fc66846da 100644 --- a/app/src/main/java/de/xikolo/controllers/helper/VideoSettingsHelper.kt +++ b/app/src/main/java/de/xikolo/controllers/helper/VideoSettingsHelper.kt @@ -33,13 +33,13 @@ class VideoSettingsHelper( * Video quality classes based on adaptive bitrate selection by specifying it as a fraction of * the bitrate range. * - * targetBitrate = lowestBitrate + (highestBitrate - lowestBitrate) * bitrateScale / 100 + * targetBitrate = lowestBitrate + (highestBitrate - lowestBitrate) * qualityFraction */ - enum class VideoQuality(val percent: Int) { - LOW(0), - MEDIUM(33), - HIGH(66), - BEST(100); + enum class VideoQuality(val qualityFraction: Float) { + LOW(0f), + MEDIUM(0.33f), + HIGH(0.67f), + BEST(1f); fun toString(context: Context): String { return when (this) { 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 68404f206..f75797604 100644 --- a/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt @@ -598,25 +598,25 @@ open class VideoStreamPlayerFragment : BaseFragment() { when (mode) { VideoSettingsHelper.PlaybackMode.AUTO -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) - playerView.setDesiredBitrate(null) + playerView.setDesiredQuality(null) } VideoSettingsHelper.PlaybackMode.LOW -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) - playerView.setDesiredBitrate(VideoSettingsHelper.VideoQuality.LOW.percent) + playerView.setDesiredQuality(VideoSettingsHelper.VideoQuality.LOW.qualityFraction) } VideoSettingsHelper.PlaybackMode.MEDIUM -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) - playerView.setDesiredBitrate( - VideoSettingsHelper.VideoQuality.MEDIUM.percent + playerView.setDesiredQuality( + VideoSettingsHelper.VideoQuality.MEDIUM.qualityFraction ) } VideoSettingsHelper.PlaybackMode.HIGH -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) - playerView.setDesiredBitrate(VideoSettingsHelper.VideoQuality.HIGH.percent) + playerView.setDesiredQuality(VideoSettingsHelper.VideoQuality.HIGH.qualityFraction) } VideoSettingsHelper.PlaybackMode.BEST -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) - playerView.setDesiredBitrate(VideoSettingsHelper.VideoQuality.BEST.percent) + playerView.setDesiredQuality(VideoSettingsHelper.VideoQuality.BEST.qualityFraction) } VideoSettingsHelper.PlaybackMode.LEGACY_HD -> { playerView.setProgressiveVideoUri(Uri.parse(videoStream.hdUrl)) diff --git a/app/src/main/java/de/xikolo/download/DownloadItem.kt b/app/src/main/java/de/xikolo/download/DownloadItem.kt index d2b0c964c..05eb0ad64 100644 --- a/app/src/main/java/de/xikolo/download/DownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/DownloadItem.kt @@ -12,6 +12,7 @@ interface DownloadItem { /** * The identifier of the download. + * Must not be accessed when [downloadable] is false. */ val identifier: I @@ -62,7 +63,7 @@ interface DownloadItem { * * @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 was deleted successfully, otherwise false. + * 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 index 864c4f2d3..87ff239aa 100644 --- a/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt +++ b/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt @@ -36,10 +36,6 @@ abstract class DownloadItemImpl - /** - * The identifier of the download. - * Must not be accessed when [downloadable] is false. - */ final override val identifier: I get() = downloader.identify(request) diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt index 7dfcd6b70..3bc7b1f13 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt @@ -38,6 +38,8 @@ 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. @@ -132,9 +134,10 @@ object HlsVideoDownloadHandler : if (download.state == Download.STATE_COMPLETED && args.showNotification ) { - NotificationUtil.getInstance(context).showDownloadCompletedNotification( - args.title - ) + NotificationUtil.getInstance(context) + .showDownloadCompletedNotification( + args.title + ) } startNotifierThread(downloadManager) @@ -191,23 +194,41 @@ object HlsVideoDownloadHandler : object : DownloadHelper.Callback { override fun onPrepared(helper: DownloadHelper) { val manifest = helper.manifest as HlsManifest - val formats = manifest.masterPlaylist.variants.map { - it.format - } - val subtitles = manifest.masterPlaylist.subtitles.map { - it.format.language - } + + val bitrates = manifest.masterPlaylist.variants.map { it.format.bitrate } + val lowestBitrate = bitrates.minOrNull() ?: 0 + val highestBitrate = bitrates.maxOrNull() ?: 0 + val targetBitrate = lowestBitrate + request.quality * + (highestBitrate - lowestBitrate) + val closestBitrate = bitrates + .map { + abs(it - targetBitrate).roundToInt() + } + .minOrNull() + + val subtitles = manifest.masterPlaylist.subtitles + .mapNotNull { + it.format.language + } + .toTypedArray() + helper.clearTrackSelections(0) helper.addTrackSelection( 0, DefaultTrackSelector.ParametersBuilder(context) - .setForceHighestSupportedBitrate(true) + .apply { + if (closestBitrate != null) { + setMinVideoBitrate(closestBitrate - 1) + setMaxVideoBitrate(closestBitrate + 1) + } + } .build() ) helper.addTextLanguagesToSelection( true, - //*(subtitles?.keys?.toTypedArray() ?: arrayOf()) + *subtitles ) + val downloadRequest = helper.getDownloadRequest( identify(request).get(), ArgumentWrapper( @@ -363,7 +384,7 @@ object HlsVideoDownloadHandler : Download.STATE_COMPLETED -> DownloadStatus.State.DOWNLOADED else -> DownloadStatus.State.DELETED }, - if(download.state == Download.STATE_FAILED){ + if (download.state == Download.STATE_FAILED) { Exception("Download failed with reason ${download.failureReason}") } else null ) diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadIdentifier.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadIdentifier.kt index f26c3be71..ea79520d5 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadIdentifier.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadIdentifier.kt @@ -10,7 +10,7 @@ import de.xikolo.download.DownloadIdentifier */ data class HlsVideoDownloadIdentifier( private val url: String, - private val quality: Int + private val quality: Float ) : DownloadIdentifier { companion object { @@ -22,7 +22,7 @@ data class HlsVideoDownloadIdentifier( */ fun from(identifier: String): HlsVideoDownloadIdentifier { val parts = identifier.split(";", limit = 2) - return HlsVideoDownloadIdentifier(parts[1], parts[0].toInt()) + return HlsVideoDownloadIdentifier(parts[1], parts[0].toInt() / 100.0f) } } @@ -30,6 +30,6 @@ data class HlsVideoDownloadIdentifier( * Returns the internal identifier. */ fun get(): String { - return "$quality;$url" + 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 index b8f613095..4671ad46d 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt @@ -16,7 +16,7 @@ import de.xikolo.utils.extensions.sdcardStorage open class HlsVideoDownloadItem( val url: String?, val category: DownloadCategory, - val quality: Int, + val quality: Float, //val subtitles: Map?, storage: Storage = App.instance.preferredStorage ) : DownloadItemImpl(storage) { diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt index e2d623137..56c15d5cc 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt @@ -13,8 +13,8 @@ import de.xikolo.models.Storage * Video, audio and subtitle tracks are downloaded. * * @param url The HLS master playlist URL. - * @param quality The desired bitrate of the video as in [VideoSettingsHelper.VideoQuality] - * bitratePercent. + * @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. @@ -26,7 +26,7 @@ import de.xikolo.models.Storage */ class HlsVideoDownloadRequest( val url: String, - val quality: Int, + val quality: Float, //val subtitles: Map?, val storage: Storage, override val title: String, diff --git a/app/src/main/java/de/xikolo/models/DownloadAsset.kt b/app/src/main/java/de/xikolo/models/DownloadAsset.kt index 898213b02..68ba6f3da 100644 --- a/app/src/main/java/de/xikolo/models/DownloadAsset.kt +++ b/app/src/main/java/de/xikolo/models/DownloadAsset.kt @@ -105,7 +105,7 @@ object DownloadAsset { HlsVideoDownloadItem( video.streamToPlay?.hlsUrl, DownloadCategory.Course(item.courseId), - quality.percent/*, + quality.qualityFraction/*, video.subtitles.associate { it.language to it.vttUrl }*/ 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 d2147ce4f..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,10 +112,17 @@ fun T.cast(activity: Activity, autoPlay: Boolean): PendingResult Date: Sun, 18 Apr 2021 20:52:00 +0200 Subject: [PATCH 08/15] add size estimation logic --- .../hlsvideodownload/HlsVideoDownloadHandler.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt index 3bc7b1f13..2f234c39d 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt @@ -205,6 +205,8 @@ object HlsVideoDownloadHandler : abs(it - targetBitrate).roundToInt() } .minOrNull() + val estimatedSize = closestBitrate + ?.times(manifest.mediaPlaylist.durationUs * 1000000 / 8) val subtitles = manifest.masterPlaylist.subtitles .mapNotNull { @@ -234,7 +236,8 @@ object HlsVideoDownloadHandler : ArgumentWrapper( request.title, request.showNotification, - request.category + request.category, + estimatedSize ).encode() ) helper.release() @@ -368,7 +371,8 @@ object HlsVideoDownloadHandler : return DownloadStatus(null, null, DownloadStatus.State.DELETED, null) } - val totalSize = download.contentLength.takeUnless { it < 0 } + val estimatedSize = ArgumentWrapper.decode(download.request.data).estimatedSize + val totalSize = download.contentLength.takeUnless { it < 0 } ?: estimatedSize ?: if (download.state == Download.STATE_COMPLETED) { download.bytesDownloaded } else { @@ -421,7 +425,8 @@ object HlsVideoDownloadHandler : internal data class ArgumentWrapper( val title: String, val showNotification: Boolean, - val category: DownloadCategory + val category: DownloadCategory, + val estimatedSize: Long? ) { companion object { fun decode(data: ByteArray): ArgumentWrapper = From 94bd593b5c75e0f9727bce5bb0c58009ae9f5d91 Mon Sep 17 00:00:00 2001 From: Ben Engelhaupt Date: Wed, 21 Apr 2021 18:57:09 +0200 Subject: [PATCH 09/15] adjust tests --- .../instrumented/unit/DownloadHandlerTest.kt | 26 ++++++++--------- .../instrumented/unit/DownloadItemTest.kt | 28 +++++++++++++------ .../unit/HlsVideoDownloadItemTest.kt | 26 ++++++++++++----- 3 files changed, 52 insertions(+), 28 deletions(-) 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 index ebd448d2a..cc5f1c2a4 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt @@ -46,7 +46,7 @@ abstract class DownloadHandlerTest, status = it } downloadHandler.delete(identifier) - waitWhile({ status?.state?.equals(DownloadStatus.State.DELETED) == false }, 3000) + waitWhile({ status?.state?.equals(DownloadStatus.State.DELETED) != true }, 3000) downloadHandler.listen(identifier, null) } @@ -111,9 +111,9 @@ abstract class DownloadHandlerTest, downloadHandler.download(successfulTestRequest) // wait for download to start waitWhile({ - status?.state?.equals(DownloadStatus.State.DELETED) == true || - (status?.state?.equals(DownloadStatus.State.PENDING) == false && - status?.state?.equals(DownloadStatus.State.RUNNING) == false) + status?.state?.equals(DownloadStatus.State.DELETED) != false || + (status?.state?.equals(DownloadStatus.State.PENDING) != true && + status?.state?.equals(DownloadStatus.State.RUNNING) != true) }, 10000) assertNotNull(status!!.totalBytes) assertNotNull(status!!.downloadedBytes) @@ -159,7 +159,7 @@ abstract class DownloadHandlerTest, // start download downloadHandler.download(successfulTestRequest) // wait for download to finish - waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) == false }) + waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) != true }) assertNotNull(status!!.totalBytes) assertNotNull(status!!.downloadedBytes) assertEquals(status!!.totalBytes, status!!.downloadedBytes) @@ -176,7 +176,7 @@ abstract class DownloadHandlerTest, // start download downloadHandler.download(successfulTestRequest) // wait for download to finish - waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) == false }) + waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) != true }) var result = false downloadHandler.delete(identifier) { @@ -198,7 +198,7 @@ abstract class DownloadHandlerTest, // start download downloadHandler.download(successfulTestRequest) // wait for download to fail - waitWhile({ status?.state?.equals(DownloadStatus.State.DELETED) == false }) + waitWhile({ status?.state?.equals(DownloadStatus.State.DELETED) != true }) } @Test @@ -217,9 +217,9 @@ abstract class DownloadHandlerTest, } downloadHandler.download(successfulTestRequest) waitWhile({ - status?.state?.equals(DownloadStatus.State.DELETED) == true || - (status?.state?.equals(DownloadStatus.State.PENDING) == false && - status?.state?.equals(DownloadStatus.State.RUNNING) == false) + status?.state?.equals(DownloadStatus.State.DELETED) != false || + (status?.state?.equals(DownloadStatus.State.PENDING) != true && + status?.state?.equals(DownloadStatus.State.RUNNING) != true) }, 10000) result = false @@ -237,7 +237,7 @@ abstract class DownloadHandlerTest, result = it } // wait for and check result - waitWhile({ result?.size?.equals(0) == false }, 1000) + waitWhile({ result?.size?.equals(0) != true }, 1000) var status: DownloadStatus? = null downloadHandler.listen(downloadHandler.identify(successfulTestRequest)) { @@ -246,14 +246,14 @@ abstract class DownloadHandlerTest, // start download downloadHandler.download(successfulTestRequest) // wait for download to finish - waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) == false }) + waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) != true }) result = null downloadHandler.getDownloads(context.preferredStorage) { result = it } // wait for result - waitWhile({ result?.size?.equals(1) == false }, 1000) + waitWhile({ result?.size?.equals(1) != true }, 1000) } @Test 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 index ea44f3e91..acb939ac2 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt @@ -16,8 +16,8 @@ import org.junit.Before import org.junit.Rule import org.junit.Test -abstract class DownloadItemTest, - F, I : DownloadIdentifier> : BaseTest() { +abstract class DownloadItemTest, + D, I : DownloadIdentifier> : BaseTest() { @Rule @JvmField @@ -32,8 +32,20 @@ abstract class DownloadItemTest, abstract val testDownloadItemNotDownloadable: T @Before - fun deleteItem() { - testDownloadItem.delete(activityTestRule.activity) + fun deleteAllItems() { + fun deleteItem(item: DownloadItem) { + var deleted = false + testDownloadItem.status.observeForever { + if(it.state == DownloadStatus.State.DELETED) { + deleted = true + } + } + item.delete(activityTestRule.activity) + waitWhile({ !deleted }, 3000) + } + + deleteItem(testDownloadItem) + deleteItem(testDownloadItemNotDownloadable) } @Test @@ -101,8 +113,8 @@ abstract class DownloadItemTest, startResult = it } - waitWhile({!startResult}, 3000) - waitWhile({!downloaded}) + waitWhile({ !startResult }, 3000) + waitWhile({ !downloaded }) assertNotNull(testDownloadItem.download) var deleted = false @@ -117,8 +129,8 @@ abstract class DownloadItemTest, deleteResult = it } - waitWhile({!deleteResult}, 3000) - waitWhile({!deleted}) + waitWhile({ !deleteResult }, 3000) + waitWhile({ !deleted }) assertNull(testDownloadItem.download) } diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadItemTest.kt index 067697fd1..26ab3aa25 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadItemTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadItemTest.kt @@ -9,6 +9,7 @@ import de.xikolo.download.hlsvideodownload.HlsVideoDownloadItem import de.xikolo.testing.instrumented.mocking.SampleMockData import de.xikolo.utils.extensions.preferredStorage import org.junit.Assert.assertNotEquals +import org.junit.Before import org.junit.Test class HlsVideoDownloadItemTest : DownloadItemTest Date: Thu, 13 May 2021 21:36:28 +0200 Subject: [PATCH 10/15] adjust tests --- .../instrumented/unit/DownloadHandlerTest.kt | 6 +++--- .../testing/instrumented/unit/DownloadItemTest.kt | 9 +++++---- .../instrumented/unit/FileDownloadHandlerTest.kt | 5 ++--- .../instrumented/unit/FileDownloadItemTest.kt | 5 +++-- .../unit/HlsVideoDownloadHandlerTest.kt | 5 ++--- .../instrumented/unit/HlsVideoDownloadItemTest.kt | 13 +++++++------ .../main/java/de/xikolo/download/DownloadStatus.kt | 4 ++-- .../download/filedownload/FileDownloadHandler.kt | 6 ++++++ 8 files changed, 30 insertions(+), 23 deletions(-) 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 index cc5f1c2a4..aa41aaf1f 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt @@ -114,7 +114,7 @@ abstract class DownloadHandlerTest, status?.state?.equals(DownloadStatus.State.DELETED) != false || (status?.state?.equals(DownloadStatus.State.PENDING) != true && status?.state?.equals(DownloadStatus.State.RUNNING) != true) - }, 10000) + }, 30000) assertNotNull(status!!.totalBytes) assertNotNull(status!!.downloadedBytes) if (status!!.totalBytes!! >= 0L) { @@ -220,7 +220,7 @@ abstract class DownloadHandlerTest, status?.state?.equals(DownloadStatus.State.DELETED) != false || (status?.state?.equals(DownloadStatus.State.PENDING) != true && status?.state?.equals(DownloadStatus.State.RUNNING) != true) - }, 10000) + }, 30000) result = false downloadHandler.isDownloadingAnything { @@ -267,7 +267,7 @@ abstract class DownloadHandlerTest, result2 = it } // wait for `result` and `result2` to become true - waitWhile({ !result || !result2 }, 3000) + waitWhile({ !result || !result2 }, 10000) } protected fun waitWhile(condition: () -> Boolean, timeout: Long = 300000) { 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 index acb939ac2..1b733090c 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt @@ -35,9 +35,11 @@ abstract class DownloadItemTest, fun deleteAllItems() { fun deleteItem(item: DownloadItem) { var deleted = false - testDownloadItem.status.observeForever { - if(it.state == DownloadStatus.State.DELETED) { - deleted = true + activityTestRule.activity.runOnUiThread { + item.status.observeForever { + if (it.state == DownloadStatus.State.DELETED) { + deleted = true + } } } item.delete(activityTestRule.activity) @@ -45,7 +47,6 @@ abstract class DownloadItemTest, } deleteItem(testDownloadItem) - deleteItem(testDownloadItemNotDownloadable) } @Test 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 index 0753a4062..a5bbe285c 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadHandlerTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadHandlerTest.kt @@ -4,7 +4,6 @@ import de.xikolo.download.DownloadCategory 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 import io.mockk.every import io.mockk.spyk @@ -17,14 +16,14 @@ class FileDownloadHandlerTest : DownloadHandlerTest() { public override fun setValue(value: DownloadStatus) { - super.setValue(value) + super.postValue(value) } override fun getValue(): DownloadStatus { @@ -101,7 +101,7 @@ data class DownloadStatus( } init { - super.setValue(DownloadStatus(totalBytes, downloadedBytes, state, error)) + super.postValue(DownloadStatus(totalBytes, downloadedBytes, state, error)) } } } 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 ee357f342..2fdca2efc 100644 --- a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadHandler.kt @@ -316,13 +316,19 @@ object FileDownloadHandler : DownloadHandler Unit)? ) { listeners[identifier.get()] = listener + + var called = false disabledNotificationsManager.getDownload(identifier.get()) { a -> enabledNotificationsManager.getDownload(identifier.get()) { b -> listener?.invoke( getDownloadStatus(a ?: b) ) + called = true } } + while (!called) { + Thread.sleep(100) + } } override fun getDownloads( From 2f0d4e001df595722ec0bcda4bc6b345160bdfc8 Mon Sep 17 00:00:00 2001 From: Ben Engelhaupt Date: Sun, 16 May 2021 21:54:13 +0200 Subject: [PATCH 11/15] fix and adjust tests --- .../instrumented/unit/BaseDownloadTest.kt | 21 +++ .../instrumented/unit/DownloadHandlerTest.kt | 156 ++++++------------ .../instrumented/unit/DownloadItemTest.kt | 69 ++++---- .../instrumented/unit/FileDownloadItemTest.kt | 108 +++++++----- ...HlsVideoDownloadHandlerTest.kt => HSLS.kt} | 38 ++++- .../unit/HlsVideoDownloadItemTest.kt | 59 +------ .../java/de/xikolo/download/DownloadStatus.kt | 4 +- 7 files changed, 210 insertions(+), 245 deletions(-) create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/BaseDownloadTest.kt rename app/src/androidTest/java/de/xikolo/testing/instrumented/unit/{HlsVideoDownloadHandlerTest.kt => HSLS.kt} (59%) diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/BaseDownloadTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/BaseDownloadTest.kt new file mode 100644 index 000000000..13d5ffa4e --- /dev/null +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/BaseDownloadTest.kt @@ -0,0 +1,21 @@ +package de.xikolo.testing.instrumented.unit + +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 + +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/DownloadHandlerTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt index aa41aaf1f..e2488ea1d 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt @@ -1,35 +1,20 @@ package de.xikolo.testing.instrumented.unit -import android.Manifest -import androidx.test.rule.ActivityTestRule -import androidx.test.rule.GrantPermissionRule -import de.xikolo.controllers.main.MainActivity import de.xikolo.download.DownloadCategory 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 de.xikolo.utils.extensions.preferredStorage 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.Rule import org.junit.Test abstract class DownloadHandlerTest, - I : DownloadIdentifier, R : DownloadRequest> : BaseTest() { - - @Rule - @JvmField - var activityTestRule = - ActivityTestRule(MainActivity::class.java, false, true) - - @Rule - @JvmField - var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE) + I : DownloadIdentifier, R : DownloadRequest> : BaseDownloadTest() { abstract var downloadHandler: T @@ -66,22 +51,22 @@ abstract class DownloadHandlerTest, fun testDownloadListenerRegistration() { val identifier = downloadHandler.identify(successfulTestRequest) - var called = false + var listenerCalled = false // register listener downloadHandler.listen(identifier) { - called = true + listenerCalled = true } - assertTrue(called) + assertTrue(listenerCalled) - // reset `called` to false - called = false + // reset `listenerCalled` to false + listenerCalled = false // unregister listener downloadHandler.listen(identifier, null) // perform an action downloadHandler.download(successfulTestRequest) try { - // wait for listener to set `called` to true, if this is the case then fail - waitWhile({ !called }, 3000) + // 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 @@ -89,32 +74,30 @@ abstract class DownloadHandlerTest, } @Test - fun testDownloadStarting() { - var result = false - downloadHandler.download(successfulTestRequest) { - result = it - } - // wait for `result` to become true - waitWhile({ !result }, 3000) - } - - @Test - fun testDownloadStatusAfterStart() { + fun testDownloadStatusDuringProcess() { val identifier = downloadHandler.identify(successfulTestRequest) - // register listener var status: DownloadStatus? = null downloadHandler.listen(identifier) { status = it } + // start download - downloadHandler.download(successfulTestRequest) + 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) }, 30000) + + // test status after start assertNotNull(status!!.totalBytes) assertNotNull(status!!.downloadedBytes) if (status!!.totalBytes!! >= 0L) { @@ -122,68 +105,52 @@ abstract class DownloadHandlerTest, status!!.downloadedBytes!! <= status!!.totalBytes!! ) } - } - - @Test - fun testDownloadStatusAfterCancel() { - val identifier = downloadHandler.identify(successfulTestRequest) - - // register listener - var status: DownloadStatus? = null - downloadHandler.listen(identifier) { - status = it - } - var result = false - // start download - downloadHandler.download(successfulTestRequest) { - // cancel running download and check status - downloadHandler.delete(identifier) { - result = true - } + var isDownloadingAnythingCallbackCalled = false + downloadHandler.isDownloadingAnything { + isDownloadingAnythingCallbackCalled = it } + // assert that isDownloadingAnything returns true + waitWhile({ !isDownloadingAnythingCallbackCalled }, 1000) - // wait for `result` to become true - waitWhile({ !result }, 3000) - waitWhile({ status!!.state != DownloadStatus.State.DELETED }, 3000) - } - - @Test - fun testDownloadStatusAfterSuccess() { - val identifier = downloadHandler.identify(successfulTestRequest) - - var status: DownloadStatus? = null - downloadHandler.listen(identifier) { - status = it - } - // start download - downloadHandler.download(successfulTestRequest) // 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 testDownloadStatusAfterDelete() { + fun testDownloadStatusAfterCancel() { val identifier = downloadHandler.identify(successfulTestRequest) + // register listener var status: DownloadStatus? = null downloadHandler.listen(identifier) { status = it } - // start download - downloadHandler.download(successfulTestRequest) - // wait for download to finish - waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) != true }) - var result = false - downloadHandler.delete(identifier) { - result = it + var deleteCallbackCalled = false + // start download + downloadHandler.download(successfulTestRequest) { + // cancel running download and check status + downloadHandler.delete(identifier) { + deleteCallbackCalled = true + } } - // wait for `result` to become true - waitWhile({ !result }, 3000) + + // assert that the delete callback has been called + waitWhile({ !deleteCallbackCalled }, 3000) waitWhile({ status!!.state != DownloadStatus.State.DELETED }, 3000) } @@ -201,35 +168,6 @@ abstract class DownloadHandlerTest, waitWhile({ status?.state?.equals(DownloadStatus.State.DELETED) != true }) } - @Test - fun testIsDownloadingAnything() { - var result = true - downloadHandler.isDownloadingAnything { - result = it - } - // assert the result is false - waitWhile({ result }, 1000) - - // register listener - var status: DownloadStatus? = null - downloadHandler.listen(downloadHandler.identify(successfulTestRequest)) { - status = it - } - downloadHandler.download(successfulTestRequest) - waitWhile({ - status?.state?.equals(DownloadStatus.State.DELETED) != false || - (status?.state?.equals(DownloadStatus.State.PENDING) != true && - status?.state?.equals(DownloadStatus.State.RUNNING) != true) - }, 30000) - - result = false - downloadHandler.isDownloadingAnything { - result = it - } - // assert the result is true - waitWhile({ !result }, 1000) - } - @Test fun testGettingDownloads() { var result: Map>? = null @@ -267,7 +205,7 @@ abstract class DownloadHandlerTest, result2 = it } // wait for `result` and `result2` to become true - waitWhile({ !result || !result2 }, 10000) + waitWhile({ !result || !result2 }, 30000) } protected fun waitWhile(condition: () -> Boolean, timeout: Long = 300000) { 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 index 1b733090c..82de269da 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt @@ -1,52 +1,45 @@ package de.xikolo.testing.instrumented.unit -import android.Manifest -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.download.DownloadStatus -import de.xikolo.testing.instrumented.mocking.base.BaseTest +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.Before -import org.junit.Rule import org.junit.Test abstract class DownloadItemTest, - D, I : DownloadIdentifier> : BaseTest() { - - @Rule - @JvmField - var activityTestRule = - ActivityTestRule(MainActivity::class.java, false, true) - - @Rule - @JvmField - var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE) + D, I : DownloadIdentifier> : BaseDownloadTest() { abstract val testDownloadItem: T abstract val testDownloadItemNotDownloadable: T - @Before - fun deleteAllItems() { - fun deleteItem(item: DownloadItem) { - var deleted = false - activityTestRule.activity.runOnUiThread { - item.status.observeForever { - if (it.state == DownloadStatus.State.DELETED) { - deleted = true - } + protected fun onUi(action: () -> Unit) { + activityTestRule.activity.runOnUiThread(action) + } + + protected fun deleteItem(item: DownloadItem) { + var deleted = false + var deleteCallbackCalled = false + onUi { + item.status.observe(activityTestRule.activity) { + if (it.state == DownloadStatus.State.DELETED) { + deleted = true } } - item.delete(activityTestRule.activity) - waitWhile({ !deleted }, 3000) + item.delete(activityTestRule.activity) { + deleteCallbackCalled = true + } } + waitWhile({ !deleted || !deleteCallbackCalled }, 3000) + } - deleteItem(testDownloadItem) + @Before + fun deleteAllItems() { + //deleteItem(testDownloadItem) } @Test @@ -92,17 +85,19 @@ abstract class DownloadItemTest, @Test fun testStatusBefore() { - testDownloadItem.status.observe(activityTestRule.activity){ - assertNotNull(it) - assertNull(testDownloadItem.download) + onUi { + testDownloadItem.status.observe(activityTestRule.activity) { + assertNotNull(it) + assertNull(testDownloadItem.download) + } } } @Test fun testDownloadAndDelete() { var downloaded = false - testDownloadItem.status.observe(activityTestRule.activity){ - if(it.state == DownloadStatus.State.DOWNLOADED) { + testDownloadItem.status.observe(activityTestRule.activity) { + if (it.state == DownloadStatus.State.DOWNLOADED) { downloaded = true } } @@ -110,7 +105,7 @@ abstract class DownloadItemTest, assertNull(testDownloadItem.download) var startResult = false - testDownloadItem.start(activityTestRule.activity){ + testDownloadItem.start(activityTestRule.activity) { startResult = it } @@ -119,14 +114,14 @@ abstract class DownloadItemTest, assertNotNull(testDownloadItem.download) var deleted = false - testDownloadItem.status.observe(activityTestRule.activity){ - if(it.state == DownloadStatus.State.DELETED) { + testDownloadItem.status.observe(activityTestRule.activity) { + if (it.state == DownloadStatus.State.DELETED) { deleted = true } } var deleteResult = false - testDownloadItem.delete(activityTestRule.activity){ + testDownloadItem.delete(activityTestRule.activity) { deleteResult = it } 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 index 71b22d954..06f1f04fd 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadItemTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadItemTest.kt @@ -3,9 +3,12 @@ package de.xikolo.testing.instrumented.unit import androidx.test.annotation.UiThreadTest 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.FileDownloadItem import de.xikolo.utils.extensions.preferredStorage +import io.mockk.every +import io.mockk.spyk import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -16,18 +19,29 @@ class FileDownloadItemTest : DownloadItemTest() { override var downloadHandler = spyk(HlsVideoDownloadHandler, recordPrivateCalls = true) { @@ -40,4 +43,37 @@ class HlsVideoDownloadHandlerTest : DownloadHandlerTest() { @@ -28,57 +25,9 @@ class HlsVideoDownloadItemTest : DownloadItemTest() { public override fun setValue(value: DownloadStatus) { - super.postValue(value) + super.setValue(value) } override fun getValue(): DownloadStatus { @@ -101,7 +101,7 @@ data class DownloadStatus( } init { - super.postValue(DownloadStatus(totalBytes, downloadedBytes, state, error)) + super.setValue(DownloadStatus(totalBytes, downloadedBytes, state, error)) } } } From ef9cf17f88bb21dc72e1dfa3140a60bdeaf9b9ac Mon Sep 17 00:00:00 2001 From: Ben-Noah Engelhaupt Date: Mon, 28 Jun 2021 08:02:51 +0200 Subject: [PATCH 12/15] finish tests and make them pass --- app/dependencies.gradle.kts | 1 - .../unit/FileDownloadHandlerTest.kt | 39 ---- .../unit/HlsVideoDownloadItemTest.kt | 33 ---- .../unit/{ => download}/BaseDownloadTest.kt | 4 +- .../{ => download}/DownloadHandlerTest.kt | 52 ++--- .../unit/{ => download}/DownloadItemTest.kt | 109 +++++++---- .../AbstractFileDownloadHandlerTest.kt | 56 ++++++ .../AbstractFileDownloadItemTest.kt} | 41 ++-- .../ExternalStorageFileDownloadHandlerTest.kt | 8 + .../ExternalStorageFileDownloadItemTest.kt | 8 + .../InternalStorageFileDownloadHandlerTest.kt | 8 + .../InternalStorageFileDownloadItemTest.kt | 8 + .../AbstractHlsVideoDownloadHandlerTest.kt} | 37 ++-- .../AbstractHlsVideoDownloadItemTest.kt | 31 +++ ...ernalStorageHlsVideoDownloadHandlerTest.kt | 8 + ...ExternalStorageHlsVideoDownloadItemTest.kt | 8 + ...ernalStorageHlsVideoDownloadHandlerTest.kt | 8 + ...InternalStorageHlsVideoDownloadItemTest.kt | 8 + .../de/xikolo/download/DownloadHandler.kt | 2 +- .../java/de/xikolo/download/DownloadItem.kt | 1 + .../de/xikolo/download/DownloadItemImpl.kt | 4 +- .../filedownload/FileDownloadHandler.kt | 179 ++++++++++-------- .../download/filedownload/FileDownloadItem.kt | 5 +- .../HlsVideoDownloadHandler.kt | 103 +++++++--- .../hlsvideodownload/HlsVideoDownloadItem.kt | 8 +- 25 files changed, 479 insertions(+), 290 deletions(-) delete mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadHandlerTest.kt delete mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadItemTest.kt rename app/src/androidTest/java/de/xikolo/testing/instrumented/unit/{ => download}/BaseDownloadTest.kt (69%) rename app/src/androidTest/java/de/xikolo/testing/instrumented/unit/{ => download}/DownloadHandlerTest.kt (86%) rename app/src/androidTest/java/de/xikolo/testing/instrumented/unit/{ => download}/DownloadItemTest.kt (54%) create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/AbstractFileDownloadHandlerTest.kt rename app/src/androidTest/java/de/xikolo/testing/instrumented/unit/{FileDownloadItemTest.kt => download/filedownload/AbstractFileDownloadItemTest.kt} (71%) create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/ExternalStorageFileDownloadHandlerTest.kt create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/ExternalStorageFileDownloadItemTest.kt create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/InternalStorageFileDownloadHandlerTest.kt create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/InternalStorageFileDownloadItemTest.kt rename app/src/androidTest/java/de/xikolo/testing/instrumented/unit/{HSLS.kt => download/hlsvideodownload/AbstractHlsVideoDownloadHandlerTest.kt} (68%) create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/AbstractHlsVideoDownloadItemTest.kt create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/ExternalStorageHlsVideoDownloadHandlerTest.kt create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/ExternalStorageHlsVideoDownloadItemTest.kt create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/InternalStorageHlsVideoDownloadHandlerTest.kt create mode 100644 app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/hlsvideodownload/InternalStorageHlsVideoDownloadItemTest.kt diff --git a/app/dependencies.gradle.kts b/app/dependencies.gradle.kts index 14ebd5c56..cacf7a9c8 100644 --- a/app/dependencies.gradle.kts +++ b/app/dependencies.gradle.kts @@ -98,6 +98,5 @@ dependencies { androidTestImplementation("androidx.test.espresso:espresso-contrib:3.3.0") androidTestImplementation("androidx.test:runner:1.3.0") androidTestImplementation("androidx.test:rules:1.3.0") - androidTestImplementation("io.mockk:mockk-android:1.11.0") testImplementation("junit:junit:4.13.2") } 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 a5bbe285c..000000000 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadHandlerTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -package de.xikolo.testing.instrumented.unit - -import de.xikolo.download.DownloadCategory -import de.xikolo.download.filedownload.FileDownloadHandler -import de.xikolo.download.filedownload.FileDownloadIdentifier -import de.xikolo.download.filedownload.FileDownloadRequest -import de.xikolo.utils.extensions.preferredStorage -import io.mockk.every -import io.mockk.spyk - -class FileDownloadHandlerTest : DownloadHandlerTest() { - - override var downloadHandler = spyk(FileDownloadHandler, recordPrivateCalls = true) { - every { this@spyk getProperty "context" } answers { context } - } - - override var successfulTestRequest = FileDownloadRequest( - "https://file-examples-com.github.io/uploads/2017/04/file_example_MP4_1280_10MG.mp4", - createTempFile(directory = context.preferredStorage.file), - "File 1", - true, - DownloadCategory.Other - ) - override var successfulTestRequest2 = FileDownloadRequest( - "https://file-examples-com.github.io/uploads/2017/10/file-example_PDF_1MB.pdf", - createTempFile(directory = context.preferredStorage.file), - "File 2", - true, - DownloadCategory.Other - ) - override var failingTestRequest = FileDownloadRequest( - "https://www.example.com/notfoundfilehwqnqkdrzn42r.mp4", - createTempFile(directory = context.preferredStorage.file), - "Failing File", - true, - DownloadCategory.Other - ) -} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadItemTest.kt deleted file mode 100644 index 7e338caeb..000000000 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/HlsVideoDownloadItemTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package de.xikolo.testing.instrumented.unit - -import com.google.android.exoplayer2.source.MediaSource -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.utils.extensions.preferredStorage -import io.mockk.every - -class HlsVideoDownloadItemTest : DownloadItemTest() { - - private val storage = context.preferredStorage - - override val testDownloadItem = 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, - storage - ) - override val testDownloadItemNotDownloadable = HlsVideoDownloadItem( - null, - DownloadCategory.Other, - 0.0f, - storage - ) - - init { - every { testDownloadItem.downloader } returns HSLS().downloadHandler - every { testDownloadItemNotDownloadable.downloader } returns HSLS().downloadHandler - } -} diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/BaseDownloadTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/BaseDownloadTest.kt similarity index 69% rename from app/src/androidTest/java/de/xikolo/testing/instrumented/unit/BaseDownloadTest.kt rename to app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/BaseDownloadTest.kt index 13d5ffa4e..f821af4db 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/BaseDownloadTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/BaseDownloadTest.kt @@ -1,4 +1,4 @@ -package de.xikolo.testing.instrumented.unit +package de.xikolo.testing.instrumented.unit.download import android.Manifest import androidx.test.rule.ActivityTestRule @@ -7,6 +7,8 @@ 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 diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/DownloadHandlerTest.kt similarity index 86% rename from app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt rename to app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/DownloadHandlerTest.kt index e2488ea1d..4a28f8d56 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadHandlerTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/DownloadHandlerTest.kt @@ -1,11 +1,10 @@ -package de.xikolo.testing.instrumented.unit +package de.xikolo.testing.instrumented.unit.download -import de.xikolo.download.DownloadCategory import de.xikolo.download.DownloadHandler import de.xikolo.download.DownloadIdentifier import de.xikolo.download.DownloadRequest import de.xikolo.download.DownloadStatus -import de.xikolo.utils.extensions.preferredStorage +import de.xikolo.models.Storage import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -16,11 +15,12 @@ import org.junit.Test abstract class DownloadHandlerTest, I : DownloadIdentifier, R : DownloadRequest> : BaseDownloadTest() { - abstract var downloadHandler: T + abstract val downloadHandler: T - abstract var successfulTestRequest: R - abstract var successfulTestRequest2: R - abstract var failingTestRequest: R + abstract val successfulTestRequest: R + abstract val successfulTestRequest2: R + abstract val failingTestRequest: R + abstract val storage: Storage @Before fun deleteAllDownloads() { @@ -56,7 +56,7 @@ abstract class DownloadHandlerTest, downloadHandler.listen(identifier) { listenerCalled = true } - assertTrue(listenerCalled) + waitWhile({ !listenerCalled }, 3000) // reset `listenerCalled` to false listenerCalled = false @@ -95,7 +95,7 @@ abstract class DownloadHandlerTest, status?.state?.equals(DownloadStatus.State.DELETED) != false || (status?.state?.equals(DownloadStatus.State.PENDING) != true && status?.state?.equals(DownloadStatus.State.RUNNING) != true) - }, 30000) + }) // test status after start assertNotNull(status!!.totalBytes) @@ -111,7 +111,7 @@ abstract class DownloadHandlerTest, isDownloadingAnythingCallbackCalled = it } // assert that isDownloadingAnything returns true - waitWhile({ !isDownloadingAnythingCallbackCalled }, 1000) + waitWhile({ !isDownloadingAnythingCallbackCalled }, 3000) // wait for download to finish waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) != true }) @@ -151,7 +151,7 @@ abstract class DownloadHandlerTest, // assert that the delete callback has been called waitWhile({ !deleteCallbackCalled }, 3000) - waitWhile({ status!!.state != DownloadStatus.State.DELETED }, 3000) + waitWhile({ status?.state?.equals(DownloadStatus.State.DELETED) != true }, 3000) } @Test @@ -170,28 +170,32 @@ abstract class DownloadHandlerTest, @Test fun testGettingDownloads() { - var result: Map>? = null - downloadHandler.getDownloads(context.preferredStorage) { - result = it + var count: Int? = null + downloadHandler.getDownloads(storage) { + count = it.size } // wait for and check result - waitWhile({ result?.size?.equals(0) != true }, 1000) + waitWhile({ count == null }, 1000) - var status: DownloadStatus? = null + var downloaded = false downloadHandler.listen(downloadHandler.identify(successfulTestRequest)) { - status = it + if(it.state == DownloadStatus.State.DOWNLOADED){ + downloaded = true + } } // start download downloadHandler.download(successfulTestRequest) // wait for download to finish - waitWhile({ status?.state?.equals(DownloadStatus.State.DOWNLOADED) != true }) + waitWhile({ !downloaded }) - result = null - downloadHandler.getDownloads(context.preferredStorage) { - result = it + var nextCount: Int? = null + downloadHandler.getDownloads(storage) { + nextCount = it.size } // wait for result - waitWhile({ result?.size?.equals(1) != true }, 1000) + waitWhile({ nextCount == null }, 1000) + + assertEquals(count!! + 1, nextCount); } @Test @@ -205,10 +209,10 @@ abstract class DownloadHandlerTest, result2 = it } // wait for `result` and `result2` to become true - waitWhile({ !result || !result2 }, 30000) + waitWhile({ !result || !result2 }) } - protected fun waitWhile(condition: () -> Boolean, timeout: Long = 300000) { + protected fun waitWhile(condition: () -> Boolean, timeout: Long = 60000) { var waited = 0 while (condition()) { Thread.sleep(100) diff --git a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/DownloadItemTest.kt similarity index 54% rename from app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt rename to app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/DownloadItemTest.kt index 82de269da..274f39430 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/DownloadItemTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/DownloadItemTest.kt @@ -1,4 +1,4 @@ -package de.xikolo.testing.instrumented.unit +package de.xikolo.testing.instrumented.unit.download import de.xikolo.download.DownloadIdentifier import de.xikolo.download.DownloadItem @@ -8,6 +8,7 @@ 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 @@ -17,34 +18,34 @@ abstract class DownloadItemTest, abstract val testDownloadItem: T abstract val testDownloadItemNotDownloadable: T - protected fun onUi(action: () -> Unit) { - activityTestRule.activity.runOnUiThread(action) - } - - protected fun deleteItem(item: DownloadItem) { - var deleted = false - var deleteCallbackCalled = false - onUi { - item.status.observe(activityTestRule.activity) { - if (it.state == DownloadStatus.State.DELETED) { - deleted = true + @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) } - item.delete(activityTestRule.activity) { - deleteCallbackCalled = true - } + + waitWhile({ !deleted }, 10000) } - waitWhile({ !deleted || !deleteCallbackCalled }, 3000) - } - @Before - fun deleteAllItems() { - //deleteItem(testDownloadItem) + deleteItem(testDownloadItem) } @Test fun testIdentifier() { testDownloadItem.identifier + try { + testDownloadItemNotDownloadable.identifier + fail("Statement should fail") + } catch (e: Exception) { + // expected behavior + } } @Test @@ -79,34 +80,54 @@ abstract class DownloadItemTest, @Test fun testStatus() { - testDownloadItem.status - testDownloadItemNotDownloadable.status + 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() { - onUi { + 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 - testDownloadItem.status.observe(activityTestRule.activity) { - if (it.state == DownloadStatus.State.DOWNLOADED) { - downloaded = true + onUiThread { + testDownloadItem.status.observe(activityTestRule.activity) { + if (it.state == DownloadStatus.State.DOWNLOADED) { + downloaded = true + } } } assertNull(testDownloadItem.download) var startResult = false - testDownloadItem.start(activityTestRule.activity) { - startResult = it + onUiThread { + testDownloadItem.start(activityTestRule.activity) { + startResult = it + } } waitWhile({ !startResult }, 3000) @@ -114,15 +135,19 @@ abstract class DownloadItemTest, assertNotNull(testDownloadItem.download) var deleted = false - testDownloadItem.status.observe(activityTestRule.activity) { - if (it.state == DownloadStatus.State.DELETED) { - deleted = true + onUiThread { + testDownloadItem.status.observe(activityTestRule.activity) { + if (it.state == DownloadStatus.State.DELETED) { + deleted = true + } } } var deleteResult = false - testDownloadItem.delete(activityTestRule.activity) { - deleteResult = it + onUiThread { + testDownloadItem.delete(activityTestRule.activity) { + deleteResult = it + } } waitWhile({ !deleteResult }, 3000) @@ -130,7 +155,19 @@ abstract class DownloadItemTest, assertNull(testDownloadItem.download) } - protected fun waitWhile(condition: () -> Boolean, timeout: Long = 300000) { + @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) @@ -140,4 +177,8 @@ abstract class DownloadItemTest, } } } + + 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/FileDownloadItemTest.kt b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/AbstractFileDownloadItemTest.kt similarity index 71% rename from app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadItemTest.kt rename to app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/AbstractFileDownloadItemTest.kt index 06f1f04fd..58f429ae9 100644 --- a/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/FileDownloadItemTest.kt +++ b/app/src/androidTest/java/de/xikolo/testing/instrumented/unit/download/filedownload/AbstractFileDownloadItemTest.kt @@ -1,49 +1,38 @@ -package de.xikolo.testing.instrumented.unit +package de.xikolo.testing.instrumented.unit.download.filedownload -import androidx.test.annotation.UiThreadTest 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.FileDownloadItem -import de.xikolo.utils.extensions.preferredStorage -import io.mockk.every -import io.mockk.spyk +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 -class FileDownloadItemTest : DownloadItemTest() { - private val storage = context.preferredStorage + abstract val storage: Storage - override val testDownloadItem = spyk( - FileDownloadItem( + 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 = spyk( - FileDownloadItem( + + override val testDownloadItemNotDownloadable + get() = FileDownloadItem( null, DownloadCategory.Other, - "null.null" + "null.null", + storage ) - ) - - init { - every { testDownloadItem.downloader } returns spyk(FileDownloadHandler, recordPrivateCalls = true) { - every { this@spyk getProperty "context" } answers { context } - } - every { testDownloadItemNotDownloadable.downloader } returns FileDownloadHandlerTest().downloadHandler - } @Test - @UiThreadTest fun testFileName() { testDownloadItem.fileName testDownloadItemNotDownloadable.fileName @@ -53,13 +42,12 @@ class FileDownloadItemTest : DownloadItemTest() { - override var downloadHandler = spyk(HlsVideoDownloadHandler, recordPrivateCalls = true) { - every { this@spyk getProperty "context" } answers { context } - } + override val downloadHandler = HlsVideoDownloadHandler - override var successfulTestRequest = 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 var successfulTestRequest2 = HlsVideoDownloadRequest( + 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, @@ -35,7 +33,7 @@ class HSLS : DownloadHandlerTest() { + + 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, + storage + ) + + override val testDownloadItemNotDownloadable + get() = HlsVideoDownloadItem( + null, + DownloadCategory.Other, + 0.0f, + 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/java/de/xikolo/download/DownloadHandler.kt b/app/src/main/java/de/xikolo/download/DownloadHandler.kt index 035b027cd..c8f22e695 100644 --- a/app/src/main/java/de/xikolo/download/DownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/DownloadHandler.kt @@ -56,7 +56,7 @@ interface DownloadHandler { /** * Registers a listener for a download that notifies when the download status changes. * Overrides the previous listener. - * The listener is always invoked immediately after registering it. + * 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 diff --git a/app/src/main/java/de/xikolo/download/DownloadItem.kt b/app/src/main/java/de/xikolo/download/DownloadItem.kt index 05eb0ad64..ae026fcb6 100644 --- a/app/src/main/java/de/xikolo/download/DownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/DownloadItem.kt @@ -40,6 +40,7 @@ interface DownloadItem { /** * Total size of the download. + * Returns 0 per default if the download size cannot be determined. */ val size: Long diff --git a/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt b/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt index 87ff239aa..2b11915ec 100644 --- a/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt +++ b/app/src/main/java/de/xikolo/download/DownloadItemImpl.kt @@ -106,9 +106,9 @@ abstract class DownloadItemImpl 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) + } + } } } } @@ -232,6 +248,8 @@ object FileDownloadHandler : DownloadHandler Unit)?) { + val mainHandler = Handler(Looper.getMainLooper()) + val req = Request(request.url, request.localFile.toUri()).apply { networkType = if (ApplicationPreferences().isDownloadNetworkLimitedOnMobile) { @@ -272,41 +290,57 @@ object FileDownloadHandler : DownloadHandler Unit)?) { - enabledNotificationsManager.getDownload(identifier.get()) { d1 -> - if (d1 != null) { - enabledNotificationsManager.cancel(d1.id) - enabledNotificationsManager.delete(d1.id) - enabledNotificationsManager.remove(d1.id) - callback?.invoke(true) + handler.post { + if (req.extras.getBoolean(REQUEST_EXTRA_SHOW_NOTIFICATION, true)) { + enabledNotificationsManager } else { - disabledNotificationsManager.getDownload(identifier.get()) { d2 -> - if (d2 != null) { - disabledNotificationsManager.cancel(d2.id) - disabledNotificationsManager.delete(d2.id) - disabledNotificationsManager.remove(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) + } + } + } + } } } } @@ -315,58 +349,51 @@ object FileDownloadHandler : DownloadHandler Unit)? ) { + val mainHandler = Handler(Looper.getMainLooper()) + listeners[identifier.get()] = listener - var called = false - disabledNotificationsManager.getDownload(identifier.get()) { a -> - enabledNotificationsManager.getDownload(identifier.get()) { b -> - listener?.invoke( - getDownloadStatus(a ?: b) - ) - called = true + handler.post { + disabledNotificationsManager.getDownload(identifier.get()) { a -> + enabledNotificationsManager.getDownload(identifier.get()) { b -> + mainHandler.post { + listener?.invoke( + getDownloadStatus(a ?: b) + ) + } + } } } - while (!called) { - Thread.sleep(100) - } } override fun getDownloads( storage: Storage, callback: (Map>) -> Unit ) { - disabledNotificationsManager.getDownloads { a -> - enabledNotificationsManager.getDownloads { b -> - callback.invoke( - a - .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 - ) - ) - } + - 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 + 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 + ) ) - ) - } - ) + } + ) + } + } } } } 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 27cafed38..204d10c84 100644 --- a/app/src/main/java/de/xikolo/download/filedownload/FileDownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/filedownload/FileDownloadItem.kt @@ -6,7 +6,6 @@ import de.xikolo.download.DownloadCategory import de.xikolo.download.DownloadItemImpl import de.xikolo.download.DownloadStatus import de.xikolo.models.Storage -import de.xikolo.utils.extensions.fileSize import de.xikolo.utils.extensions.internalStorage import de.xikolo.utils.extensions.open import de.xikolo.utils.extensions.preferredStorage @@ -37,7 +36,7 @@ open class FileDownloadItem( get() = fileName override val size: Long - get() = download?.fileSize ?: 0L + get() = download?.length() ?: 0L final override val download: File? get() { @@ -78,7 +77,6 @@ open class FileDownloadItem( ) override fun onStatusChanged(newStatus: DownloadStatus) { - super.onStatusChanged(newStatus) when (newStatus.state) { DownloadStatus.State.DOWNLOADED -> { File("$filePath.tmp").renameTo(File(filePath)) @@ -87,6 +85,7 @@ open class FileDownloadItem( File(filePath).delete() } } + super.onStatusChanged(newStatus) } /** diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt index 2f234c39d..1a1c5dfe7 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt @@ -4,7 +4,9 @@ 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 @@ -47,6 +49,8 @@ import kotlin.math.roundToInt object HlsVideoDownloadHandler : DownloadHandler { + val TAG: String = HlsVideoDownloadHandler::class.java.simpleName + private val context: Context get() = App.instance @@ -65,6 +69,7 @@ object HlsVideoDownloadHandler : val dataSourceFactory = DefaultHttpDataSourceFactory(Config.HEADER_USER_AGENT_VALUE) init { + Log.i(TAG, "Starting $TAG") DownloadService.start( context, HlsVideoDownloadForegroundService::class.java @@ -151,10 +156,14 @@ object HlsVideoDownloadHandler : downloadManager: DownloadManager, download: Download ) { + Log.d(TAG, "Download successfully removed: ${download.request.id}") val identifier = download.request.id - notifyStatus(identifier) + val listener = listeners[identifier] listeners.remove(identifier) downloads.remove(identifier) + listener?.invoke( + getDownloadStatus(null) + ) } } ) @@ -184,6 +193,7 @@ object HlsVideoDownloadHandler : } 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(), @@ -195,18 +205,21 @@ object HlsVideoDownloadHandler : override fun onPrepared(helper: DownloadHelper) { val manifest = helper.manifest as HlsManifest - val bitrates = manifest.masterPlaylist.variants.map { it.format.bitrate } - val lowestBitrate = bitrates.minOrNull() ?: 0 - val highestBitrate = bitrates.maxOrNull() ?: 0 + 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 closestBitrate = bitrates - .map { - abs(it - targetBitrate).roundToInt() - } - .minOrNull() - val estimatedSize = closestBitrate - ?.times(manifest.mediaPlaylist.durationUs * 1000000 / 8) + 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 { @@ -255,13 +268,21 @@ object HlsVideoDownloadHandler : 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() + throw Exception("Error during storage selection") } DownloadService.sendAddDownload( @@ -273,6 +294,10 @@ object HlsVideoDownloadHandler : callback?.invoke(true) } catch (e: Exception) { + Log.e( + TAG, + "Starting downloading failed: ${request.url} aka $identifier ($e)" + ) downloads[identifier] = Download( downloadRequest, Download.STATE_FAILED, @@ -288,6 +313,10 @@ object HlsVideoDownloadHandler : } override fun onPrepareError(helper: DownloadHelper, e: IOException) { + Log.e( + TAG, + "Starting downloading failed in helper preparation: ${request.url} ($e)" + ) callback?.invoke(false) helper.release() } @@ -296,6 +325,7 @@ object HlsVideoDownloadHandler : } override fun delete(identifier: HlsVideoDownloadIdentifier, callback: ((Boolean) -> Unit)?) { + Log.i(TAG, "Received deletion request: $identifier") DownloadService.sendRemoveDownload( context, HlsVideoDownloadInternalStorageBackgroundService::class.java, @@ -321,6 +351,7 @@ object HlsVideoDownloadHandler : identifier: HlsVideoDownloadIdentifier, listener: ((DownloadStatus) -> Unit)? ) { + Log.i(TAG, "Registering listener $listener for $identifier") listeners[identifier.get()] = listener listener?.invoke( getDownloadStatus( @@ -338,6 +369,10 @@ object HlsVideoDownloadHandler : 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) { @@ -345,12 +380,13 @@ object HlsVideoDownloadHandler : } 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().let { + getManager(context, cache).downloadIndex.getDownloads(Download.STATE_COMPLETED).let { val map = mutableMapOf>() it.moveToFirst() @@ -368,36 +404,43 @@ object HlsVideoDownloadHandler : 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 estimatedSize = ArgumentWrapper.decode(download.request.data).estimatedSize - val totalSize = download.contentLength.takeUnless { it < 0 } ?: estimatedSize + val totalSize = download.contentLength.takeUnless { it <= 0 } ?: if (download.state == Download.STATE_COMPLETED) { download.bytesDownloaded } else { - download.bytesDownloaded * 100 / download.percentDownloaded + ArgumentWrapper.decode(download.request.data).estimatedSize + ?: download.bytesDownloaded * 100 / download.percentDownloaded }.toLong() - - return DownloadStatus( - totalSize, - download.bytesDownloaded, - when (download.state) { - Download.STATE_QUEUED, Download.STATE_RESTARTING -> DownloadStatus.State.PENDING - Download.STATE_DOWNLOADING -> DownloadStatus.State.RUNNING - Download.STATE_COMPLETED -> DownloadStatus.State.DOWNLOADED - else -> DownloadStatus.State.DELETED - }, - if (download.state == Download.STATE_FAILED) { - Exception("Download failed with reason ${download.failureReason}") - } else null + 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) { diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt index 4671ad46d..87b5f0dcb 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt @@ -27,7 +27,7 @@ open class HlsVideoDownloadItem( get() = url != null override val title: String - get() = url!! + get() = url ?: "" private val cache: Cache get() = if (storage == context.sdcardStorage) { @@ -48,7 +48,11 @@ open class HlsVideoDownloadItem( ).createMediaSource(request.mediaItem) override val size: Long - get() = indexEntry?.bytesDownloaded ?: 0L + get() = try { + indexEntry!!.bytesDownloaded + } catch (e: Exception) { + 0L + } final override val download: MediaSource? get() { From 258611931591f144439f3d409b9c094a650961a4 Mon Sep 17 00:00:00 2001 From: Ben-Noah Engelhaupt Date: Sun, 4 Jul 2021 21:13:23 +0200 Subject: [PATCH 13/15] fix some playback issues --- .../hlsvideodownload/HlsVideoDownloadItem.kt | 56 ++++++++----------- .../HlsVideoDownloadRequest.kt | 28 +--------- 2 files changed, 24 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt index 87b5f0dcb..79d701b08 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt @@ -29,50 +29,40 @@ open class HlsVideoDownloadItem( override val title: String get() = url ?: "" - private val cache: Cache - get() = if (storage == context.sdcardStorage) { + private fun getCache(storage: Storage): Cache { + return if (storage == context.sdcardStorage) { HlsVideoDownloadHandler.getSdcardStorageCache(context)!! } else { HlsVideoDownloadHandler.getInternalStorageCache(context) } + } - private val indexEntry: Download? - get() = HlsVideoDownloadHandler.getManager(context, cache) + private fun getIndexEntry(storage: Storage): Download? { + return HlsVideoDownloadHandler.getManager(context, getCache(storage)) .downloadIndex - .getDownload(identifier.get()) + .getDownload( + identifier.get() + ) + } - private val mediaSource: MediaSource - get() = HlsMediaSource.Factory( - CacheDataSource.Factory() - .setCache(cache) - ).createMediaSource(request.mediaItem) + private fun getMediaSource(storage: Storage): MediaSource? { + return getIndexEntry(storage)?.let { + return HlsMediaSource.Factory( + CacheDataSource.Factory() + .setCache(getCache(storage)) + .setCacheWriteDataSinkFactory(null) + ).createMediaSource( + it.request.toMediaItem() + ) + } + } override val size: Long - get() = try { - indexEntry!!.bytesDownloaded - } catch (e: Exception) { - 0L - } + get() = getIndexEntry(storage)?.bytesDownloaded ?: 0L final override val download: MediaSource? - get() { - val originalStorage = storage - return try { - storage = context.internalStorage - indexEntry!! - mediaSource - } catch (e: Exception) { - try { - storage = context.sdcardStorage!! - indexEntry!! - mediaSource - } catch (e: Exception) { - null - } - } finally { - storage = originalStorage - } - } + get() = getMediaSource(context.internalStorage) + ?: context.sdcardStorage?.let { getMediaSource(it) } /*val subs: Map? get() { diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt index 56c15d5cc..4d6c73b4e 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt @@ -1,9 +1,5 @@ package de.xikolo.download.hlsvideodownload -import android.net.Uri -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.offline.StreamKey -import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist import de.xikolo.download.DownloadCategory import de.xikolo.download.DownloadRequest import de.xikolo.models.Storage @@ -32,26 +28,4 @@ class HlsVideoDownloadRequest( override val title: String, override val showNotification: Boolean, override val category: DownloadCategory -) : DownloadRequest { - - val mediaItem = MediaItem.Builder() - .setUri(Uri.parse(url)) - /*.setSubtitles( - subtitles?.map { - MediaItem.Subtitle( - Uri.parse(it.value), - MimeTypes.TEXT_VTT, - it.key, - C.SELECTION_FLAG_DEFAULT - ) - } - )*/ - .setStreamKeys( - listOf( - StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 1), - StreamKey(HlsMasterPlaylist.GROUP_INDEX_AUDIO, 1), - StreamKey(HlsMasterPlaylist.GROUP_INDEX_SUBTITLE, 1) - ) - ) - .build() -} +) : DownloadRequest From 6cb500f8fb22b284e33275ecf1ec55179a29dbd3 Mon Sep 17 00:00:00 2001 From: Ben-Noah Engelhaupt Date: Tue, 13 Jul 2021 01:32:14 +0200 Subject: [PATCH 14/15] fix subtitle bug --- .../AbstractHlsVideoDownloadItemTest.kt | 7 +- .../video/VideoItemPlayerFragment.kt | 7 +- .../filedownload/FileDownloadHandler.kt | 3 + .../HlsVideoDownloadHandler.kt | 10 ++- .../hlsvideodownload/HlsVideoDownloadItem.kt | 73 +++++++------------ .../HlsVideoDownloadRequest.kt | 2 - .../java/de/xikolo/models/DownloadAsset.kt | 4 +- .../de/xikolo/views/ExoPlayerVideoView.kt | 28 ++++--- 8 files changed, 67 insertions(+), 67 deletions(-) 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 index 27d7d3551..50cb6da67 100644 --- 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 @@ -1,6 +1,7 @@ package de.xikolo.testing.instrumented.unit.download.hlsvideodownload -import com.google.android.exoplayer2.source.MediaSource +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 @@ -9,7 +10,7 @@ import de.xikolo.models.Storage import de.xikolo.testing.instrumented.unit.download.DownloadItemTest abstract class AbstractHlsVideoDownloadItemTest : DownloadItemTest() { + Pair>, HlsVideoDownloadIdentifier>() { abstract val storage: Storage @@ -18,6 +19,7 @@ abstract class AbstractHlsVideoDownloadItemTest : DownloadItemTest Unit)?> = mutableMapOf() diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt index 1a1c5dfe7..4d1ebdedc 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt @@ -70,7 +70,7 @@ object HlsVideoDownloadHandler : init { Log.i(TAG, "Starting $TAG") - DownloadService.start( + DownloadService.startForeground( context, HlsVideoDownloadForegroundService::class.java ) @@ -119,7 +119,7 @@ object HlsVideoDownloadHandler : getDatabaseProvider(context), cache, dataSourceFactory, - Executors.newSingleThreadExecutor() + Executors.newCachedThreadPool() ).apply { maxParallelDownloads = 5 minRetryCount = 1 @@ -156,7 +156,10 @@ object HlsVideoDownloadHandler : downloadManager: DownloadManager, download: Download ) { - Log.d(TAG, "Download successfully removed: ${download.request.id}") + Log.d( + TAG, + "Download successfully removed: ${download.request.id}" + ) val identifier = download.request.id val listener = listeners[identifier] listeners.remove(identifier) @@ -253,6 +256,7 @@ object HlsVideoDownloadHandler : estimatedSize ).encode() ) + downloadRequest.customCacheKey helper.release() val identifier = downloadRequest.id diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt index 79d701b08..8070d3b34 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt @@ -1,10 +1,14 @@ 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.MediaSource +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 @@ -17,9 +21,10 @@ open class HlsVideoDownloadItem( val url: String?, val category: DownloadCategory, val quality: Float, - //val subtitles: Map?, + val subtitles: Map, storage: Storage = App.instance.preferredStorage -) : DownloadItemImpl(storage) { +) : DownloadItemImpl>, + HlsVideoDownloadIdentifier, HlsVideoDownloadRequest>(storage) { final override val downloader = HlsVideoDownloadHandler @@ -45,65 +50,43 @@ open class HlsVideoDownloadItem( ) } - private fun getMediaSource(storage: Storage): MediaSource? { - return getIndexEntry(storage)?.let { - return HlsMediaSource.Factory( + private fun getMediaSource(storage: Storage): Pair>? { + return getIndexEntry(storage)?.let { indexEntry -> + HlsMediaSource.Factory( CacheDataSource.Factory() .setCache(getCache(storage)) .setCacheWriteDataSinkFactory(null) ).createMediaSource( - it.request.toMediaItem() - ) + 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: MediaSource? + final override val download: Pair>? get() = getMediaSource(context.internalStorage) ?: context.sdcardStorage?.let { getMediaSource(it) } - /*val subs: Map? - get() { - fun getMediaSource(language: String, url: String): MediaSource = - SingleSampleMediaSource.Factory( - CacheDataSource.Factory() - .setCache(cache) - ) - .createMediaSource( - MediaItem.Subtitle( - Uri.parse(url), - MimeTypes.TEXT_VTT, - language, - C.SELECTION_FLAG_DEFAULT - ), - C.TIME_UNSET - ) - - val originalStorage = storage - return try { - storage = context.internalStorage - indexEntry!! - subtitles?.entries?.associate { - it.key to getMediaSource(it.key, it.value) - } - } catch (e: Exception) { - storage = context.sdcardStorage!! - indexEntry!! - subtitles?.entries?.associate { - it.key to getMediaSource(it.key, it.value) - } - } finally { - storage = originalStorage - } - }*/ - final override val request get() = HlsVideoDownloadRequest( url!!, quality, - //subtitles, storage, title, true, diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt index 4d6c73b4e..73baeff80 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadRequest.kt @@ -14,7 +14,6 @@ import de.xikolo.models.Storage * 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 subtitles The subtitles to download along the video. * @param storage The storage location for the download. * @param title The title of the download. * @param showNotification Whether to show a notification while downloading. @@ -23,7 +22,6 @@ import de.xikolo.models.Storage class HlsVideoDownloadRequest( val url: String, val quality: Float, - //val subtitles: Map?, val storage: Storage, override val title: String, override val showNotification: Boolean, diff --git a/app/src/main/java/de/xikolo/models/DownloadAsset.kt b/app/src/main/java/de/xikolo/models/DownloadAsset.kt index 68ba6f3da..87806ad78 100644 --- a/app/src/main/java/de/xikolo/models/DownloadAsset.kt +++ b/app/src/main/java/de/xikolo/models/DownloadAsset.kt @@ -105,10 +105,10 @@ object DownloadAsset { HlsVideoDownloadItem( video.streamToPlay?.hlsUrl, DownloadCategory.Course(item.courseId), - quality.qualityFraction/*, + quality.qualityFraction, video.subtitles.associate { it.language to it.vttUrl - }*/ + } ) { override val title = App.instance.getString(R.string.video) + ": " + item.title diff --git a/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt b/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt index 3f604433b..bc3353c58 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 { @@ -153,9 +154,6 @@ open class ExoPlayerVideoView : PlayerView { override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { } - - override fun onTimelineChanged(timeline: Timeline, reason: Int) { - } } ) @@ -292,20 +290,32 @@ open class ExoPlayerVideoView : PlayerView { DefaultTrackSelector.ParametersBuilder(context) .apply { if (quality != null) { - setMaxVideoBitrate(quality.toInt()) // ToDo - setMinVideoBitrate(quality.toInt()) + 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() { - 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 = Renderer.VIDEO_SCALING_MODE_DEFAULT + exoplayer.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT } From 0b32ea799e609870b97cfc3aecbfab0e27003a2f Mon Sep 17 00:00:00 2001 From: Ben-Noah Engelhaupt Date: Sun, 18 Jul 2021 22:43:36 +0200 Subject: [PATCH 15/15] Fix linting errors --- .../unit/download/BaseDownloadTest.kt | 1 - .../unit/download/DownloadHandlerTest.kt | 11 ++++---- .../AbstractHlsVideoDownloadHandlerTest.kt | 6 +++-- .../AbstractHlsVideoDownloadItemTest.kt | 3 ++- .../controllers/helper/DownloadViewHelper.kt | 2 +- .../video/VideoStreamPlayerFragment.kt | 19 +++++++------- .../HlsVideoDownloadHandler.kt | 26 ++++++++++++------- .../hlsvideodownload/HlsVideoDownloadItem.kt | 3 ++- app/src/main/java/de/xikolo/models/Video.kt | 6 +++-- .../de/xikolo/views/ExoPlayerVideoView.kt | 2 ++ 10 files changed, 48 insertions(+), 31 deletions(-) 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 index f821af4db..2bdc10469 100644 --- 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 @@ -19,5 +19,4 @@ abstract class BaseDownloadTest : BaseTest() { @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 index 4a28f8d56..0b8a0beb7 100644 --- 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 @@ -92,9 +92,10 @@ abstract class DownloadHandlerTest, // 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) + 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 @@ -179,7 +180,7 @@ abstract class DownloadHandlerTest, var downloaded = false downloadHandler.listen(downloadHandler.identify(successfulTestRequest)) { - if(it.state == DownloadStatus.State.DOWNLOADED){ + if (it.state == DownloadStatus.State.DOWNLOADED) { downloaded = true } } @@ -195,7 +196,7 @@ abstract class DownloadHandlerTest, // wait for result waitWhile({ nextCount == null }, 1000) - assertEquals(count!! + 1, nextCount); + assertEquals(count!! + 1, nextCount) } @Test 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 index 8414f273e..7a48513be 100644 --- 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 @@ -18,7 +18,8 @@ abstract class AbstractHlsVideoDownloadHandlerTest : DownloadHandlerTest showEndState() DownloadStatus.State.DELETED -> { showStartState() - if(status.error != null){ + if (status.error != null) { activity.showToast(R.string.error) } } 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 f75797604..9edd1e22e 100644 --- a/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt +++ b/app/src/main/java/de/xikolo/controllers/video/VideoStreamPlayerFragment.kt @@ -602,7 +602,9 @@ open class VideoStreamPlayerFragment : BaseFragment() { } VideoSettingsHelper.PlaybackMode.LOW -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) - playerView.setDesiredQuality(VideoSettingsHelper.VideoQuality.LOW.qualityFraction) + playerView.setDesiredQuality( + VideoSettingsHelper.VideoQuality.LOW.qualityFraction + ) } VideoSettingsHelper.PlaybackMode.MEDIUM -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) @@ -612,11 +614,15 @@ open class VideoStreamPlayerFragment : BaseFragment() { } VideoSettingsHelper.PlaybackMode.HIGH -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) - playerView.setDesiredQuality(VideoSettingsHelper.VideoQuality.HIGH.qualityFraction) + playerView.setDesiredQuality( + VideoSettingsHelper.VideoQuality.HIGH.qualityFraction + ) } VideoSettingsHelper.PlaybackMode.BEST -> { playerView.setHLSVideoUri(Uri.parse(videoStream.hlsUrl)) - playerView.setDesiredQuality(VideoSettingsHelper.VideoQuality.BEST.qualityFraction) + playerView.setDesiredQuality( + VideoSettingsHelper.VideoQuality.BEST.qualityFraction + ) } VideoSettingsHelper.PlaybackMode.LEGACY_HD -> { playerView.setProgressiveVideoUri(Uri.parse(videoStream.hdUrl)) @@ -842,12 +848,7 @@ open class VideoStreamPlayerFragment : BaseFragment() { if (setVideo(videoSettingsHelper.currentMode)) { updateSubtitles() updatePlaybackSpeed() - if (isOfflineVideo) { - // ToDo - /*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) { diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt index 4d1ebdedc..5c3aaa059 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadHandler.kt @@ -218,9 +218,11 @@ object HlsVideoDownloadHandler : } val closestBitrate = closestFormat?.bitrate - val estimatedSize = (closestFormat?.averageBitrate - ?.takeUnless { it == Format.NO_VALUE } - ?: closestBitrate) + val estimatedSize = ( + closestFormat?.averageBitrate + ?.takeUnless { it == Format.NO_VALUE } + ?: closestBitrate + ) ?.times(manifest.mediaPlaylist.durationUs) ?.div(8000000) // to bytes and seconds @@ -274,7 +276,8 @@ object HlsVideoDownloadHandler : if (request.storage == context.internalStorage) { Log.i( TAG, - "Starting downloading to internal storage: ${request.url} aka $identifier" + "Starting downloading to internal storage: " + + "${request.url} aka $identifier" ) HlsVideoDownloadInternalStorageForegroundService::class.java } else if (request.storage == context.sdcardStorage && @@ -282,7 +285,8 @@ object HlsVideoDownloadHandler : ) { Log.i( TAG, - "Starting downloading to sdcard storage: ${request.url} aka $identifier" + "Starting downloading to sdcard storage: " + + "${request.url} aka $identifier" ) HlsVideoDownloadSdcardStorageForegroundService::class.java } else { @@ -409,7 +413,8 @@ object HlsVideoDownloadHandler : private fun getDownloadStatus(download: Download?): DownloadStatus { if (download == null) { Log.w( - TAG, "getDownloadStatus(): Download not found, default status is generated: " + + TAG, + "getDownloadStatus(): Download not found, default status is generated: " + "${download?.request?.id}" ) return DownloadStatus(null, null, DownloadStatus.State.DELETED, null) @@ -423,7 +428,9 @@ object HlsVideoDownloadHandler : ?: 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_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 @@ -434,8 +441,9 @@ object HlsVideoDownloadHandler : } else null Log.d( - TAG, "getDownloadStatus(): Generated download status [${state.name}]" + - "${downloaded}/${totalSize} B (error: ${error}) for ${download.request.id}" + TAG, + "getDownloadStatus(): Generated download status [${state.name}]" + + "$downloaded/$totalSize B (error: $error) for ${download.request.id}" ) return DownloadStatus(totalSize, downloaded, state, error) } diff --git a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt index 8070d3b34..2c38c18b6 100644 --- a/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt +++ b/app/src/main/java/de/xikolo/download/hlsvideodownload/HlsVideoDownloadItem.kt @@ -50,7 +50,8 @@ open class HlsVideoDownloadItem( ) } - private fun getMediaSource(storage: Storage): Pair>? { + private fun getMediaSource(storage: Storage): + Pair>? { return getIndexEntry(storage)?.let { indexEntry -> HlsMediaSource.Factory( CacheDataSource.Factory() diff --git a/app/src/main/java/de/xikolo/models/Video.kt b/app/src/main/java/de/xikolo/models/Video.kt index a62b8f025..949eec5d5 100644 --- a/app/src/main/java/de/xikolo/models/Video.kt +++ b/app/src/main/java/de/xikolo/models/Video.kt @@ -105,13 +105,15 @@ open class Video : RealmObject() { val streamToPlay: VideoStream? get() { return if (singleStream != null && ( - singleStream?.hlsUrl != null || singleStream?.hdUrl != null || + singleStream?.hlsUrl != null || + singleStream?.hdUrl != null || singleStream?.sdUrl != null ) ) { singleStream } else if (lecturerStream != null && ( - lecturerStream?.hlsUrl != null || lecturerStream?.hdUrl != null || + lecturerStream?.hlsUrl != null || + lecturerStream?.hdUrl != null || lecturerStream?.sdUrl != null ) ) { diff --git a/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt b/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt index bc3353c58..3dc294c09 100644 --- a/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt +++ b/app/src/main/java/de/xikolo/views/ExoPlayerVideoView.kt @@ -261,6 +261,8 @@ open class ExoPlayerVideoView : PlayerView { false } } + } finally { + mediaMetadataRetriever?.release() } } previewPrepareThread.start()