From b5cb367edb0e4e7e6ed7c904cc40edcd4134e6c2 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Tue, 16 Sep 2025 21:49:14 -0400 Subject: [PATCH] Track downloaded streams and surface status on video detail --- .../10.json | 856 ++++++++++++++++++ app/src/main/java/org/schabi/newpipe/App.kt | 3 + .../org/schabi/newpipe/NewPipeDatabase.java | 3 +- .../schabi/newpipe/database/AppDatabase.java | 10 +- .../org/schabi/newpipe/database/Converters.kt | 11 + .../schabi/newpipe/database/Migrations.java | 17 + .../download/DownloadedStreamEntity.kt | 103 +++ .../download/DownloadedStreamStatus.kt | 14 + .../database/download/DownloadedStreamsDao.kt | 58 ++ .../newpipe/download/DownloadActivity.java | 8 + .../download/DownloadAvailabilityChecker.kt | 33 + .../newpipe/download/DownloadDialog.java | 70 +- .../newpipe/download/DownloadMaintenance.kt | 45 + .../download/DownloadRevalidationWorker.kt | 29 + .../download/DownloadedStreamsRepository.kt | 238 +++++ .../fragments/detail/VideoDetailFragment.kt | 300 ++++++ .../us/shandian/giga/get/DownloadMission.java | 4 + .../giga/service/DownloadManagerService.java | 36 +- .../main/res/layout/download_status_sheet.xml | 76 ++ .../main/res/layout/fragment_video_detail.xml | 24 +- app/src/main/res/values/strings.xml | 15 + app/src/main/res/values/styles_download.xml | 9 + 22 files changed, 1949 insertions(+), 13 deletions(-) create mode 100644 app/schemas/org.schabi.newpipe.database.AppDatabase/10.json create mode 100644 app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamEntity.kt create mode 100644 app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamStatus.kt create mode 100644 app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamsDao.kt create mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadAvailabilityChecker.kt create mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadMaintenance.kt create mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadRevalidationWorker.kt create mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadedStreamsRepository.kt create mode 100644 app/src/main/res/layout/download_status_sheet.xml create mode 100644 app/src/main/res/values/styles_download.xml diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/10.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/10.json new file mode 100644 index 000000000..b6ee8079a --- /dev/null +++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/10.json @@ -0,0 +1,856 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "92195bb0de0864bb1a0d7e4bbb16ec0f", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatar_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subscriberCount", + "columnName": "subscriber_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "notificationMode", + "columnName": "notification_mode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_subscriptions_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "creationDate", + "columnName": "creation_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_history_search", + "unique": false, + "columnNames": [ + "search" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "streams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "streamType", + "columnName": "stream_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploader_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "viewCount", + "columnName": "view_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "textualUploadDate", + "columnName": "textual_upload_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "upload_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isUploadDateApproximation", + "columnName": "is_upload_date_approximation", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_streams_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "stream_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessDate", + "columnName": "access_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatCount", + "columnName": "repeat_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "stream_id", + "access_date" + ] + }, + "indices": [ + { + "name": "index_stream_history_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "stream_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressMillis", + "columnName": "progress_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "stream_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL, `display_index` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isThumbnailPermanent", + "columnName": "is_thumbnail_permanent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailStreamId", + "columnName": "thumbnail_stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayIndex", + "columnName": "display_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist_stream_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "playlistUid", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "join_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "join_index" + ] + }, + "indices": [ + { + "name": "index_playlist_stream_join_playlist_id_join_index", + "unique": true, + "columnNames": [ + "playlist_id", + "join_index" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" + }, + { + "name": "index_playlist_stream_join_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "remote_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `display_index` INTEGER NOT NULL, `stream_count` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayIndex", + "columnName": "display_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamCount", + "columnName": "stream_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_remote_playlists_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "stream_id", + "subscription_id" + ] + }, + "indices": [ + { + "name": "index_feed_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_feed_group_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed_group_subscription_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "feedGroupId", + "columnName": "group_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "group_id", + "subscription_id" + ] + }, + "indices": [ + { + "name": "index_feed_group_subscription_join_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "feed_group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "group_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_last_updated", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscription_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "downloaded_streams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stream_uid` INTEGER NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `file_uri` TEXT NOT NULL, `parent_uri` TEXT, `display_name` TEXT, `mime` TEXT, `size_bytes` INTEGER, `quality_label` TEXT, `duration_ms` INTEGER, `status` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, `last_checked_at` INTEGER, `missing_since` INTEGER, FOREIGN KEY(`stream_uid`) REFERENCES `streams`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamUid", + "columnName": "stream_uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileUri", + "columnName": "file_uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentUri", + "columnName": "parent_uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mime", + "columnName": "mime", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "qualityLabel", + "columnName": "quality_label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckedAt", + "columnName": "last_checked_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "missingSince", + "columnName": "missing_since", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_downloaded_streams_stream_uid", + "unique": true, + "columnNames": [ + "stream_uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloaded_streams_stream_uid` ON `${TABLE_NAME}` (`stream_uid`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "stream_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "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, '92195bb0de0864bb1a0d7e4bbb16ec0f')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/App.kt b/app/src/main/java/org/schabi/newpipe/App.kt index a34caa957..4a0664117 100644 --- a/app/src/main/java/org/schabi/newpipe/App.kt +++ b/app/src/main/java/org/schabi/newpipe/App.kt @@ -25,6 +25,7 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins import org.acra.ACRA.init import org.acra.ACRA.isACRASenderServiceProcess import org.acra.config.CoreConfigurationBuilder +import org.schabi.newpipe.download.DownloadMaintenance import org.schabi.newpipe.error.ReCaptchaActivity import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.downloader.Downloader @@ -120,6 +121,8 @@ open class App : configureRxJavaErrorHandler() YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl) + + DownloadMaintenance.schedule(this) } override fun newImageLoader(context: Context): ImageLoader = diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java index 21c5354f4..ea1dbdf0c 100644 --- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java @@ -9,6 +9,7 @@ import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6; import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7; import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8; import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9; +import static org.schabi.newpipe.database.Migrations.MIGRATION_9_10; import android.content.Context; import android.database.Cursor; @@ -29,7 +30,7 @@ public final class NewPipeDatabase { return Room .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME) .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, - MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9) + MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10) .build(); } diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java index 04d93a238..8d5b951a1 100644 --- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java @@ -1,11 +1,13 @@ package org.schabi.newpipe.database; -import static org.schabi.newpipe.database.Migrations.DB_VER_9; +import static org.schabi.newpipe.database.Migrations.DB_VER_10; import androidx.room.Database; import androidx.room.RoomDatabase; import androidx.room.TypeConverters; +import org.schabi.newpipe.database.download.DownloadedStreamEntity; +import org.schabi.newpipe.database.download.DownloadedStreamsDao; import org.schabi.newpipe.database.feed.dao.FeedDAO; import org.schabi.newpipe.database.feed.dao.FeedGroupDAO; import org.schabi.newpipe.database.feed.model.FeedEntity; @@ -36,9 +38,9 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity; StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class, PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class, FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, - FeedLastUpdatedEntity.class + FeedLastUpdatedEntity.class, DownloadedStreamEntity.class }, - version = DB_VER_9 + version = DB_VER_10 ) public abstract class AppDatabase extends RoomDatabase { public static final String DATABASE_NAME = "newpipe.db"; @@ -62,4 +64,6 @@ public abstract class AppDatabase extends RoomDatabase { public abstract FeedGroupDAO feedGroupDAO(); public abstract SubscriptionDAO subscriptionDAO(); + + public abstract DownloadedStreamsDao downloadedStreamsDao(); } diff --git a/app/src/main/java/org/schabi/newpipe/database/Converters.kt b/app/src/main/java/org/schabi/newpipe/database/Converters.kt index ec097cc1b..95af4297b 100644 --- a/app/src/main/java/org/schabi/newpipe/database/Converters.kt +++ b/app/src/main/java/org/schabi/newpipe/database/Converters.kt @@ -1,6 +1,7 @@ package org.schabi.newpipe.database import androidx.room.TypeConverter +import org.schabi.newpipe.database.download.DownloadedStreamStatus import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.local.subscription.FeedGroupIcon import java.time.Instant @@ -49,4 +50,14 @@ class Converters { fun feedGroupIconOf(id: Int): FeedGroupIcon { return FeedGroupIcon.entries.first { it.id == id } } + + @TypeConverter + fun downloadedStreamStatusOf(value: Int?): DownloadedStreamStatus? { + return value?.let { DownloadedStreamStatus.fromValue(it) } + } + + @TypeConverter + fun integerOf(status: DownloadedStreamStatus?): Int? { + return status?.value + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.java b/app/src/main/java/org/schabi/newpipe/database/Migrations.java index c9f630869..a2f2171e7 100644 --- a/app/src/main/java/org/schabi/newpipe/database/Migrations.java +++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.java @@ -27,6 +27,7 @@ public final class Migrations { public static final int DB_VER_7 = 7; public static final int DB_VER_8 = 8; public static final int DB_VER_9 = 9; + public static final int DB_VER_10 = 10; private static final String TAG = Migrations.class.getName(); public static final boolean DEBUG = MainActivity.DEBUG; @@ -302,6 +303,22 @@ public final class Migrations { } }; + public static final Migration MIGRATION_9_10 = new Migration(DB_VER_9, DB_VER_10) { + @Override + public void migrate(@NonNull final SupportSQLiteDatabase database) { + database.execSQL("CREATE TABLE IF NOT EXISTS downloaded_streams " + + "(id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "stream_uid INTEGER NOT NULL, service_id INTEGER NOT NULL, " + + "url TEXT NOT NULL, file_uri TEXT NOT NULL, parent_uri TEXT, " + + "display_name TEXT, mime TEXT, size_bytes INTEGER, quality_label TEXT, " + + "duration_ms INTEGER, status INTEGER NOT NULL, added_at INTEGER NOT NULL, " + + "last_checked_at INTEGER, missing_since INTEGER, FOREIGN KEY(stream_uid) " + + "REFERENCES streams(uid) ON UPDATE CASCADE ON DELETE CASCADE)"); + database.execSQL("CREATE UNIQUE INDEX index_downloaded_streams_stream_uid " + + "ON downloaded_streams (stream_uid)"); + } + }; + private Migrations() { } } diff --git a/app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamEntity.kt b/app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamEntity.kt new file mode 100644 index 000000000..febc9f9d4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamEntity.kt @@ -0,0 +1,103 @@ +package org.schabi.newpipe.database.download + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_ADDED_AT +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_DISPLAY_NAME +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_DURATION_MS +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_FILE_URI +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_ID +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_LAST_CHECKED_AT +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_MIME +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_MISSING_SINCE +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_PARENT_URI +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_QUALITY_LABEL +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_SERVICE_ID +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_SIZE_BYTES +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_STATUS +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_STREAM_UID +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_URL +import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.TABLE_NAME +import org.schabi.newpipe.database.stream.model.StreamEntity + +@Entity( + tableName = TABLE_NAME, + indices = [Index(value = [COLUMN_STREAM_UID], unique = true)], + foreignKeys = [ + ForeignKey( + entity = StreamEntity::class, + parentColumns = [StreamEntity.STREAM_ID], + childColumns = [COLUMN_STREAM_UID], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class DownloadedStreamEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = COLUMN_ID) + var id: Long = 0, + + @ColumnInfo(name = COLUMN_STREAM_UID) + var streamUid: Long, + + @ColumnInfo(name = COLUMN_SERVICE_ID) + var serviceId: Int, + + @ColumnInfo(name = COLUMN_URL) + var url: String, + + @ColumnInfo(name = COLUMN_FILE_URI) + var fileUri: String, + + @ColumnInfo(name = COLUMN_PARENT_URI) + var parentUri: String? = null, + + @ColumnInfo(name = COLUMN_DISPLAY_NAME) + var displayName: String? = null, + + @ColumnInfo(name = COLUMN_MIME) + var mime: String? = null, + + @ColumnInfo(name = COLUMN_SIZE_BYTES) + var sizeBytes: Long? = null, + + @ColumnInfo(name = COLUMN_QUALITY_LABEL) + var qualityLabel: String? = null, + + @ColumnInfo(name = COLUMN_DURATION_MS) + var durationMs: Long? = null, + + @ColumnInfo(name = COLUMN_STATUS) + var status: DownloadedStreamStatus, + + @ColumnInfo(name = COLUMN_ADDED_AT) + var addedAt: Long, + + @ColumnInfo(name = COLUMN_LAST_CHECKED_AT) + var lastCheckedAt: Long? = null, + + @ColumnInfo(name = COLUMN_MISSING_SINCE) + var missingSince: Long? = null +) { + companion object { + const val TABLE_NAME = "downloaded_streams" + const val COLUMN_ID = "id" + const val COLUMN_STREAM_UID = "stream_uid" + const val COLUMN_SERVICE_ID = "service_id" + const val COLUMN_URL = "url" + const val COLUMN_FILE_URI = "file_uri" + const val COLUMN_PARENT_URI = "parent_uri" + const val COLUMN_DISPLAY_NAME = "display_name" + const val COLUMN_MIME = "mime" + const val COLUMN_SIZE_BYTES = "size_bytes" + const val COLUMN_QUALITY_LABEL = "quality_label" + const val COLUMN_DURATION_MS = "duration_ms" + const val COLUMN_STATUS = "status" + const val COLUMN_ADDED_AT = "added_at" + const val COLUMN_LAST_CHECKED_AT = "last_checked_at" + const val COLUMN_MISSING_SINCE = "missing_since" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamStatus.kt b/app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamStatus.kt new file mode 100644 index 000000000..c890a657e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamStatus.kt @@ -0,0 +1,14 @@ +package org.schabi.newpipe.database.download + +enum class DownloadedStreamStatus(val value: Int) { + IN_PROGRESS(0), + AVAILABLE(1), + MISSING(2), + UNLINKED(3); + + companion object { + fun fromValue(value: Int): DownloadedStreamStatus = entries.firstOrNull { + it.value == value + } ?: IN_PROGRESS + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamsDao.kt b/app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamsDao.kt new file mode 100644 index 000000000..5c6535e25 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamsDao.kt @@ -0,0 +1,58 @@ +package org.schabi.newpipe.database.download + +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 io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Maybe + +@Dao +interface DownloadedStreamsDao { + @Query("SELECT * FROM downloaded_streams WHERE stream_uid = :streamUid LIMIT 1") + fun observeByStreamUid(streamUid: Long): Flowable> + + @Query("SELECT * FROM downloaded_streams WHERE stream_uid = :streamUid LIMIT 1") + fun getByStreamUid(streamUid: Long): Maybe + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(entity: DownloadedStreamEntity): Long + + @Update + fun update(entity: DownloadedStreamEntity): Int + + @Query("SELECT * FROM downloaded_streams WHERE stream_uid = :streamUid LIMIT 1") + fun findEntityByStreamUid(streamUid: Long): DownloadedStreamEntity? + + @Query("SELECT * FROM downloaded_streams WHERE id = :id LIMIT 1") + fun findEntityById(id: Long): DownloadedStreamEntity? + + @Transaction + fun insertOrUpdate(entity: DownloadedStreamEntity): Long { + val newId = insert(entity) + if (newId != -1L) { + entity.id = newId + return newId + } + update(entity) + return entity.id + } + + @Query("UPDATE downloaded_streams SET status = :status, last_checked_at = :lastCheckedAt, missing_since = :missingSince WHERE id = :id") + fun updateStatus(id: Long, status: DownloadedStreamStatus, lastCheckedAt: Long?, missingSince: Long?) + + @Query("UPDATE downloaded_streams SET file_uri = :fileUri WHERE id = :id") + fun updateFileUri(id: Long, fileUri: String) + + @Delete + fun delete(entity: DownloadedStreamEntity) + + @Query("DELETE FROM downloaded_streams WHERE stream_uid = :streamUid") + fun deleteByStreamUid(streamUid: Long): Int + + @Query("SELECT * FROM downloaded_streams WHERE status = :status ORDER BY last_checked_at ASC LIMIT :limit") + fun listByStatus(status: DownloadedStreamStatus, limit: Int): List +} diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java index 33702a6a3..d6c0c15b9 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java @@ -81,6 +81,14 @@ public class DownloadActivity extends AppCompatActivity { return true; } + @Override + protected void onResume() { + super.onResume(); + new Thread(() -> + DownloadMaintenance.revalidateAvailable(DownloadActivity.this, 10) + ).start(); + } + @Override public boolean onOptionsItemSelected(final MenuItem item) { switch (item.getItemId()) { diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadAvailabilityChecker.kt b/app/src/main/java/org/schabi/newpipe/download/DownloadAvailabilityChecker.kt new file mode 100644 index 000000000..ac39cf32b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadAvailabilityChecker.kt @@ -0,0 +1,33 @@ +package org.schabi.newpipe.download + +import android.content.Context +import android.net.Uri +import android.util.Log +import org.schabi.newpipe.BuildConfig +import java.io.File + +object DownloadAvailabilityChecker { + private const val TAG = "DownloadAvailabilityChecker" + + fun isReadable(context: Context, uri: Uri): Boolean { + val scheme = uri.scheme + return when { + scheme.equals("file", ignoreCase = true) -> + File(uri.path ?: return false).canRead() + scheme.equals("content", ignoreCase = true) -> + probeContentUri(context, uri) + else -> probeContentUri(context, uri) + } + } + + private fun probeContentUri(context: Context, uri: Uri): Boolean { + return try { + context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { true } ?: false + } catch (throwable: Throwable) { + if (BuildConfig.DEBUG) { + Log.w(TAG, "Failed to probe availability for $uri", throwable) + } + false + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index 0857fa339..a43738e1f 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -79,7 +79,9 @@ import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.TimeUnit; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.postprocessing.Postprocessing; @@ -1132,12 +1134,70 @@ public class DownloadDialog extends DialogFragment ); } - DownloadManagerService.startMission(context, urls, storage, kind, threads, - currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo)); + final String qualityLabel = buildQualityLabel(selectedStream); + final MediaFormat selectedFormat = selectedStream.getFormat(); + final String resolvedMime = selectedFormat != null ? selectedFormat.getMimeType() + : storage.getType(); + final Long durationMs = currentInfo.getDuration() > 0 + ? TimeUnit.SECONDS.toMillis(currentInfo.getDuration()) : null; + final Long estimatedSize = nearLength > 0 ? nearLength : null; - Toast.makeText(context, getString(R.string.download_has_started), - Toast.LENGTH_SHORT).show(); + final char missionKind = kind; + final int missionThreads = threads; + final String missionSourceUrl = currentInfo.getUrl(); + final String missionPsName = psName; + final String[] missionPsArgs = psArgs; + final long missionNearLength = nearLength; - dismiss(); + disposables.add(DownloadedStreamsRepository.INSTANCE + .upsertForEnqueued(requireContext(), currentInfo, storage, null, resolvedMime, + qualityLabel, durationMs, estimatedSize) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(association -> { + DownloadManagerService.startMission( + context, + urls, + storage, + missionKind, + missionThreads, + missionSourceUrl, + missionPsName, + missionPsArgs, + missionNearLength, + new ArrayList<>(recoveryInfo), + association.getStreamUid(), + association.getEntityId(), + currentInfo.getServiceId() + ); + + Toast.makeText(context, getString(R.string.download_has_started), + Toast.LENGTH_SHORT).show(); + + dismiss(); + }, + throwable -> ErrorUtil.createNotification(requireContext(), + new ErrorInfo(throwable, UserAction.DOWNLOAD_FAILED, + "Preparing download metadata", currentInfo)) + )); + } + + @Nullable + private String buildQualityLabel(@NonNull final Stream stream) { + if (stream instanceof VideoStream) { + return ((VideoStream) stream).getResolution(); + } else if (stream instanceof AudioStream) { + final int bitrate = ((AudioStream) stream).getAverageBitrate(); + return bitrate > 0 ? bitrate + "kbps" : null; + } else if (stream instanceof SubtitlesStream) { + final SubtitlesStream subtitlesStream = (SubtitlesStream) stream; + final String language = subtitlesStream.getDisplayLanguageName(); + if (subtitlesStream.isAutoGenerated()) { + return language + " (" + getString(R.string.caption_auto_generated) + ")"; + } + return language; + } + + final MediaFormat format = stream.getFormat(); + return format != null ? format.getSuffix() : null; } } diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadMaintenance.kt b/app/src/main/java/org/schabi/newpipe/download/DownloadMaintenance.kt new file mode 100644 index 000000000..44f0a96cf --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadMaintenance.kt @@ -0,0 +1,45 @@ +package org.schabi.newpipe.download + +import android.content.Context +import android.net.Uri +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.database.download.DownloadedStreamStatus +import java.util.concurrent.TimeUnit + +object DownloadMaintenance { + private const val WORK_NAME = "download_revalidation" + + @JvmStatic + fun revalidateAvailable(context: Context, limit: Int = 25) { + val dao = NewPipeDatabase.getInstance(context).downloadedStreamsDao() + val entries = dao.listByStatus(DownloadedStreamStatus.AVAILABLE, limit) + if (entries.isEmpty()) return + + val now = System.currentTimeMillis() + for (entry in entries) { + val uriString = entry.fileUri + if (uriString.isBlank()) { + dao.updateStatus(entry.id, DownloadedStreamStatus.MISSING, now, entry.missingSince ?: now) + continue + } + + val available = DownloadAvailabilityChecker.isReadable(context, Uri.parse(uriString)) + if (available) { + dao.updateStatus(entry.id, DownloadedStreamStatus.AVAILABLE, now, null) + } else { + dao.updateStatus(entry.id, DownloadedStreamStatus.MISSING, now, entry.missingSince ?: now) + } + } + } + + @JvmStatic + fun schedule(context: Context) { + val workRequest = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .build() + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, workRequest) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadRevalidationWorker.kt b/app/src/main/java/org/schabi/newpipe/download/DownloadRevalidationWorker.kt new file mode 100644 index 000000000..5c80a28ac --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadRevalidationWorker.kt @@ -0,0 +1,29 @@ +package org.schabi.newpipe.download + +import android.content.Context +import android.util.Log +import androidx.work.Worker +import androidx.work.WorkerParameters +import org.schabi.newpipe.BuildConfig + +class DownloadRevalidationWorker( + appContext: Context, + workerParams: WorkerParameters, +) : Worker(appContext, workerParams) { + + override fun doWork(): Result { + return try { + DownloadMaintenance.revalidateAvailable(applicationContext) + Result.success() + } catch (throwable: Throwable) { + if (BuildConfig.DEBUG) { + Log.e(TAG, "Failed to revalidate downloads", throwable) + } + Result.retry() + } + } + + private companion object { + private const val TAG = "DownloadRevalidation" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadedStreamsRepository.kt b/app/src/main/java/org/schabi/newpipe/download/DownloadedStreamsRepository.kt new file mode 100644 index 000000000..7d2354830 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadedStreamsRepository.kt @@ -0,0 +1,238 @@ +package org.schabi.newpipe.download + +import android.content.Context +import android.net.Uri +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.database.AppDatabase +import org.schabi.newpipe.database.download.DownloadedStreamEntity +import org.schabi.newpipe.database.download.DownloadedStreamStatus +import org.schabi.newpipe.database.download.DownloadedStreamsDao +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.streams.io.StoredFileHelper + +object DownloadedStreamsRepository { + + data class DownloadAssociation( + val streamUid: Long, + val entityId: Long + ) + + private fun database(context: Context): AppDatabase { + return NewPipeDatabase.getInstance(context) + } + + private fun downloadedDao(context: Context): DownloadedStreamsDao { + return database(context).downloadedStreamsDao() + } + + fun observeByStreamUid(context: Context, streamUid: Long): Flowable> { + return downloadedDao(context) + .observeByStreamUid(streamUid) + .subscribeOn(Schedulers.io()) + } + + fun getByStreamUid(context: Context, streamUid: Long): Maybe { + return downloadedDao(context) + .getByStreamUid(streamUid) + .subscribeOn(Schedulers.io()) + } + + fun ensureStreamEntry(context: Context, info: StreamInfo): Single { + return Single.fromCallable { + database(context).streamDAO().upsert(StreamEntity(info)) + }.subscribeOn(Schedulers.io()) + } + + fun upsertForEnqueued( + context: Context, + info: StreamInfo, + storage: StoredFileHelper, + displayName: String?, + mime: String?, + qualityLabel: String?, + durationMs: Long?, + sizeBytes: Long? + ): Single { + return Single.fromCallable { + val db = database(context) + db.runInTransaction { + val streamDao = db.streamDAO() + val dao = db.downloadedStreamsDao() + val streamId = streamDao.upsert(StreamEntity(info)) + val now = System.currentTimeMillis() + val fileUri = storage.uriString() + val entity = dao.findEntityByStreamUid(streamId) + val resolvedDisplayName = displayName ?: storage.getName() + val resolvedMime = mime ?: storage.getType() + + if (entity == null) { + val newEntity = DownloadedStreamEntity( + streamUid = streamId, + serviceId = info.serviceId, + url = info.url, + fileUri = fileUri, + parentUri = storage.parentUriString(), + displayName = resolvedDisplayName, + mime = resolvedMime, + sizeBytes = sizeBytes, + qualityLabel = qualityLabel, + durationMs = durationMs, + status = DownloadedStreamStatus.IN_PROGRESS, + addedAt = now, + lastCheckedAt = null, + missingSince = null + ) + val insertedId = dao.insert(newEntity) + val resolvedId = if (insertedId == -1L) { + dao.findEntityByStreamUid(streamId)?.id + ?: throw IllegalStateException("Failed to resolve downloaded stream entry") + } else { + insertedId + } + newEntity.id = resolvedId + DownloadAssociation(streamId, resolvedId) + } else { + entity.serviceId = info.serviceId + entity.url = info.url + entity.fileUri = fileUri + val parentUri = storage.parentUriString() + if (parentUri != null) { + entity.parentUri = parentUri + } + entity.displayName = resolvedDisplayName + entity.mime = resolvedMime + entity.sizeBytes = sizeBytes + entity.qualityLabel = qualityLabel + entity.durationMs = durationMs + entity.status = DownloadedStreamStatus.IN_PROGRESS + entity.lastCheckedAt = null + entity.missingSince = null + if (entity.addedAt <= 0) { + entity.addedAt = now + } + dao.update(entity) + DownloadAssociation(streamId, entity.id) + } + } + }.subscribeOn(Schedulers.io()) + } + + fun markFinished( + context: Context, + association: DownloadAssociation, + serviceId: Int, + url: String, + storage: StoredFileHelper, + mime: String?, + qualityLabel: String?, + durationMs: Long?, + sizeBytes: Long? + ): Completable { + return Completable.fromAction { + val dao = downloadedDao(context) + val now = System.currentTimeMillis() + val entity = dao.findEntityById(association.entityId) + ?: dao.findEntityByStreamUid(association.streamUid) + ?: DownloadedStreamEntity( + streamUid = association.streamUid, + serviceId = serviceId, + url = url, + fileUri = storage.uriString(), + parentUri = storage.parentUriString(), + displayName = storage.getName(), + mime = mime ?: storage.getType(), + sizeBytes = sizeBytes, + qualityLabel = qualityLabel, + durationMs = durationMs, + status = DownloadedStreamStatus.IN_PROGRESS, + addedAt = now + ) + entity.serviceId = serviceId + entity.url = url + entity.fileUri = storage.uriString() + storage.parentUriString()?.let { entity.parentUri = it } + entity.displayName = storage.getName() + val resolvedMime = mime ?: storage.getType() ?: entity.mime + entity.mime = resolvedMime + entity.sizeBytes = sizeBytes ?: storage.safeLength() ?: entity.sizeBytes + if (qualityLabel != null) { + entity.qualityLabel = qualityLabel + } + if (durationMs != null) { + entity.durationMs = durationMs + } + entity.status = DownloadedStreamStatus.AVAILABLE + entity.lastCheckedAt = now + entity.missingSince = null + if (entity.addedAt <= 0) { + entity.addedAt = now + } + + if (entity.id == 0L) { + val newId = dao.insert(entity) + entity.id = newId + } else { + dao.update(entity) + } + }.subscribeOn(Schedulers.io()) + } + + fun updateStatus( + context: Context, + entityId: Long, + status: DownloadedStreamStatus, + lastCheckedAt: Long? = System.currentTimeMillis(), + missingSince: Long? = null + ): Completable { + return Completable.fromAction { + downloadedDao(context).updateStatus(entityId, status, lastCheckedAt, missingSince) + }.subscribeOn(Schedulers.io()) + } + + fun updateFileUri(context: Context, entityId: Long, uri: Uri): Completable { + return Completable.fromAction { + downloadedDao(context).updateFileUri(entityId, uri.toString()) + }.subscribeOn(Schedulers.io()) + } + + fun relink(context: Context, entity: DownloadedStreamEntity, uri: Uri): Completable { + return Single.fromCallable { + StoredFileHelper(context, uri, entity.mime ?: StoredFileHelper.DEFAULT_MIME) + }.flatMapCompletable { helper -> + val association = DownloadAssociation(entity.streamUid, entity.id) + markFinished( + context, + association, + entity.serviceId, + entity.url, + helper, + helper.type, + entity.qualityLabel, + entity.durationMs, + helper.safeLength() + ) + }.subscribeOn(Schedulers.io()) + } + + fun deleteByStreamUid(context: Context, streamUid: Long): Completable { + return Completable.fromAction { + downloadedDao(context).deleteByStreamUid(streamUid) + }.subscribeOn(Schedulers.io()) + } + + private fun StoredFileHelper.uriString(): String = getUri().toString() + + private fun StoredFileHelper.safeLength(): Long? { + return runCatching { length() }.getOrNull() + } + + private fun StoredFileHelper.parentUriString(): String? { + return runCatching { getParentUri() }.getOrNull()?.toString() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 279f5150a..73412eba3 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -4,6 +4,7 @@ import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Activity import android.content.BroadcastReceiver +import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -12,14 +13,17 @@ import android.content.pm.ActivityInfo import android.database.ContentObserver import android.graphics.Color import android.graphics.Rect +import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.provider.DocumentsContract import android.provider.Settings import android.util.DisplayMetrics import android.util.Log import android.util.TypedValue +import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.MotionEvent import android.view.View @@ -31,7 +35,9 @@ import android.view.WindowManager import android.view.animation.DecelerateInterpolator import android.widget.FrameLayout import android.widget.RelativeLayout +import android.widget.TextView import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.AttrRes import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog @@ -44,6 +50,7 @@ import androidx.core.net.toUri import androidx.core.os.postDelayed import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.documentfile.provider.DocumentFile import androidx.preference.PreferenceManager import coil3.util.CoilUtils import com.evernote.android.state.State @@ -52,15 +59,22 @@ import com.google.android.exoplayer2.PlaybackParameters import com.google.android.material.appbar.AppBarLayout import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback +import com.google.android.material.bottomsheet.BottomSheetDialog import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.App import org.schabi.newpipe.R +import org.schabi.newpipe.database.download.DownloadedStreamEntity +import org.schabi.newpipe.database.download.DownloadedStreamStatus import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.databinding.DownloadStatusSheetBinding import org.schabi.newpipe.databinding.FragmentVideoDetailBinding +import org.schabi.newpipe.download.DownloadActivity +import org.schabi.newpipe.download.DownloadAvailabilityChecker import org.schabi.newpipe.download.DownloadDialog +import org.schabi.newpipe.download.DownloadedStreamsRepository import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar @@ -115,6 +129,7 @@ import org.schabi.newpipe.util.ThemeHelper import org.schabi.newpipe.util.external_communication.KoreUtils import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.image.CoilHelper +import java.io.File import java.util.LinkedList import java.util.concurrent.TimeUnit import kotlin.math.abs @@ -181,6 +196,17 @@ class VideoDetailFragment : private var currentWorker: Disposable? = null private val disposables = CompositeDisposable() private var positionSubscriber: Disposable? = null + private var downloadStatusDisposable: Disposable? = null + private var currentStreamUid: Long? = null + private var currentDownloadedStream: DownloadedStreamEntity? = null + private var pendingRelinkEntity: DownloadedStreamEntity? = null + + private val relinkLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null && pendingRelinkEntity != null) { + handleRelinkResult(pendingRelinkEntity!!, uri) + } + pendingRelinkEntity = null + } /*////////////////////////////////////////////////////////////////////////// // Service management @@ -348,6 +374,13 @@ class VideoDetailFragment : override fun onDestroyView() { super.onDestroyView() + downloadStatusDisposable?.let { + disposables.remove(it) + it.dispose() + } + downloadStatusDisposable = null + currentDownloadedStream = null + currentStreamUid = null nullableBinding = null } @@ -1366,6 +1399,9 @@ class VideoDetailFragment : currentInfo = info setInitialData(info.serviceId, info.originalUrl, info.name, playQueue) + updateDownloadChip(null) + observeDownloadStatus(info) + updateTabs(info) binding.detailThumbnailPlayButton.animate(true, 200) @@ -1544,6 +1580,269 @@ class VideoDetailFragment : } } + private fun observeDownloadStatus(info: StreamInfo) { + val context = context ?: return + downloadStatusDisposable?.let { + disposables.remove(it) + it.dispose() + } + + val disposable = DownloadedStreamsRepository.ensureStreamEntry(context, info) + .flatMapPublisher { streamUid: Long -> + currentStreamUid = streamUid + DownloadedStreamsRepository.observeByStreamUid(context, streamUid) + } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { entities: List -> + val entity = entities.firstOrNull() + updateDownloadChip(entity) + }, + { throwable -> + if (DEBUG) { + Log.e(TAG, "Failed to observe download state", throwable) + } + updateDownloadChip(null) + } + ) + + downloadStatusDisposable = disposable + disposables.add(disposable) + } + + private fun updateDownloadChip(entity: DownloadedStreamEntity?) { + if (nullableBinding == null) return + + currentDownloadedStream = entity + val chip = binding.detailDownloadStatusChip ?: return + + if (entity == null || entity.status == DownloadedStreamStatus.UNLINKED) { + chip.isGone = true + chip.setOnClickListener(null) + return + } + + chip.isVisible = true + when (entity.status) { + DownloadedStreamStatus.IN_PROGRESS -> { + chip.text = getString(R.string.download_status_downloading) + chip.setOnClickListener { openDownloadsActivity() } + } + DownloadedStreamStatus.AVAILABLE, + DownloadedStreamStatus.MISSING -> { + chip.text = buildDownloadedLabel(entity) + chip.setOnClickListener { showDownloadOptions(entity) } + } + DownloadedStreamStatus.UNLINKED -> { + chip.isGone = true + chip.setOnClickListener(null) + } + } + } + + private fun buildDownloadedLabel(entity: DownloadedStreamEntity): String { + val quality = entity.qualityLabel?.takeIf { it.isNotBlank() } + return if (quality != null) { + getString(R.string.download_status_downloaded, quality) + } else { + getString(R.string.download_status_downloaded_simple) + } + } + + private fun showDownloadOptions(entity: DownloadedStreamEntity) { + val baseContext = requireContext() + val dialogTheme = ThemeHelper.getDialogTheme(baseContext) + val themedContext = ContextThemeWrapper(baseContext, dialogTheme) + val sheetBinding = DownloadStatusSheetBinding.inflate(LayoutInflater.from(themedContext)) + val dialog = BottomSheetDialog(themedContext) + dialog.setContentView(sheetBinding.root) + + val primaryTextColor = ThemeHelper.resolveColorFromAttr(themedContext, android.R.attr.textColorPrimary) + val secondaryTextColor = ThemeHelper.resolveColorFromAttr(themedContext, android.R.attr.textColorSecondary) + val backgroundDrawable = ThemeHelper.resolveDrawable(themedContext, android.R.attr.windowBackground) + val rippleDrawable = ThemeHelper.resolveDrawable(themedContext, R.attr.selector) + val accentColor = ThemeHelper.resolveColorFromAttr(themedContext, androidx.appcompat.R.attr.colorAccent) + + sheetBinding.root.background = backgroundDrawable + sheetBinding.downloadStatusTitle.setTextColor(primaryTextColor) + sheetBinding.downloadStatusSubtitle.setTextColor(secondaryTextColor) + + fun styleAction(textView: TextView) { + textView.setTextColor(primaryTextColor) + textView.background = rippleDrawable + } + + styleAction(sheetBinding.downloadStatusOpen) + styleAction(sheetBinding.downloadStatusDelete) + styleAction(sheetBinding.downloadStatusShowInFolder) + sheetBinding.downloadStatusRemoveLink.apply { + setTextColor(accentColor) + background = rippleDrawable + } + + val fileAvailable = entity.fileUri.takeUnless { it.isBlank() } + ?.let { DownloadAvailabilityChecker.isReadable(baseContext, Uri.parse(it)) } + ?: false + + val title = entity.displayName?.takeIf { it.isNotBlank() } + ?: currentInfo?.name + ?: getString(R.string.download) + sheetBinding.downloadStatusTitle.text = title + + val subtitleParts = mutableListOf() + entity.qualityLabel?.takeIf { it.isNotBlank() }?.let(subtitleParts::add) + if (!fileAvailable) { + subtitleParts.add(getString(R.string.download_status_missing)) + } + + if (subtitleParts.isEmpty()) { + sheetBinding.downloadStatusSubtitle.isGone = true + } else { + sheetBinding.downloadStatusSubtitle.isVisible = true + sheetBinding.downloadStatusSubtitle.text = subtitleParts.joinToString(" • ") + } + + sheetBinding.downloadStatusOpen.text = getString(R.string.download_action_open) + sheetBinding.downloadStatusDelete.text = getString(R.string.download_action_delete) + sheetBinding.downloadStatusShowInFolder.text = getString(R.string.download_action_show_in_folder) + sheetBinding.downloadStatusRemoveLink.text = getString(R.string.download_action_remove_link) + + sheetBinding.downloadStatusOpen.isVisible = fileAvailable + sheetBinding.downloadStatusDelete.isVisible = fileAvailable + sheetBinding.downloadStatusShowInFolder.isVisible = fileAvailable && !entity.parentUri.isNullOrBlank() + sheetBinding.downloadStatusRemoveLink.isVisible = true + + sheetBinding.downloadStatusOpen.setOnClickListener { + dialog.dismiss() + openDownloaded(entity) + } + + sheetBinding.downloadStatusDelete.setOnClickListener { + dialog.dismiss() + deleteDownloadedFile(entity) + } + + sheetBinding.downloadStatusShowInFolder.setOnClickListener { + dialog.dismiss() + showInFolder(entity) + } + + sheetBinding.downloadStatusRemoveLink.setOnClickListener { + dialog.dismiss() + removeDownloadAssociation(entity) + } + + dialog.show() + } + + private fun openDownloaded(entity: DownloadedStreamEntity) { + val uri = entity.fileUri.takeUnless { it.isBlank() }?.let(Uri::parse) ?: return + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, entity.mime ?: "*/*") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + runCatching { startActivity(intent) } + .onFailure { + if (DEBUG) Log.e(TAG, "Failed to open downloaded file", it) + Toast.makeText(requireContext(), R.string.download_open_failed, Toast.LENGTH_SHORT).show() + } + } + + private fun showInFolder(entity: DownloadedStreamEntity) { + val parent = entity.parentUri?.takeIf { it.isNotBlank() }?.let(Uri::parse) + if (parent == null) { + Toast.makeText(requireContext(), R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show() + return + } + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(parent, DocumentsContract.Document.MIME_TYPE_DIR) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + runCatching { startActivity(intent) } + .onFailure { + val treeIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + putExtra(DocumentsContract.EXTRA_INITIAL_URI, parent) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + runCatching { startActivity(treeIntent) } + .onFailure { throwable -> + if (DEBUG) Log.e(TAG, "Failed to open folder", throwable) + Toast.makeText(requireContext(), R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show() + } + } + } + + private fun removeDownloadAssociation(entity: DownloadedStreamEntity) { + val context = requireContext() + disposables.add( + DownloadedStreamsRepository.deleteByStreamUid(context, entity.streamUid) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { Toast.makeText(context, R.string.download_link_removed, Toast.LENGTH_SHORT).show() }, + { throwable -> + if (DEBUG) Log.e(TAG, "Failed to remove download link", throwable) + showUiErrorSnackbar(this, "Removing download link", throwable) + } + ) + ) + } + + private fun deleteDownloadedFile(entity: DownloadedStreamEntity) { + val context = requireContext() + val uriString = entity.fileUri.takeUnless { it.isBlank() } + if (uriString.isNullOrBlank()) { + Toast.makeText(context, R.string.download_delete_failed, Toast.LENGTH_SHORT).show() + return + } + + val uri = Uri.parse(uriString) + val deleted = when (uri.scheme?.lowercase()) { + ContentResolver.SCHEME_CONTENT -> DocumentFile.fromSingleUri(context, uri)?.delete() ?: false + ContentResolver.SCHEME_FILE -> uri.path?.let { File(it).delete() } ?: false + else -> runCatching { context.contentResolver.delete(uri, null, null) > 0 }.getOrDefault(false) + } + + if (!deleted) { + Toast.makeText(context, R.string.download_delete_failed, Toast.LENGTH_SHORT).show() + return + } + + removeDownloadAssociation(entity) + Toast.makeText(context, R.string.download_deleted, Toast.LENGTH_SHORT).show() + } + + private fun handleRelinkResult(entity: DownloadedStreamEntity, uri: Uri) { + val context = requireContext() + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + + disposables.add( + DownloadedStreamsRepository.relink(context, entity, uri) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { Toast.makeText(context, R.string.download_relinked, Toast.LENGTH_SHORT).show() }, + { throwable -> + if (DEBUG) Log.e(TAG, "Failed to relink download", throwable) + Toast.makeText(context, R.string.download_relink_failed, Toast.LENGTH_SHORT).show() + } + ) + ) + } + + private fun openDownloadsActivity() { + val context = requireContext() + val intent = Intent(context, DownloadActivity::class.java) + runCatching { startActivity(intent) } + } + /*////////////////////////////////////////////////////////////////////////// // Stream Results ////////////////////////////////////////////////////////////////////////// */ @@ -2270,6 +2569,7 @@ class VideoDetailFragment : private const val MAX_OVERLAY_ALPHA = 0.9f private const val MAX_PLAYER_HEIGHT = 0.7f + private val AVAILABILITY_CHECK_INTERVAL_MS = TimeUnit.MINUTES.toMillis(5) const val ACTION_SHOW_MAIN_PLAYER: String = App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER" diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java index 04930b002..f0c9374fe 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -134,6 +134,10 @@ public class DownloadMission extends Mission { */ public MissionRecoveryInfo[] recoveryInfo; + public long streamUid = -1; + public long downloadedEntityId = -1; + public int serviceId = -1; + private transient int finishCount; public transient volatile boolean running; public boolean enqueued; diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java index 45211211f..d36e9c796 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -39,6 +39,8 @@ import androidx.core.content.IntentCompat; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; +import org.schabi.newpipe.download.DownloadedStreamsRepository; +import org.schabi.newpipe.download.DownloadedStreamsRepository.DownloadAssociation; import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.player.helper.LockManager; import org.schabi.newpipe.streams.io.StoredDirectoryHelper; @@ -56,6 +58,8 @@ import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManager.NetworkState; +import io.reactivex.rxjava3.disposables.CompositeDisposable; + public class DownloadManagerService extends Service { private static final String TAG = "DownloadManagerService"; @@ -80,6 +84,9 @@ public class DownloadManagerService extends Service { private static final String EXTRA_PARENT_PATH = "DownloadManagerService.extra.storageParentPath"; private static final String EXTRA_STORAGE_TAG = "DownloadManagerService.extra.storageTag"; private static final String EXTRA_RECOVERY_INFO = "DownloadManagerService.extra.recoveryInfo"; + private static final String EXTRA_STREAM_UID = "DownloadManagerService.extra.streamUid"; + private static final String EXTRA_DOWNLOADED_ID = "DownloadManagerService.extra.downloadedId"; + private static final String EXTRA_SERVICE_ID = "DownloadManagerService.extra.serviceId"; private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished"; private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished"; @@ -118,6 +125,8 @@ public class DownloadManagerService extends Service { private PendingIntent mOpenDownloadList; + private final CompositeDisposable disposables = new CompositeDisposable(); + /** * notify media scanner on downloaded media file ... * @@ -244,6 +253,7 @@ public class DownloadManagerService extends Service { if (icLauncher != null) icLauncher.recycle(); mHandler = null; + disposables.clear(); mManager.pauseAllMissions(true); } @@ -259,6 +269,18 @@ public class DownloadManagerService extends Service { switch (msg.what) { case MESSAGE_FINISHED: + if (mission.streamUid >= 0) { + DownloadAssociation association = + new DownloadAssociation(mission.streamUid, mission.downloadedEntityId); + disposables.add(DownloadedStreamsRepository.INSTANCE + .markFinished(this, association, mission.serviceId, mission.source, + mission.storage, null, null, null, null) + .subscribe( + () -> { }, + throwable -> Log.e(TAG, + "Failed to update downloaded stream entry", throwable) + )); + } notifyMediaScanner(mission.storage.getUri()); notifyFinishedDownload(mission.storage.getName()); mManager.setFinished(mission); @@ -361,7 +383,8 @@ public class DownloadManagerService extends Service { public static void startMission(Context context, String[] urls, StoredFileHelper storage, char kind, int threads, String source, String psName, String[] psArgs, long nearLength, - ArrayList recoveryInfo) { + ArrayList recoveryInfo, + long streamUid, long downloadedEntityId, int serviceId) { final Intent intent = new Intent(context, DownloadManagerService.class) .setAction(Intent.ACTION_RUN) .putExtra(EXTRA_URLS, urls) @@ -374,7 +397,10 @@ public class DownloadManagerService extends Service { .putExtra(EXTRA_RECOVERY_INFO, recoveryInfo) .putExtra(EXTRA_PARENT_PATH, storage.getParentUri()) .putExtra(EXTRA_PATH, storage.getUri()) - .putExtra(EXTRA_STORAGE_TAG, storage.getTag()); + .putExtra(EXTRA_STORAGE_TAG, storage.getTag()) + .putExtra(EXTRA_STREAM_UID, streamUid) + .putExtra(EXTRA_DOWNLOADED_ID, downloadedEntityId) + .putExtra(EXTRA_SERVICE_ID, serviceId); context.startService(intent); } @@ -390,6 +416,9 @@ public class DownloadManagerService extends Service { String source = intent.getStringExtra(EXTRA_SOURCE); long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); + long streamUid = intent.getLongExtra(EXTRA_STREAM_UID, -1L); + long downloadedEntityId = intent.getLongExtra(EXTRA_DOWNLOADED_ID, -1L); + int serviceId = intent.getIntExtra(EXTRA_SERVICE_ID, -1); final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO, MissionRecoveryInfo.class); Objects.requireNonNull(recovery); @@ -412,6 +441,9 @@ public class DownloadManagerService extends Service { mission.source = source; mission.nearLength = nearLength; mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]); + mission.streamUid = streamUid; + mission.downloadedEntityId = downloadedEntityId; + mission.serviceId = serviceId; if (ps != null) ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); diff --git a/app/src/main/res/layout/download_status_sheet.xml b/app/src/main/res/layout/download_status_sheet.xml new file mode 100644 index 000000000..07eccf030 --- /dev/null +++ b/app/src/main/res/layout/download_status_sheet.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_video_detail.xml b/app/src/main/res/layout/fragment_video_detail.xml index 1a4711581..71a9937bd 100644 --- a/app/src/main/res/layout/fragment_video_detail.xml +++ b/app/src/main/res/layout/fragment_video_detail.xml @@ -273,8 +273,9 @@ - + + @@ -562,6 +580,7 @@ android:id="@+id/detail_meta_info_text_view" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_below="@id/detail_meta_info_separator" android:gravity="center" android:padding="12dp" android:textSize="@dimen/video_item_detail_description_text_size" @@ -570,6 +589,7 @@ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c439f19e2..d50f03310 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,21 @@ Share Download Download stream file + Downloaded • %1$s + Downloaded + Downloading… + Previously downloaded – file missing + Open file + Show in folder + Delete file + Remove link + Download link removed + Download relinked + Unable to open downloaded file + Unable to open folder + Unable to relink file + Unable to delete downloaded file + Deleted downloaded file Search Search %1$s Search %1$s (%2$s) diff --git a/app/src/main/res/values/styles_download.xml b/app/src/main/res/values/styles_download.xml new file mode 100644 index 000000000..82a01a2e9 --- /dev/null +++ b/app/src/main/res/values/styles_download.xml @@ -0,0 +1,9 @@ + + + +