diff --git a/app/schemas/com.github.libretube.db.AppDatabase/10.json b/app/schemas/com.github.libretube.db.AppDatabase/10.json new file mode 100644 index 000000000..632e2b7d0 --- /dev/null +++ b/app/schemas/com.github.libretube.db.AppDatabase/10.json @@ -0,0 +1,470 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "3df3f5c01e36e4e7fd3e02ba708e5d86", + "entities": [ + { + "tableName": "watchHistoryItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "uploadDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "videoId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "watchPosition", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "videoId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "searchHistoryItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "query" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "customInstance", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `apiUrl` TEXT NOT NULL, `frontendUrl` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiUrl", + "columnName": "apiUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "frontendUrl", + "columnName": "frontendUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "localSubscription", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`channelId` TEXT NOT NULL, PRIMARY KEY(`channelId`))", + "fields": [ + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "channelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlistBookmark", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `playlistName` TEXT, `thumbnailUrl` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, PRIMARY KEY(`playlistId`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistName", + "columnName": "playlistName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "playlistId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LocalPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LocalPlaylistItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` INTEGER NOT NULL, `videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "uploadDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `uploader` TEXT NOT NULL, `uploadDate` TEXT, `thumbnailPath` TEXT, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadDate", + "columnName": "uploadDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailPath", + "columnName": "thumbnailPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "videoId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "downloadItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `videoId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `path` TEXT NOT NULL, `url` TEXT, `format` TEXT, `quality` TEXT, `downloadSize` INTEGER NOT NULL, FOREIGN KEY(`videoId`) REFERENCES `download`(`videoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadSize", + "columnName": "downloadSize", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_downloadItem_path", + "unique": true, + "columnNames": [ + "path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloadItem_path` ON `${TABLE_NAME}` (`path`)" + } + ], + "foreignKeys": [ + { + "table": "download", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "videoId" + ], + "referencedColumns": [ + "videoId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3df3f5c01e36e4e7fd3e02ba708e5d86')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 29fb3eb0b..06d4a6c8f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -352,6 +352,11 @@ android:name=".services.BackgroundMode" android:enabled="true" android:exported="false" /> + + diff --git a/app/src/main/java/com/github/libretube/LibreTubeApp.kt b/app/src/main/java/com/github/libretube/LibreTubeApp.kt index dbd4db631..2fab6a40f 100644 --- a/app/src/main/java/com/github/libretube/LibreTubeApp.kt +++ b/app/src/main/java/com/github/libretube/LibreTubeApp.kt @@ -77,7 +77,7 @@ class LibreTubeApp : Application() { private fun initializeNotificationChannels() { val downloadChannel = NotificationChannelCompat.Builder( DOWNLOAD_CHANNEL_ID, - NotificationManagerCompat.IMPORTANCE_NONE + NotificationManagerCompat.IMPORTANCE_LOW ) .setName(getString(R.string.download_channel_name)) .setDescription(getString(R.string.download_channel_description)) diff --git a/app/src/main/java/com/github/libretube/constants/Constants.kt b/app/src/main/java/com/github/libretube/constants/Constants.kt index 25da67196..464a0dec0 100644 --- a/app/src/main/java/com/github/libretube/constants/Constants.kt +++ b/app/src/main/java/com/github/libretube/constants/Constants.kt @@ -43,6 +43,7 @@ const val PLAYER_NOTIFICATION_ID = 1 const val DOWNLOAD_PENDING_NOTIFICATION_ID = 2 const val DOWNLOAD_FAILURE_NOTIFICATION_ID = 3 const val DOWNLOAD_SUCCESS_NOTIFICATION_ID = 4 +const val DOWNLOAD_PROGRESS_NOTIFICATION_ID = 5 /** * Notification Channel IDs diff --git a/app/src/main/java/com/github/libretube/constants/IntentData.kt b/app/src/main/java/com/github/libretube/constants/IntentData.kt index 148770945..aa763177c 100644 --- a/app/src/main/java/com/github/libretube/constants/IntentData.kt +++ b/app/src/main/java/com/github/libretube/constants/IntentData.kt @@ -10,5 +10,11 @@ object IntentData { const val fileName = "fileName" const val keepQueue = "keepQueue" const val playlistType = "playlistType" + const val videoFormat = "videoFormate" + const val videoQuality = "videoQuality" + const val audioFormat = "audioFormate" + const val audioQuality = "audioQuality" + const val subtitleCode = "subtitleCode" + const val downloading = "downloading" const val openAudioPlayer = "openAudioPlayer" } diff --git a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt index cbe237d9c..56d9070d4 100644 --- a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt @@ -127,6 +127,7 @@ object PreferenceKeys { const val SHARE_WITH_TIME_CODE = "share_with_time_code" const val CONFIRM_UNSUBSCRIBE = "confirm_unsubscribing" const val CLEAR_BOOKMARKS = "clear_bookmarks" + const val MAX_CONCURRENT_DOWNLOADS = "max_concurrent_downloads" /** * History diff --git a/app/src/main/java/com/github/libretube/db/AppDatabase.kt b/app/src/main/java/com/github/libretube/db/AppDatabase.kt index 9342e51b4..91326a615 100644 --- a/app/src/main/java/com/github/libretube/db/AppDatabase.kt +++ b/app/src/main/java/com/github/libretube/db/AppDatabase.kt @@ -4,6 +4,7 @@ import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import com.github.libretube.db.dao.CustomInstanceDao +import com.github.libretube.db.dao.DownloadDao import com.github.libretube.db.dao.LocalPlaylistsDao import com.github.libretube.db.dao.LocalSubscriptionDao import com.github.libretube.db.dao.PlaylistBookmarkDao @@ -11,6 +12,8 @@ import com.github.libretube.db.dao.SearchHistoryDao import com.github.libretube.db.dao.WatchHistoryDao import com.github.libretube.db.dao.WatchPositionDao import com.github.libretube.db.obj.CustomInstance +import com.github.libretube.db.obj.Download +import com.github.libretube.db.obj.DownloadItem import com.github.libretube.db.obj.LocalPlaylist import com.github.libretube.db.obj.LocalPlaylistItem import com.github.libretube.db.obj.LocalSubscription @@ -28,12 +31,15 @@ import com.github.libretube.db.obj.WatchPosition LocalSubscription::class, PlaylistBookmark::class, LocalPlaylist::class, - LocalPlaylistItem::class + LocalPlaylistItem::class, + Download::class, + DownloadItem::class ], - version = 9, + version = 10, autoMigrations = [ AutoMigration(from = 7, to = 8), - AutoMigration(from = 8, to = 9) + AutoMigration(from = 8, to = 9), + AutoMigration(from = 9, to = 10) ] ) abstract class AppDatabase : RoomDatabase() { @@ -71,4 +77,9 @@ abstract class AppDatabase : RoomDatabase() { * Local playlists */ abstract fun localPlaylistsDao(): LocalPlaylistsDao + + /** + * Downloads + */ + abstract fun downloadDao(): DownloadDao } diff --git a/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt b/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt new file mode 100644 index 000000000..1bd6023f4 --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt @@ -0,0 +1,51 @@ +package com.github.libretube.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.github.libretube.db.obj.Download +import com.github.libretube.db.obj.DownloadItem +import com.github.libretube.db.obj.DownloadWithItems + +@Dao +interface DownloadDao { + @Transaction + @Query("SELECT * FROM download") + fun getAll(): List + + @Transaction + @Query("SELECT * FROM download WHERE videoId = :videoId") + fun findById(videoId: String): DownloadWithItems + + @Query("SELECT * FROM downloaditem WHERE id = :id") + fun findDownloadItemById(id: Int): DownloadItem + + @Query("SELECT * FROM downloadItem WHERE path = :path") + fun findDownloadItemByFilePath(path: String): DownloadItem + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertDownload(download: Download) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertDownloadItem(downloadItem: DownloadItem): Long + + @Update(onConflict = OnConflictStrategy.REPLACE) + fun updateDownload(download: Download) + + @Update(onConflict = OnConflictStrategy.REPLACE) + fun updateDownloadItem(downloadItem: DownloadItem) + + @Transaction + @Delete + fun deleteDownload(download: Download) + + @Delete + fun deleteDownloadItem(downloadItem: DownloadItem) + + @Query("DELETE FROM downloadItem WHERE videoId = :videoId") + fun deleteDownloadItemsByVideoId(videoId: String) +} diff --git a/app/src/main/java/com/github/libretube/db/obj/Download.kt b/app/src/main/java/com/github/libretube/db/obj/Download.kt new file mode 100644 index 000000000..2ec979ddd --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/obj/Download.kt @@ -0,0 +1,15 @@ +package com.github.libretube.db.obj + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "download") +data class Download( + @PrimaryKey(autoGenerate = false) + val videoId: String, + val title: String = "", + val description: String = "", + val uploader: String = "", + val uploadDate: String? = null, + val thumbnailPath: String? = null +) diff --git a/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt b/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt new file mode 100644 index 000000000..aadc27e1b --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt @@ -0,0 +1,32 @@ +package com.github.libretube.db.obj + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.github.libretube.enums.FileType + +@Entity( + tableName = "downloadItem", + indices = [Index(value = ["path"], unique = true)], + foreignKeys = [ + ForeignKey( + entity = Download::class, + parentColumns = ["videoId"], + childColumns = ["videoId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class DownloadItem( + @PrimaryKey(autoGenerate = true) + var id: Int = 0, + val type: FileType, + val videoId: String, + val fileName: String, + var path: String, + var url: String? = null, + var format: String? = null, + var quality: String? = null, + var downloadSize: Long = -1L +) diff --git a/app/src/main/java/com/github/libretube/db/obj/DownloadWithItems.kt b/app/src/main/java/com/github/libretube/db/obj/DownloadWithItems.kt new file mode 100644 index 000000000..1357a8dec --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/obj/DownloadWithItems.kt @@ -0,0 +1,13 @@ +package com.github.libretube.db.obj + +import androidx.room.Embedded +import androidx.room.Relation + +data class DownloadWithItems( + @Embedded val download: Download, + @Relation( + parentColumn = "videoId", + entityColumn = "videoId" + ) + val downloadItems: List +) diff --git a/app/src/main/java/com/github/libretube/enums/FileType.kt b/app/src/main/java/com/github/libretube/enums/FileType.kt new file mode 100644 index 000000000..e81da8aa7 --- /dev/null +++ b/app/src/main/java/com/github/libretube/enums/FileType.kt @@ -0,0 +1,7 @@ +package com.github.libretube.enums + +enum class FileType { + AUDIO, + VIDEO, + SUBTITLE +} diff --git a/app/src/main/java/com/github/libretube/extensions/ContentLength.kt b/app/src/main/java/com/github/libretube/extensions/ContentLength.kt new file mode 100644 index 000000000..b6f0a7752 --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/ContentLength.kt @@ -0,0 +1,24 @@ +package com.github.libretube.extensions + +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +suspend fun URL.getContentLength(def: Long = -1): Long { + try { + return withContext(Dispatchers.IO) { + val connection = openConnection() as HttpURLConnection + connection.setRequestProperty("Range", "bytes=0-") + + val value = connection.getHeaderField("content-length") + // If connection accepts range header, try to get total bytes + ?: connection.getHeaderField("content-range").split("/")[1] + + connection.disconnect() + value.toLong() + } + } catch (e: Exception) { e.printStackTrace() } + + return def +} diff --git a/app/src/main/java/com/github/libretube/extensions/FormatFileSize.kt b/app/src/main/java/com/github/libretube/extensions/FormatFileSize.kt new file mode 100644 index 000000000..b068964d7 --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/FormatFileSize.kt @@ -0,0 +1,23 @@ +package com.github.libretube.extensions + +import java.io.File +import kotlin.math.log2 +import kotlin.math.pow + +fun File.formatSize(): String { + return length().formatAsFileSize() +} + +fun Int.formatAsFileSize(): String { + return toLong().formatAsFileSize() +} + +fun Long.formatAsFileSize(): String { + return log2(if (this != 0L) toDouble() else 1.0).toInt().div(10).let { + val precision = when (it) { + 0 -> 0; 1 -> 1; else -> 2 + } + val prefix = arrayOf("", "K", "M", "G", "T", "P", "E", "Z", "Y") + String.format("%.${precision}f ${prefix[it]}B", toDouble() / 2.0.pow(it * 10.0)) + } +} diff --git a/app/src/main/java/com/github/libretube/extensions/Normalize.kt b/app/src/main/java/com/github/libretube/extensions/Normalize.kt index 0550a62c0..650e35a5d 100644 --- a/app/src/main/java/com/github/libretube/extensions/Normalize.kt +++ b/app/src/main/java/com/github/libretube/extensions/Normalize.kt @@ -13,3 +13,10 @@ fun Float.normalize(oldMin: Float, oldMax: Float, newMin: Float, newMax: Float): return (this - oldMin) * newRange / oldRange + newMin } + +fun Long.normalize(oldMin: Long, oldMax: Long, newMin: Long, newMax: Long): Long { + val oldRange = oldMax - oldMin + val newRange = newMax - newMin + + return (this - oldMin) * newRange / oldRange + newMin +} diff --git a/app/src/main/java/com/github/libretube/extensions/Sanitize.kt b/app/src/main/java/com/github/libretube/extensions/Sanitize.kt deleted file mode 100644 index 7c7e9cad4..000000000 --- a/app/src/main/java/com/github/libretube/extensions/Sanitize.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.github.libretube.extensions - -/** - * Replace file name specific chars - */ -fun String.sanitize(): String { - return this.replace("[^a-zA-Z0-9\\\\._]+".toRegex(), "_") -} diff --git a/app/src/main/java/com/github/libretube/extensions/ToDownloadItems.kt b/app/src/main/java/com/github/libretube/extensions/ToDownloadItems.kt new file mode 100644 index 000000000..497daaf34 --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/ToDownloadItems.kt @@ -0,0 +1,61 @@ +package com.github.libretube.extensions + +import com.github.libretube.api.obj.Streams +import com.github.libretube.db.obj.DownloadItem +import com.github.libretube.enums.FileType + +fun Streams.toDownloadItems( + videoId: String, + fileName: String, + videoFormat: String?, + videoQuality: String?, + audioFormat: String?, + audioQuality: String?, + subtitleCode: String? +): List { + val items = mutableListOf() + + if (!videoQuality.isNullOrEmpty() && !videoFormat.isNullOrEmpty()) { + val stream = videoStreams?.find { it.quality == videoQuality && it.format == videoFormat } + items.add( + DownloadItem( + type = FileType.VIDEO, + videoId = videoId, + fileName = fileName + "." + stream?.mimeType?.split("/")?.last(), + path = "", + url = stream?.url, + format = videoFormat, + quality = videoQuality + ) + ) + } + + if (!audioQuality.isNullOrEmpty() && !audioFormat.isNullOrEmpty()) { + val stream = audioStreams?.find { it.quality == audioQuality && it.format == audioFormat } + items.add( + DownloadItem( + type = FileType.AUDIO, + videoId = videoId, + fileName = fileName + "." + stream?.mimeType?.split("/")?.last(), + path = "", + url = stream?.url, + format = audioFormat, + quality = audioQuality + ) + ) + } + + if (!subtitleCode.isNullOrEmpty()) { + items.add( + DownloadItem( + type = FileType.SUBTITLE, + videoId = videoId, + fileName = "$fileName.srt", + path = "", + url = subtitles?.find { it.code == subtitleCode }?.url + ) + ) + } + + return items +} diff --git a/app/src/main/java/com/github/libretube/obj/DownloadStatus.kt b/app/src/main/java/com/github/libretube/obj/DownloadStatus.kt new file mode 100644 index 000000000..9125caa66 --- /dev/null +++ b/app/src/main/java/com/github/libretube/obj/DownloadStatus.kt @@ -0,0 +1,16 @@ +package com.github.libretube.obj + +sealed class DownloadStatus { + + object Completed : DownloadStatus() + + object Paused : DownloadStatus() + + data class Progress( + val progress: Long, + val downloaded: Long, + val total: Long + ) : DownloadStatus() + + data class Error(val message: String, val cause: Throwable? = null) : DownloadStatus() +} diff --git a/app/src/main/java/com/github/libretube/receivers/DownloadReceiver.kt b/app/src/main/java/com/github/libretube/receivers/DownloadReceiver.kt new file mode 100644 index 000000000..fcaac60ed --- /dev/null +++ b/app/src/main/java/com/github/libretube/receivers/DownloadReceiver.kt @@ -0,0 +1,24 @@ +package com.github.libretube.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.github.libretube.constants.IntentData +import com.github.libretube.services.DownloadService +import com.github.libretube.ui.activities.MainActivity + +class DownloadReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val activityIntent = Intent(context, MainActivity::class.java) + + when (intent?.action) { + DownloadService.ACTION_SERVICE_STARTED -> { + activityIntent.putExtra(IntentData.downloading, true) + } + DownloadService.ACTION_SERVICE_STOPPED -> { + activityIntent.putExtra(IntentData.downloading, false) + } + } + context?.startActivity(activityIntent) + } +} diff --git a/app/src/main/java/com/github/libretube/receivers/NotificationReceiver.kt b/app/src/main/java/com/github/libretube/receivers/NotificationReceiver.kt new file mode 100644 index 000000000..e7e21f9a1 --- /dev/null +++ b/app/src/main/java/com/github/libretube/receivers/NotificationReceiver.kt @@ -0,0 +1,33 @@ +package com.github.libretube.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import com.github.libretube.services.DownloadService + +class NotificationReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == null) return + + val serviceIntent = Intent(context, DownloadService::class.java) + serviceIntent.action = intent.action + + val id = intent.getIntExtra("id", -1) + if (id == -1) return + serviceIntent.putExtra("id", id) + + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) { + context?.startForegroundService(serviceIntent) + } else { + context?.startService(serviceIntent) + } + } + + companion object { + const val ACTION_DOWNLOAD_RESUME = + "com.github.libretube.receivers.NotificationReceiver.ACTION_DOWNLOAD_RESUME" + const val ACTION_DOWNLOAD_PAUSE = + "com.github.libretube.receivers.NotificationReceiver.ACTION_DOWNLOAD_PAUSE" + } +} diff --git a/app/src/main/java/com/github/libretube/services/DownloadService.kt b/app/src/main/java/com/github/libretube/services/DownloadService.kt index 7972ce033..6e9f5acac 100644 --- a/app/src/main/java/com/github/libretube/services/DownloadService.kt +++ b/app/src/main/java/com/github/libretube/services/DownloadService.kt @@ -1,182 +1,524 @@ package com.github.libretube.services -import android.app.DownloadManager +import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter -import android.net.Uri +import android.os.Binder +import android.os.Build import android.os.IBinder -import android.util.Log import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat import com.github.libretube.R +import com.github.libretube.api.CronetHelper +import com.github.libretube.api.RetrofitInstance import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID -import com.github.libretube.constants.DOWNLOAD_FAILURE_NOTIFICATION_ID -import com.github.libretube.constants.DOWNLOAD_SUCCESS_NOTIFICATION_ID -import com.github.libretube.enums.DownloadType -import com.github.libretube.extensions.TAG +import com.github.libretube.constants.DOWNLOAD_PROGRESS_NOTIFICATION_ID +import com.github.libretube.constants.IntentData +import com.github.libretube.db.DatabaseHolder.Companion.Database +import com.github.libretube.db.obj.Download +import com.github.libretube.db.obj.DownloadItem +import com.github.libretube.enums.FileType +import com.github.libretube.extensions.awaitQuery +import com.github.libretube.extensions.formatAsFileSize +import com.github.libretube.extensions.getContentLength +import com.github.libretube.extensions.query +import com.github.libretube.extensions.toDownloadItems +import com.github.libretube.extensions.toastFromMainThread +import com.github.libretube.obj.DownloadStatus +import com.github.libretube.receivers.NotificationReceiver +import com.github.libretube.receivers.NotificationReceiver.Companion.ACTION_DOWNLOAD_PAUSE +import com.github.libretube.receivers.NotificationReceiver.Companion.ACTION_DOWNLOAD_RESUME +import com.github.libretube.ui.activities.MainActivity import com.github.libretube.util.DownloadHelper +import com.github.libretube.util.DownloadHelper.getNotificationId +import com.github.libretube.util.ImageHelper import java.io.File +import java.net.HttpURLConnection +import java.net.SocketTimeoutException +import java.net.URL +import java.util.concurrent.Executors +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.BufferedSink +import okio.buffer +import okio.sink +import okio.source +/** + * Download service with custom implementation of downloading using [HttpURLConnection]. + */ class DownloadService : Service() { - private lateinit var videoName: String - private lateinit var videoUrl: String - private lateinit var audioUrl: String - private var downloadType: DownloadType = DownloadType.NONE + private val binder = LocalBinder() + private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + private val jobMain = SupervisorJob() + private val scope = CoroutineScope(dispatcher + jobMain) - private var videoDownloadId: Long? = null - private var audioDownloadId: Long? = null + private lateinit var notificationManager: NotificationManager + private lateinit var summaryNotificationBuilder: NotificationCompat.Builder + + private val jobs = mutableMapOf() + private val downloadQueue = mutableMapOf() + private val _downloadFlow = MutableSharedFlow>() + val downloadFlow: SharedFlow> = _downloadFlow override fun onCreate() { super.onCreate() IS_DOWNLOAD_RUNNING = true + notifyForeground() + sendBroadcast(Intent(ACTION_SERVICE_STARTED)) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - videoName = intent?.getStringExtra("videoName")!! - videoUrl = intent.getStringExtra("videoUrl")!! - audioUrl = intent.getStringExtra("audioUrl")!! - - downloadType = when { - videoUrl != "" && audioUrl != "" -> DownloadType.AUDIO_VIDEO - audioUrl != "" -> DownloadType.AUDIO - videoUrl != "" -> DownloadType.VIDEO - else -> DownloadType.NONE + when (intent?.action) { + ACTION_DOWNLOAD_RESUME -> resume(intent.getIntExtra("id", -1)) + ACTION_DOWNLOAD_PAUSE -> pause(intent.getIntExtra("id", -1)) } - if (downloadType != DownloadType.NONE) { - downloadManager() - } else { - onDestroy() + val videoId = intent?.getStringExtra(IntentData.videoId) ?: return START_NOT_STICKY + val fileName = intent.getStringExtra(IntentData.fileName) ?: videoId + val videoFormat = intent.getStringExtra(IntentData.videoFormat) + val videoQuality = intent.getStringExtra(IntentData.videoQuality) + val audioFormat = intent.getStringExtra(IntentData.audioFormat) + val audioQuality = intent.getStringExtra(IntentData.audioQuality) + val subtitleCode = intent.getStringExtra(IntentData.subtitleCode) + + scope.launch { + try { + val streams = RetrofitInstance.api.getStreams(videoId) + + awaitQuery { + Database.downloadDao().insertDownload( + Download( + videoId = videoId, + title = streams.title ?: "", + thumbnailPath = File( + DownloadHelper.getDownloadDir( + this@DownloadService, + DownloadHelper.THUMBNAIL_DIR + ), + fileName + ).absolutePath, + description = streams.description ?: "", + uploadDate = streams.uploadDate, + uploader = streams.uploader ?: "" + ) + ) + } + streams.thumbnailUrl?.let { url -> + ImageHelper.downloadImage( + this@DownloadService, + url, + File( + DownloadHelper.getDownloadDir( + this@DownloadService, + DownloadHelper.THUMBNAIL_DIR + ), + fileName + ).absolutePath + ) + } + + val downloadItems = streams.toDownloadItems( + videoId, + fileName, + videoFormat, + videoQuality, + audioFormat, + audioQuality, + subtitleCode + ) + downloadItems.forEach { start(it) } + } catch (e: Exception) { + return@launch + } } - return super.onStartCommand(intent, flags, startId) + return START_NOT_STICKY } - override fun onBind(intent: Intent?): IBinder? { - TODO("Not yet implemented") + /** + * Initiate download [Job] using [DownloadItem] by creating file according to [FileType] + * for the requested file. + */ + private fun start(item: DownloadItem) { + val file: File = when (item.type) { + FileType.AUDIO -> { + val audioDownloadDir = DownloadHelper.getDownloadDir( + this, + DownloadHelper.AUDIO_DIR + ) + File(audioDownloadDir, item.fileName) + } + FileType.VIDEO -> { + val videoDownloadDir = DownloadHelper.getDownloadDir( + this, + DownloadHelper.VIDEO_DIR + ) + File(videoDownloadDir, item.fileName) + } + FileType.SUBTITLE -> { + val subtitleDownloadDir = DownloadHelper.getDownloadDir( + this, + DownloadHelper.SUBTITLE_DIR + ) + File(subtitleDownloadDir, item.fileName) + } + } + file.createNewFile() + item.path = file.absolutePath + + item.id = awaitQuery { + Database.downloadDao().insertDownloadItem(item) + }.toInt() + + jobs[item.id] = scope.launch { + downloadFile(item) + } } - private fun downloadManager() { - // initialize and create the directories to download into + /** + * Download file and emit [DownloadStatus] to the collectors of [downloadFlow] + * and notification. + */ + private suspend fun downloadFile(item: DownloadItem) { + downloadQueue[item.id] = true + val notificationBuilder = getNotificationBuilder(item) + setResumeNotification(notificationBuilder, item) + val file = File(item.path) + var totalRead = file.length() + val url = URL(item.url ?: return) - val videoDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.VIDEO_DIR) - val audioDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR) + url.getContentLength().let { size -> + if (size > 0 && size != item.downloadSize) { + item.downloadSize = size + query { + Database.downloadDao().updateDownloadItem(item) + } + } + } - // start download try { - registerReceiver( - onDownloadComplete, - IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) - ) - if (downloadType in listOf(DownloadType.VIDEO, DownloadType.AUDIO_VIDEO)) { - videoDownloadId = downloadManagerRequest( - "[${getString(R.string.video)}] $videoName", - getString(R.string.downloading), - videoUrl, - Uri.fromFile( - File(videoDownloadDir, videoName) - ) - ) + // Set start range where last downloading was held. + val con = CronetHelper.getCronetEngine().openConnection(url) as HttpURLConnection + con.requestMethod = "GET" + con.setRequestProperty("Range", "bytes=$totalRead-") + con.connectTimeout = DownloadHelper.DEFAULT_TIMEOUT + con.readTimeout = DownloadHelper.DEFAULT_TIMEOUT + + withContext(Dispatchers.IO) { + // Retry connecting to server for n times. + for (i in 1..DownloadHelper.DEFAULT_RETRY) { + try { + con.connect() + break + } catch (_: SocketTimeoutException) { + val message = getString(R.string.downloadfailed) + " " + i + _downloadFlow.emit(item.id to DownloadStatus.Error(message)) + toastFromMainThread(message) + } + } } - if (downloadType in listOf(DownloadType.AUDIO, DownloadType.AUDIO_VIDEO)) { - audioDownloadId = downloadManagerRequest( - "[${getString(R.string.audio)}] $videoName", - getString(R.string.downloading), - audioUrl, - Uri.fromFile( - File(audioDownloadDir, videoName) - ) - ) + + // If link is expired try to regenerate using available info. + if (con.responseCode == 403) { + regenerateLink(item) + con.disconnect() + downloadFile(item) + return + } else if (con.responseCode !in 200..299) { + val message = getString(R.string.downloadfailed) + ": " + con.responseMessage + _downloadFlow.emit(item.id to DownloadStatus.Error(message)) + toastFromMainThread(message) + con.disconnect() + pause(item.id) + return } - } catch (e: IllegalArgumentException) { - Log.e(TAG(), "download error $e") - downloadFailedNotification() + + val sink: BufferedSink = file.sink(true).buffer() + val sourceByte = con.inputStream.source() + + var lastTime = System.currentTimeMillis() / 1000 + var lastRead: Long = 0 + + try { + // Check if downloading is still active and read next bytes. + while (downloadQueue[item.id] == true && + sourceByte + .read(sink.buffer, DownloadHelper.DOWNLOAD_CHUNK_SIZE) + .also { lastRead = it } != -1L + ) { + sink.emit() + totalRead += lastRead + _downloadFlow.emit( + item.id to DownloadStatus.Progress( + lastRead, + totalRead, + item.downloadSize + ) + ) + if (item.downloadSize != -1L && + System.currentTimeMillis() / 1000 > lastTime + ) { + notificationBuilder + .setContentText( + totalRead.formatAsFileSize() + " / " + + item.downloadSize.formatAsFileSize() + ) + .setProgress( + item.downloadSize.toInt(), + totalRead.toInt(), + false + ) + notificationManager.notify( + item.getNotificationId(), + notificationBuilder.build() + ) + lastTime = System.currentTimeMillis() / 1000 + } + } + } catch (_: CancellationException) { + } catch (e: Exception) { + toastFromMainThread("${getString(R.string.download)}: ${e.message}") + _downloadFlow.emit(item.id to DownloadStatus.Error(e.message.toString(), e)) + } + + withContext(Dispatchers.IO) { + sink.flush() + sink.close() + sourceByte.close() + con.disconnect() + } + } catch (_: Exception) { } + + val completed = when { + totalRead < item.downloadSize -> { + _downloadFlow.emit(item.id to DownloadStatus.Paused) + false + } + else -> { + _downloadFlow.emit(item.id to DownloadStatus.Completed) + true + } + } + setPauseNotification(notificationBuilder, item, completed) + pause(item.id) + } + + /** + * Resume download which may have been paused. + */ + fun resume(id: Int) { + // If file is already downloading then avoid new download job. + if (downloadQueue[id] == true) return + + if (downloadQueue.values.count { it } >= DownloadHelper.getMaxConcurrentDownloads()) { + toastFromMainThread(getString(R.string.concurrent_downloads_limit_reached)) + scope.launch { + _downloadFlow.emit(id to DownloadStatus.Paused) + } + return + } + + val downloadItem = awaitQuery { + Database.downloadDao().findDownloadItemById(id) + } + scope.launch { + downloadFile(downloadItem) } } - private val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - // Fetching the download id received with the broadcast - // Checking if the received broadcast is for our enqueued download by matching download id - val downloadId = intent.getLongExtra( - DownloadManager.EXTRA_DOWNLOAD_ID, - -1 + /** + * Pause downloading job for given [id]. If no downloads are active, stop the service. + */ + fun pause(id: Int) { + downloadQueue[id] = false + + // Stop the service if no downloads are active. + if (downloadQueue.none { it.value }) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_DETACH) + } + sendBroadcast(Intent(ACTION_SERVICE_STOPPED)) + stopSelf() + } + } + + /** + * Regenerate stream url using available info format and quality. + */ + private suspend fun regenerateLink(item: DownloadItem) { + val streams = RetrofitInstance.api.getStreams(item.videoId) + val stream = when (item.type) { + FileType.AUDIO -> streams.audioStreams + FileType.VIDEO -> streams.videoStreams + else -> null + } + stream?.find { it.format == item.format && it.quality == item.quality }?.let { + item.url = it.url + } + query { + Database.downloadDao().updateDownloadItem(item) + } + } + + /** + * Check whether the file downloading or not. + */ + fun isDownloading(id: Int): Boolean { + return downloadQueue[id] ?: false + } + + private fun notifyForeground() { + notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + + summaryNotificationBuilder = NotificationCompat + .Builder(this, DOWNLOAD_CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(getString(R.string.downloading)) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setGroup(DOWNLOAD_NOTIFICATION_GROUP) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOnlyAlertOnce(true) + .setGroupSummary(true) + + startForeground(DOWNLOAD_PROGRESS_NOTIFICATION_ID, summaryNotificationBuilder.build()) + } + + private fun getNotificationBuilder(item: DownloadItem): NotificationCompat.Builder { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT + } else { + PendingIntent.FLAG_CANCEL_CURRENT + } + + val activityIntent = + PendingIntent.getActivity( + this@DownloadService, + 0, + Intent(this@DownloadService, MainActivity::class.java).apply { + putExtra("fragmentToOpen", "downloads") + }, + flags ) - if (downloadId == audioDownloadId) { - audioDownloadId = null - } else if (downloadId == videoDownloadId) videoDownloadId = null - if (audioDownloadId != null || videoDownloadId != null) return - - downloadSucceededNotification() - onDestroy() - } + return NotificationCompat + .Builder(this, DOWNLOAD_CHANNEL_ID) + .setContentTitle("[${item.type}] ${item.fileName}") + .setProgress(0, 0, true) + .setOngoing(true) + .setContentIntent(activityIntent) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .setGroup(DOWNLOAD_NOTIFICATION_GROUP) } - private fun downloadManagerRequest( - title: String, - descriptionText: String, - url: String, - destination: Uri - ): Long { - val request: DownloadManager.Request = - DownloadManager.Request(Uri.parse(url)) - .setTitle(title) // Title of the Download Notification - .setDescription(descriptionText) // Description of the Download Notification - .setDestinationUri(destination) - .setAllowedOverMetered(true) // Set if download is allowed on Mobile network - .setAllowedOverRoaming(true) + private fun setResumeNotification( + notificationBuilder: NotificationCompat.Builder, + item: DownloadItem + ) { + notificationBuilder + .setSmallIcon(android.R.drawable.stat_sys_download) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .clearActions() + .addAction(getPauseAction(item.id)) - val downloadManager: DownloadManager = - applicationContext.getSystemService(DOWNLOAD_SERVICE) as DownloadManager - return downloadManager.enqueue(request) + notificationManager.notify(item.getNotificationId(), notificationBuilder.build()) } - private fun downloadFailedNotification() { - val builder = NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_download) - .setContentTitle(resources.getString(R.string.downloadfailed)) - .setContentText(getString(R.string.fail)) - .setPriority(NotificationCompat.PRIORITY_HIGH) + private fun setPauseNotification( + notificationBuilder: NotificationCompat.Builder, + item: DownloadItem, + isCompleted: Boolean = false + ) { + notificationBuilder + .setProgress(0, 0, false) + .setOngoing(false) + .clearActions() - with(NotificationManagerCompat.from(this)) { - // notificationId is a unique int for each notification that you must define - notify(DOWNLOAD_FAILURE_NOTIFICATION_ID, builder.build()) + if (isCompleted) { + notificationBuilder + .setSmallIcon(R.drawable.ic_done) + .setContentText(getString(R.string.download_completed)) + } else { + notificationBuilder + .setSmallIcon(R.drawable.ic_pause) + .setContentText(getString(R.string.download_paused)) + .addAction(getResumeAction(item.id)) } + notificationManager.notify(item.getNotificationId(), notificationBuilder.build()) } - private fun downloadSucceededNotification() { - Log.i(TAG(), "Download succeeded") - val builder = NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_download) - .setContentTitle(resources.getString(R.string.success)) - .setContentText(getString(R.string.downloadsucceeded)) - .setPriority(NotificationCompat.PRIORITY_HIGH) + private fun getResumeAction(id: Int): NotificationCompat.Action { + val intent = Intent(this, NotificationReceiver::class.java) - with(NotificationManagerCompat.from(this)) { - // notificationId is a unique int for each notification that you must define - notify(DOWNLOAD_SUCCESS_NOTIFICATION_ID, builder.build()) + intent.action = ACTION_DOWNLOAD_RESUME + intent.putExtra("id", id) + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + (PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } else { + PendingIntent.FLAG_UPDATE_CURRENT } + + return NotificationCompat.Action.Builder( + R.drawable.ic_play, + getString(R.string.resume), + PendingIntent.getBroadcast(this, id, intent, flags) + ).build() + } + + private fun getPauseAction(id: Int): NotificationCompat.Action { + val intent = Intent(this, NotificationReceiver::class.java) + + intent.action = ACTION_DOWNLOAD_PAUSE + intent.putExtra("id", id) + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + (PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + + return NotificationCompat.Action.Builder( + R.drawable.ic_pause, + getString(R.string.pause), + PendingIntent.getBroadcast(this, id, intent, flags) + ).build() } override fun onDestroy() { - try { - unregisterReceiver(onDownloadComplete) - } catch (e: Exception) { - e.printStackTrace() - } - + downloadQueue.clear() IS_DOWNLOAD_RUNNING = false - - stopService(Intent(this, DownloadService::class.java)) + sendBroadcast(Intent(ACTION_SERVICE_STOPPED)) super.onDestroy() } + override fun onBind(intent: Intent?): IBinder { + val ids = intent?.getIntArrayExtra("ids") + ids?.forEach { id -> resume(id) } + return binder + } + + inner class LocalBinder : Binder() { + fun getService(): DownloadService = this@DownloadService + } + companion object { + private const val DOWNLOAD_NOTIFICATION_GROUP = "download_notification_group" + const val ACTION_SERVICE_STARTED = + "com.github.libretube.services.DownloadService.ACTION_SERVICE_STARTED" + const val ACTION_SERVICE_STOPPED = + "com.github.libretube.services.DownloadService.ACTION_SERVICE_STOPPED" var IS_DOWNLOAD_RUNNING = false } } diff --git a/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt index 93ef77c57..66d3a6174 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt @@ -35,6 +35,7 @@ import com.github.libretube.extensions.toID import com.github.libretube.services.ClosingService import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.dialogs.ErrorDialog +import com.github.libretube.ui.fragments.DownloadsFragment import com.github.libretube.ui.fragments.PlayerFragment import com.github.libretube.ui.models.PlayerViewModel import com.github.libretube.ui.models.SearchViewModel @@ -423,6 +424,14 @@ class MainActivity : BaseActivity() { navController.navigate(R.id.subscriptionsFragment) "library" -> navController.navigate(R.id.libraryFragment) + "downloads" -> + navController.navigate(R.id.downloadsFragment) + } + if (intent?.getBooleanExtra(IntentData.downloading, false) == true) { + (supportFragmentManager.fragments.find { it is NavHostFragment }) + ?.childFragmentManager?.fragments?.forEach { fragment -> + (fragment as? DownloadsFragment)?.bindDownloadService() + } } } diff --git a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt index 102ccead5..092b48a78 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt @@ -17,10 +17,12 @@ import androidx.core.view.WindowInsetsControllerCompat import com.github.libretube.constants.IntentData import com.github.libretube.databinding.ActivityOfflinePlayerBinding import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding +import com.github.libretube.db.DatabaseHolder.Companion.Database +import com.github.libretube.enums.FileType +import com.github.libretube.extensions.awaitQuery import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.extensions.setAspectRatio import com.github.libretube.ui.models.PlayerViewModel -import com.github.libretube.util.DownloadHelper import com.github.libretube.util.PlayerHelper import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem @@ -33,7 +35,7 @@ import java.io.File class OfflinePlayerActivity : BaseActivity() { private lateinit var binding: ActivityOfflinePlayerBinding - private lateinit var fileName: String + private lateinit var videoId: String private lateinit var player: ExoPlayer private lateinit var playerView: StyledPlayerView private lateinit var playerBinding: ExoStyledPlayerControlViewBinding @@ -46,7 +48,7 @@ class OfflinePlayerActivity : BaseActivity() { super.onCreate(savedInstanceState) - fileName = intent?.getStringExtra(IntentData.fileName)!! + videoId = intent?.getStringExtra(IntentData.videoId)!! binding = ActivityOfflinePlayerBinding.inflate(layoutInflater) setContentView(binding.root) @@ -96,15 +98,17 @@ class OfflinePlayerActivity : BaseActivity() { } private fun playVideo() { - val videoUri = File( - DownloadHelper.getDownloadDir(this, DownloadHelper.VIDEO_DIR), - fileName - ).toUri() + val downloadFiles = awaitQuery { + Database.downloadDao().findById(videoId).downloadItems + } - val audioUri = File( - DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR), - fileName - ).toUri() + val video = downloadFiles.firstOrNull { it.type == FileType.VIDEO } + val audio = downloadFiles.firstOrNull { it.type == FileType.AUDIO } + val subtitle = downloadFiles.firstOrNull { it.type == FileType.SUBTITLE } + + val videoUri = video?.path?.let { File(it).toUri() } + val audioUri = audio?.path?.let { File(it).toUri() } + val subtitleUri = subtitle?.path?.let { File(it).toUri() } setMediaSource( videoUri, diff --git a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt index 5a94d9f54..a61a25b07 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt @@ -1,24 +1,29 @@ package com.github.libretube.ui.adapters import android.annotation.SuppressLint +import android.content.Context import android.content.Intent import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R import com.github.libretube.constants.IntentData import com.github.libretube.databinding.DownloadedMediaRowBinding -import com.github.libretube.extensions.formatShort -import com.github.libretube.obj.DownloadedFile +import com.github.libretube.db.DatabaseHolder +import com.github.libretube.db.obj.DownloadWithItems +import com.github.libretube.extensions.formatAsFileSize +import com.github.libretube.extensions.query import com.github.libretube.ui.activities.OfflinePlayerActivity import com.github.libretube.ui.viewholders.DownloadsViewHolder -import com.github.libretube.util.DownloadHelper -import com.github.libretube.util.TextUtils +import com.github.libretube.util.ImageHelper import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.io.File class DownloadsAdapter( - private val files: MutableList + private val context: Context, + private val downloads: MutableList, + private val toogleDownload: (DownloadWithItems) -> Boolean ) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder { val binding = DownloadedMediaRowBinding.inflate( @@ -31,24 +36,56 @@ class DownloadsAdapter( @SuppressLint("SetTextI18n") override fun onBindViewHolder(holder: DownloadsViewHolder, position: Int) { - val file = files[position] + val download = downloads[position].download + val items = downloads[position].downloadItems holder.binding.apply { - fileName.text = file.name - fileSize.text = "${file.size / (1024 * 1024)} MiB" + title.text = download.title + uploaderName.text = download.uploader + videoInfo.text = download.uploadDate - file.metadata?.let { - uploaderName.text = it.uploader - videoInfo.text = it.views.formatShort() + " " + - root.context.getString(R.string.views_placeholder) + - TextUtils.SEPARATOR + it.uploadDate + val downloadSize = items.sumOf { it.downloadSize } + val currentSize = items.sumOf { File(it.path).length() } + + if (downloadSize == -1L) { + progressBar.isIndeterminate = true + } else { + progressBar.max = downloadSize.toInt() + progressBar.progress = currentSize.toInt() } - thumbnailImage.setImageBitmap(file.thumbnail) + val totalSizeInfo = if (downloadSize > 0) { + downloadSize.formatAsFileSize() + } else { + context.getString(R.string.unknown) + } + if (downloadSize > currentSize) { + downloadOverlay.visibility = View.VISIBLE + resumePauseBtn.setImageResource(R.drawable.ic_download) + fileSize.text = "${currentSize.formatAsFileSize()} / $totalSizeInfo" + } else { + downloadOverlay.visibility = View.GONE + fileSize.text = totalSizeInfo + } + + download.thumbnailPath?.let { path -> + thumbnailImage.setImageBitmap(ImageHelper.getDownloadedImage(context, path)) + } + + progressBar.setOnClickListener { + val isDownloading = toogleDownload(downloads[position]) + + resumePauseBtn.setImageResource( + if (isDownloading) { + R.drawable.ic_pause + } else { + R.drawable.ic_download + } + ) + } root.setOnClickListener { - val intent = Intent(root.context, OfflinePlayerActivity::class.java).also { - it.putExtra(IntentData.fileName, file.name) - } + val intent = Intent(root.context, OfflinePlayerActivity::class.java) + intent.putExtra(IntentData.videoId, download.videoId) root.context.startActivity(intent) } @@ -61,27 +98,18 @@ class DownloadsAdapter( ) { _, index -> when (index) { 0 -> { - val audioDir = DownloadHelper.getDownloadDir( - root.context, - DownloadHelper.AUDIO_DIR - ) - val videoDir = DownloadHelper.getDownloadDir( - root.context, - DownloadHelper.VIDEO_DIR - ) - - listOf(audioDir, videoDir).forEach { - val f = File(it, file.name) - if (f.exists()) { + items.map { File(it.path) }.forEach { file -> + if (file.exists()) { try { - f.delete() - } catch (e: Exception) { - e.printStackTrace() - } + file.delete() + } catch (_: Exception) { } } } - files.removeAt(position) + query { + DatabaseHolder.Database.downloadDao().deleteDownload(download) + } + downloads.removeAt(position) notifyItemRemoved(position) notifyItemRangeChanged(position, itemCount) } @@ -95,6 +123,6 @@ class DownloadsAdapter( } override fun getItemCount(): Int { - return files.size + return downloads.size } } diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/DownloadDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/DownloadDialog.kt index 3cdeb9732..0ca9b30be 100644 --- a/app/src/main/java/com/github/libretube/ui/dialogs/DownloadDialog.kt +++ b/app/src/main/java/com/github/libretube/ui/dialogs/DownloadDialog.kt @@ -1,8 +1,8 @@ package com.github.libretube.ui.dialogs import android.app.Dialog -import android.content.Intent import android.os.Bundle +import android.text.InputFilter import android.util.Log import android.view.View import android.widget.ArrayAdapter @@ -15,10 +15,8 @@ import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.obj.Streams import com.github.libretube.databinding.DialogDownloadBinding import com.github.libretube.extensions.TAG -import com.github.libretube.extensions.sanitize -import com.github.libretube.services.DownloadService -import com.github.libretube.util.ImageHelper -import com.github.libretube.util.MetadataHelper +import com.github.libretube.util.DownloadHelper +import com.github.libretube.util.TextUtils import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.io.IOException import retrofit2.HttpException @@ -41,6 +39,25 @@ class DownloadDialog( binding.videoSpinner.visibility = View.VISIBLE } + binding.fileName.filters += InputFilter { source, start, end, _, _, _ -> + if (source.isNullOrBlank()) { + return@InputFilter null + } + + // Extract actual source + val actualSource = source.subSequence(start, end) + // Filter out unsupported characters + val filtered = actualSource.filterNot { + TextUtils.RESERVED_CHARS.contains(it, true) + } + // Check if something was filtered out + return@InputFilter if (actualSource.length != filtered.length) { + filtered + } else { + null + } + } + return MaterialAlertDialogBuilder(requireContext()) .setView(binding.root) .show() @@ -68,37 +85,45 @@ class DownloadDialog( binding.fileName.setText(streams.title.toString()) val vidName = arrayListOf() - val videoUrl = arrayListOf() // add empty selection vidName.add(getString(R.string.no_video)) - videoUrl.add("") // add all available video streams for (vid in streams.videoStreams!!) { if (vid.url != null) { val name = vid.quality + " " + vid.format vidName.add(name) - videoUrl.add(vid.url!!) } } val audioName = arrayListOf() - val audioUrl = arrayListOf() // add empty selection audioName.add(getString(R.string.no_audio)) - audioUrl.add("") // add all available audio streams for (audio in streams.audioStreams!!) { if (audio.url != null) { val name = audio.quality + " " + audio.format audioName.add(name) - audioUrl.add(audio.url!!) } } + val subtitleName = arrayListOf() + + // add empty selection + subtitleName.add(getString(R.string.no_subtitle)) + + // add all available subtitles + for (subtitle in streams.subtitles!!) { + if (subtitle.url != null) { + subtitleName.add(subtitle.name.toString()) + } + } + + if (subtitleName.size == 1) binding.subtitleSpinner.visibility = View.GONE + // initialize the video sources val videoArrayAdapter = ArrayAdapter( requireContext(), @@ -109,6 +134,7 @@ class DownloadDialog( binding.videoSpinner.adapter = videoArrayAdapter if (binding.videoSpinner.size >= 1) binding.videoSpinner.setSelection(1) if (binding.audioSpinner.size >= 1) binding.audioSpinner.setSelection(1) + if (binding.subtitleSpinner.size >= 1) binding.subtitleSpinner.setSelection(1) // initialize the audio sources val audioArrayAdapter = ArrayAdapter( @@ -120,48 +146,55 @@ class DownloadDialog( binding.audioSpinner.adapter = audioArrayAdapter if (binding.audioSpinner.size >= 1) binding.audioSpinner.setSelection(1) + // initialize the subtitle sources + val subtitleArrayAdapter = ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_item, + subtitleName + ) + subtitleArrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.subtitleSpinner.adapter = subtitleArrayAdapter + if (binding.subtitleSpinner.size >= 1) binding.subtitleSpinner.setSelection(1) + binding.download.setOnClickListener { if (binding.fileName.text.toString().isEmpty()) { Toast.makeText(context, R.string.invalid_filename, Toast.LENGTH_SHORT).show() return@setOnClickListener } - val vidUrl = videoUrl[binding.videoSpinner.selectedItemPosition] - val audUrl = audioUrl[binding.audioSpinner.selectedItemPosition] + val videoPosition = binding.videoSpinner.selectedItemPosition - 1 + val audioPosition = binding.audioSpinner.selectedItemPosition - 1 + val subtitlePosition = binding.subtitleSpinner.selectedItemPosition - 1 - if (audUrl == "" && vidUrl == "") { + if (videoPosition == -1 && audioPosition == -1 && subtitlePosition == -1) { Toast.makeText(context, R.string.nothing_selected, Toast.LENGTH_SHORT).show() return@setOnClickListener } - val fileName = binding.fileName.text.toString().sanitize() - - val metadataHelper = MetadataHelper(requireContext()) - metadataHelper.createMetadata(fileName, streams) - streams.thumbnailUrl?.let { thumbnailUrl -> - ImageHelper.downloadImage( - requireContext(), - thumbnailUrl, - fileName - ) + val videoStream = when (videoPosition) { + -1 -> null + else -> streams.videoStreams[videoPosition] + } + val audioStream = when (audioPosition) { + -1 -> null + else -> streams.audioStreams[audioPosition] + } + val subtitle = when (subtitlePosition) { + -1 -> null + else -> streams.subtitles[subtitlePosition] } - val intent = Intent(context, DownloadService::class.java) - - intent.putExtra( - "videoName", - fileName - ) - intent.putExtra( - "videoUrl", - vidUrl - ) - intent.putExtra( - "audioUrl", - audUrl + DownloadHelper.startDownloadService( + context = requireContext(), + videoId = videoId, + fileName = binding.fileName.text.toString(), + videoFormat = videoStream?.format, + videoQuality = videoStream?.quality, + audioFormat = audioStream?.format, + audioQuality = audioStream?.quality, + subtitleCode = subtitle?.code ) - context?.startService(intent) dismiss() } } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt index fb7812ca9..77bb48329 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt @@ -1,21 +1,62 @@ package com.github.libretube.ui.fragments +import android.content.ComponentName +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection import android.os.Bundle +import android.os.IBinder import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.size +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.github.libretube.R import com.github.libretube.databinding.FragmentDownloadsBinding +import com.github.libretube.db.DatabaseHolder.Companion.Database +import com.github.libretube.db.obj.DownloadWithItems +import com.github.libretube.extensions.awaitQuery +import com.github.libretube.extensions.formatAsFileSize +import com.github.libretube.obj.DownloadStatus +import com.github.libretube.receivers.DownloadReceiver +import com.github.libretube.services.DownloadService import com.github.libretube.ui.adapters.DownloadsAdapter import com.github.libretube.ui.base.BaseFragment +import com.github.libretube.ui.viewholders.DownloadsViewHolder import com.github.libretube.util.DownloadHelper -import com.github.libretube.util.ImageHelper -import com.github.libretube.util.MetadataHelper +import java.io.File +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch class DownloadsFragment : BaseFragment() { private lateinit var binding: FragmentDownloadsBinding + private var binder: DownloadService.LocalBinder? = null + private val downloads = mutableListOf() + private val downloadReceiver = DownloadReceiver() + + private val serviceConnection = object : ServiceConnection { + var isBound = false + var job: Job? = null + + override fun onServiceConnected(name: ComponentName?, iBinder: IBinder?) { + binder = iBinder as DownloadService.LocalBinder + isBound = true + job?.cancel() + job = lifecycleScope.launch { + binder?.getService()?.downloadFlow?.collectLatest { + updateProgress(it.first, it.second) + } + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + binder = null + isBound = false + } + } override fun onCreateView( inflater: LayoutInflater, @@ -29,36 +70,118 @@ class DownloadsFragment : BaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val files = DownloadHelper.getDownloadedFiles(requireContext()) - - if (files.isEmpty()) return - - val metadataHelper = MetadataHelper(requireContext()) - files.forEach { - metadataHelper.getMetadata(it.name)?.let { streams -> - it.metadata = streams - } - ImageHelper.getDownloadedImage(requireContext(), it.name)?.let { bitmap -> - it.thumbnail = bitmap - } + awaitQuery { + downloads.addAll(Database.downloadDao().getAll()) } + if (downloads.isEmpty()) return binding.downloadsEmpty.visibility = View.GONE binding.downloads.visibility = View.VISIBLE binding.downloads.layoutManager = LinearLayoutManager(context) - binding.downloads.adapter = DownloadsAdapter(files) + + binding.downloads.adapter = DownloadsAdapter(requireContext(), downloads) { + var isDownloading = false + val ids = it.downloadItems + .filter { item -> File(item.path).length() < item.downloadSize } + .map { item -> item.id } + + if (!serviceConnection.isBound) { + DownloadHelper.startDownloadService(requireContext()) + bindDownloadService(ids.toIntArray()) + return@DownloadsAdapter true + } + + binder?.getService()?.let { service -> + isDownloading = ids.any { id -> service.isDownloading(id) } + + ids.forEach { id -> + if (isDownloading) { + service.pause(id) + } else { + service.resume(id) + } + } + } + return@DownloadsAdapter isDownloading.not() + } binding.downloads.adapter?.registerAdapterDataObserver( object : RecyclerView.AdapterDataObserver() { - override fun onChanged() { + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { if (binding.downloads.size == 0) { binding.downloads.visibility = View.GONE binding.downloadsEmpty.visibility = View.VISIBLE } - super.onChanged() + super.onItemRangeRemoved(positionStart, itemCount) } } ) } + + override fun onStart() { + if (DownloadService.IS_DOWNLOAD_RUNNING) { + val intent = Intent(requireContext(), DownloadService::class.java) + context?.bindService(intent, serviceConnection, 0) + } + super.onStart() + } + + override fun onResume() { + super.onResume() + val filter = IntentFilter() + filter.addAction(DownloadService.ACTION_SERVICE_STARTED) + filter.addAction(DownloadService.ACTION_SERVICE_STOPPED) + context?.registerReceiver(downloadReceiver, filter) + } + + fun bindDownloadService(ids: IntArray? = null) { + if (serviceConnection.isBound) return + + val intent = Intent(context, DownloadService::class.java) + intent.putExtra("ids", ids) + context?.bindService(intent, serviceConnection, 0) + } + + fun updateProgress(id: Int, status: DownloadStatus) { + val index = downloads.indexOfFirst { + it.downloadItems.any { item -> item.id == id } + } + val view = binding.downloads.findViewHolderForAdapterPosition(index) as? DownloadsViewHolder + + view?.binding?.apply { + when (status) { + DownloadStatus.Paused -> { + resumePauseBtn.setImageResource(R.drawable.ic_download) + } + DownloadStatus.Completed -> { + downloadOverlay.visibility = View.GONE + } + is DownloadStatus.Progress -> { + downloadOverlay.visibility = View.VISIBLE + resumePauseBtn.setImageResource(R.drawable.ic_pause) + if (progressBar.isIndeterminate) return + progressBar.incrementProgressBy(status.progress.toInt()) + val progressInfo = progressBar.progress.formatAsFileSize() + + " /\n" + progressBar.max.formatAsFileSize() + fileSize.text = progressInfo + } + is DownloadStatus.Error -> { + resumePauseBtn.setImageResource(R.drawable.ic_restart) + } + } + } + } + + override fun onPause() { + super.onPause() + context?.unregisterReceiver(downloadReceiver) + } + + override fun onStop() { + super.onStop() + if (serviceConnection.isBound) { + context?.unbindService(serviceConnection) + } + } } diff --git a/app/src/main/java/com/github/libretube/util/DownloadHelper.kt b/app/src/main/java/com/github/libretube/util/DownloadHelper.kt index 041452811..a789f09bd 100644 --- a/app/src/main/java/com/github/libretube/util/DownloadHelper.kt +++ b/app/src/main/java/com/github/libretube/util/DownloadHelper.kt @@ -1,15 +1,23 @@ package com.github.libretube.util import android.content.Context +import android.content.Intent import android.os.Build -import com.github.libretube.obj.DownloadedFile +import com.github.libretube.constants.IntentData +import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.db.obj.DownloadItem +import com.github.libretube.services.DownloadService import java.io.File object DownloadHelper { const val VIDEO_DIR = "video" const val AUDIO_DIR = "audio" + const val SUBTITLE_DIR = "subtitle" const val METADATA_DIR = "metadata" const val THUMBNAIL_DIR = "thumbnail" + const val DOWNLOAD_CHUNK_SIZE = 8L * 1024 + const val DEFAULT_TIMEOUT = 15 * 1000 + const val DEFAULT_RETRY = 3 fun getOfflineStorageDir(context: Context): File { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return context.filesDir @@ -30,28 +38,41 @@ object DownloadHelper { } } - private fun File.toDownloadedFile(): DownloadedFile { - return DownloadedFile( - name = this.name, - size = this.length() - ) + fun getMaxConcurrentDownloads(): Int { + return PreferenceHelper.getString( + PreferenceKeys.MAX_CONCURRENT_DOWNLOADS, + "6" + ).toFloat().toInt() } - fun getDownloadedFiles(context: Context): MutableList { - val videoFiles = getDownloadDir(context, VIDEO_DIR).listFiles().orEmpty() - val audioFiles = getDownloadDir(context, AUDIO_DIR).listFiles().orEmpty().toMutableList() + fun startDownloadService( + context: Context, + videoId: String? = null, + fileName: String? = null, + videoFormat: String? = null, + videoQuality: String? = null, + audioFormat: String? = null, + audioQuality: String? = null, + subtitleCode: String? = null + ) { + val intent = Intent(context, DownloadService::class.java) - val files = mutableListOf() + intent.putExtra(IntentData.videoId, videoId) + intent.putExtra(IntentData.fileName, fileName) + intent.putExtra(IntentData.videoFormat, videoFormat) + intent.putExtra(IntentData.videoQuality, videoQuality) + intent.putExtra(IntentData.audioFormat, audioFormat) + intent.putExtra(IntentData.audioQuality, audioQuality) + intent.putExtra(IntentData.subtitleCode, subtitleCode) - videoFiles.forEach { - audioFiles.removeIf { audioFile -> audioFile.name == it.name } - files.add(it.toDownloadedFile()) + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) } + } - audioFiles.forEach { - files.add(it.toDownloadedFile()) - } - - return files + fun DownloadItem.getNotificationId(): Int { + return Int.MAX_VALUE - id } } diff --git a/app/src/main/java/com/github/libretube/util/ImageHelper.kt b/app/src/main/java/com/github/libretube/util/ImageHelper.kt index 73329fbd5..f5aaca9b6 100644 --- a/app/src/main/java/com/github/libretube/util/ImageHelper.kt +++ b/app/src/main/java/com/github/libretube/util/ImageHelper.kt @@ -55,15 +55,12 @@ object ImageHelper { if (!DataSaverMode.isEnabled(target.context)) target.load(url, imageLoader) } - fun downloadImage(context: Context, url: String, fileName: String) { + fun downloadImage(context: Context, url: String, path: String) { val request = ImageRequest.Builder(context) .data(url) .target { result -> val bitmap = (result as BitmapDrawable).bitmap - val file = File( - DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR), - fileName - ) + val file = File(path) saveImage(context, bitmap, Uri.fromFile(file)) } .build() @@ -71,11 +68,8 @@ object ImageHelper { imageLoader.enqueue(request) } - fun getDownloadedImage(context: Context, fileName: String): Bitmap? { - val file = File( - DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR), - fileName - ) + fun getDownloadedImage(context: Context, path: String): Bitmap? { + val file = File(path) if (!file.exists()) return null return getImage(context, Uri.fromFile(file)) } diff --git a/app/src/main/java/com/github/libretube/util/TextUtils.kt b/app/src/main/java/com/github/libretube/util/TextUtils.kt index e3b774b17..4d5f2f9e3 100644 --- a/app/src/main/java/com/github/libretube/util/TextUtils.kt +++ b/app/src/main/java/com/github/libretube/util/TextUtils.kt @@ -17,6 +17,11 @@ object TextUtils { */ const val EMAIL_REGEX = "^[A-Za-z](.*)([@]{1})(.{1,})(\\.)(.{1,})" + /** + * Reserved characters by unix which can not be used for file name. + */ + const val RESERVED_CHARS = "?:\"*|/\\<>\u0000" + fun toTwoDecimalsString(num: Int): String { return if (num >= 10) num.toString() else "0$num" } diff --git a/app/src/main/res/drawable/circular_progress.xml b/app/src/main/res/drawable/circular_progress.xml new file mode 100644 index 000000000..2aab83901 --- /dev/null +++ b/app/src/main/res/drawable/circular_progress.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_done.xml b/app/src/main/res/drawable/ic_done.xml index 160f0a052..2ce2c8440 100644 --- a/app/src/main/res/drawable/ic_done.xml +++ b/app/src/main/res/drawable/ic_done.xml @@ -2,16 +2,12 @@ android:width="24dp" android:height="24dp" android:tint="?attr/colorControlNormal" - android:viewportWidth="400" - android:viewportHeight="400"> + android:viewportWidth="24" + android:viewportHeight="24"> + android:fillColor="@android:color/white" + android:pathData="M20.13,5.41l-1.41,-1.41l-9.19,9.19l-4.25,-4.24l-1.41,1.41l5.66,5.66z" /> + android:fillColor="@android:color/white" + android:pathData="M5,18h14v2h-14z" /> diff --git a/app/src/main/res/layout/dialog_download.xml b/app/src/main/res/layout/dialog_download.xml index e82a19532..ebfbcf6d3 100644 --- a/app/src/main/res/layout/dialog_download.xml +++ b/app/src/main/res/layout/dialog_download.xml @@ -58,6 +58,12 @@ android:layout_height="wrap_content" android:layout_marginVertical="10dp" /> + +