From b5cb367edb0e4e7e6ed7c904cc40edcd4134e6c2 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Tue, 16 Sep 2025 21:49:14 -0400 Subject: [PATCH 1/3] 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 00000000000..b6ee8079a51 --- /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 a34caa957b1..4a066411733 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 21c5354f44d..ea1dbdf0c16 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_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 @@ private static AppDatabase getDatabase(final Context context) { 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 04d93a238d5..8d5b951a119 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 @@ 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 ec097cc1bf3..95af4297b33 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 c9f630869c9..a2f2171e78b 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 void migrate(@NonNull final SupportSQLiteDatabase database) { } }; + 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 00000000000..febc9f9d4b8 --- /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 00000000000..c890a657e90 --- /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 00000000000..5c6535e250e --- /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 33702a6a3d4..d6c0c15b9e9 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 boolean onCreateOptionsMenu(final Menu menu) { 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 00000000000..ac39cf32b55 --- /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 0857fa33935..a43738e1feb 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.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 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { ); } - 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; + + 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; + + 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)) + )); + } - Toast.makeText(context, getString(R.string.download_has_started), - Toast.LENGTH_SHORT).show(); + @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; + } - dismiss(); + 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 00000000000..44f0a96cfa1 --- /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 00000000000..5c80a28acf5 --- /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 00000000000..7d2354830fd --- /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 279f5150a84..73412eba359 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 04930b002de..f0c9374fe2d 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 45211211f40..d36e9c79602 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.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.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 void onDestroy() { if (icLauncher != null) icLauncher.recycle(); mHandler = null; + disposables.clear(); mManager.pauseAllMissions(true); } @@ -259,6 +269,18 @@ private boolean handleMessage(@NonNull Message msg) { 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 void updateForegroundState(boolean state) { 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 static void startMission(Context context, String[] urls, StoredFileHelper .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 @@ private void startMission(Intent intent) { 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 @@ private void startMission(Intent intent) { 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 00000000000..07eccf0304a --- /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 1a4711581e2..71a9937bdb4 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 c439f19e272..d50f033103f 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 00000000000..82a01a2e9af --- /dev/null +++ b/app/src/main/res/values/styles_download.xml @@ -0,0 +1,9 @@ + + + + From 13b10b6e52fb4fcb19ab85d6bf2425045c23d4b8 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Wed, 17 Sep 2025 10:58:41 -0400 Subject: [PATCH 2/3] Refactor download status persistence and UI Restore finshed mission conditoina --- .../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 | 67 +- .../newpipe/download/DownloadMaintenance.kt | 45 - .../download/DownloadRevalidationWorker.kt | 29 - .../download/DownloadStatusRepository.kt | 207 +++++ .../download/DownloadedStreamsRepository.kt | 238 ----- .../newpipe/download/ui/DownloadStatusUi.kt | 126 +++ .../fragments/detail/VideoDetailFragment.kt | 319 ++----- .../detail/VideoDownloadStatusViewModel.kt | 119 +++ .../us/shandian/giga/get/DownloadMission.java | 2 +- .../us/shandian/giga/get/FinishedMission.java | 7 + .../giga/get/sqlite/FinishedMissionStore.java | 49 +- .../giga/service/DownloadManager.java | 75 ++ .../giga/service/DownloadManagerService.java | 40 +- .../main/res/layout/download_status_sheet.xml | 76 -- .../main/res/layout/fragment_video_detail.xml | 14 +- app/src/main/res/values/strings.xml | 2 - app/src/main/res/values/styles_download.xml | 9 - 28 files changed, 685 insertions(+), 1855 deletions(-) delete mode 100644 app/schemas/org.schabi.newpipe.database.AppDatabase/10.json delete mode 100644 app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamEntity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamStatus.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamsDao.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadAvailabilityChecker.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadMaintenance.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadRevalidationWorker.kt create mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadedStreamsRepository.kt create mode 100644 app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt delete mode 100644 app/src/main/res/layout/download_status_sheet.xml delete 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 deleted file mode 100644 index b6ee8079a51..00000000000 --- a/app/schemas/org.schabi.newpipe.database.AppDatabase/10.json +++ /dev/null @@ -1,856 +0,0 @@ -{ - "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 4a066411733..a34caa957b1 100644 --- a/app/src/main/java/org/schabi/newpipe/App.kt +++ b/app/src/main/java/org/schabi/newpipe/App.kt @@ -25,7 +25,6 @@ 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 @@ -121,8 +120,6 @@ 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 ea1dbdf0c16..21c5354f44d 100644 --- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java @@ -9,7 +9,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; @@ -30,7 +29,7 @@ private static AppDatabase getDatabase(final Context context) { 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_9_10) + MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9) .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 8d5b951a119..04d93a238d5 100644 --- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java @@ -1,13 +1,11 @@ package org.schabi.newpipe.database; -import static org.schabi.newpipe.database.Migrations.DB_VER_10; +import static org.schabi.newpipe.database.Migrations.DB_VER_9; 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; @@ -38,9 +36,9 @@ StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class, PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class, FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, - FeedLastUpdatedEntity.class, DownloadedStreamEntity.class + FeedLastUpdatedEntity.class }, - version = DB_VER_10 + version = DB_VER_9 ) public abstract class AppDatabase extends RoomDatabase { public static final String DATABASE_NAME = "newpipe.db"; @@ -64,6 +62,4 @@ 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 95af4297b33..ec097cc1bf3 100644 --- a/app/src/main/java/org/schabi/newpipe/database/Converters.kt +++ b/app/src/main/java/org/schabi/newpipe/database/Converters.kt @@ -1,7 +1,6 @@ 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 @@ -50,14 +49,4 @@ 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 a2f2171e78b..c9f630869c9 100644 --- a/app/src/main/java/org/schabi/newpipe/database/Migrations.java +++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.java @@ -27,7 +27,6 @@ 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; @@ -303,22 +302,6 @@ public void migrate(@NonNull final SupportSQLiteDatabase database) { } }; - 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 deleted file mode 100644 index febc9f9d4b8..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamEntity.kt +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index c890a657e90..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamStatus.kt +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 5c6535e250e..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/download/DownloadedStreamsDao.kt +++ /dev/null @@ -1,58 +0,0 @@ -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 d6c0c15b9e9..33702a6a3d4 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java @@ -81,14 +81,6 @@ public boolean onCreateOptionsMenu(final Menu menu) { 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 deleted file mode 100644 index ac39cf32b55..00000000000 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadAvailabilityChecker.kt +++ /dev/null @@ -1,33 +0,0 @@ -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 a43738e1feb..98cfbb25cd0 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -79,9 +79,7 @@ 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; @@ -1135,50 +1133,27 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { } 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; - - 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; - - 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)) - )); + + DownloadManagerService.startMission( + context, + urls, + storage, + kind, + threads, + currentInfo.getUrl(), + psName, + psArgs, + nearLength, + new ArrayList<>(recoveryInfo), + -1L, + currentInfo.getServiceId(), + qualityLabel + ); + + Toast.makeText(context, getString(R.string.download_has_started), + Toast.LENGTH_SHORT).show(); + + dismiss(); } @Nullable diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadMaintenance.kt b/app/src/main/java/org/schabi/newpipe/download/DownloadMaintenance.kt deleted file mode 100644 index 44f0a96cfa1..00000000000 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadMaintenance.kt +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 5c80a28acf5..00000000000 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadRevalidationWorker.kt +++ /dev/null @@ -1,29 +0,0 @@ -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/DownloadStatusRepository.kt b/app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt new file mode 100644 index 00000000000..4002ef42c11 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt @@ -0,0 +1,207 @@ +package org.schabi.newpipe.download + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.net.Uri +import android.os.Handler +import android.os.IBinder +import android.os.Message +import androidx.annotation.MainThread +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import us.shandian.giga.get.DownloadMission +import us.shandian.giga.get.FinishedMission +import us.shandian.giga.service.DownloadManager +import us.shandian.giga.service.DownloadManagerService +import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder +import us.shandian.giga.service.MissionState +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +sealed interface DownloadStatus { + data object None : DownloadStatus + data class InProgress(val running: Boolean) : DownloadStatus + data class Completed(val info: CompletedDownload) : DownloadStatus +} + +data class CompletedDownload( + val displayName: String?, + val qualityLabel: String?, + val mimeType: String?, + val fileUri: Uri?, + val parentUri: Uri?, + val fileAvailable: Boolean +) + +object DownloadStatusRepository { + + fun observe(context: Context, serviceId: Int, url: String): Flow = callbackFlow { + if (serviceId < 0 || url.isBlank()) { + trySend(DownloadStatus.None) + close() + return@callbackFlow + } + + val appContext = context.applicationContext + val intent = Intent(appContext, DownloadManagerService::class.java) + appContext.startService(intent) + var binder: DownloadManagerBinder? = null + var registeredCallback: Handler.Callback? = null + + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val downloadBinder = service as? DownloadManagerBinder + if (downloadBinder == null) { + trySend(DownloadStatus.None) + appContext.unbindService(this) + close() + return + } + binder = downloadBinder + trySend(downloadBinder.getDownloadStatus(serviceId, url, false).toDownloadStatus()) + + val callback = Handler.Callback { message: Message -> + val mission = message.obj + if (mission.matches(serviceId, url)) { + val snapshot = downloadBinder.getDownloadStatus(serviceId, url, false) + trySend(snapshot.toDownloadStatus()) + } + false + } + registeredCallback = callback + downloadBinder.addMissionEventListener(callback) + } + + override fun onServiceDisconnected(name: ComponentName?) { + registeredCallback?.let { callback -> binder?.removeMissionEventListener(callback) } + binder = null + trySend(DownloadStatus.None) + } + } + + val bound = appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE) + if (!bound) { + trySend(DownloadStatus.None) + close() + return@callbackFlow + } + + awaitClose { + registeredCallback?.let { callback -> binder?.removeMissionEventListener(callback) } + runCatching { appContext.unbindService(connection) } + } + } + + suspend fun refresh(context: Context, serviceId: Int, url: String): DownloadStatus { + if (serviceId < 0 || url.isBlank()) return DownloadStatus.None + return withBinder(context) { binder -> + binder.getDownloadStatus(serviceId, url, true).toDownloadStatus() + } + } + + suspend fun deleteFile(context: Context, serviceId: Int, url: String): Boolean { + if (serviceId < 0 || url.isBlank()) return false + return withBinder(context) { binder -> + binder.deleteFinishedMission(serviceId, url, true) + } + } + + suspend fun removeLink(context: Context, serviceId: Int, url: String): Boolean { + if (serviceId < 0 || url.isBlank()) return false + return withBinder(context) { binder -> + binder.deleteFinishedMission(serviceId, url, false) + } + } + + private suspend fun withBinder(context: Context, block: (DownloadManagerBinder) -> T): T { + val appContext = context.applicationContext + val intent = Intent(appContext, DownloadManagerService::class.java) + appContext.startService(intent) + return suspendCancellableCoroutine { continuation -> + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val binder = service as? DownloadManagerBinder + if (binder == null) { + if (continuation.isActive) { + continuation.resumeWithException(IllegalStateException("Download service binder is null")) + } + appContext.unbindService(this) + return + } + try { + val result = block(binder) + if (continuation.isActive) { + continuation.resume(result) + } + } catch (throwable: Throwable) { + if (continuation.isActive) { + continuation.resumeWithException(throwable) + } + } finally { + appContext.unbindService(this) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + if (continuation.isActive) { + continuation.resumeWithException(IllegalStateException("Download service disconnected")) + } + } + } + + val bound = appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE) + if (!bound) { + continuation.resumeWithException(IllegalStateException("Unable to bind download service")) + return@suspendCancellableCoroutine + } + + continuation.invokeOnCancellation { + runCatching { appContext.unbindService(connection) } + } + } + } + + private fun Any?.matches(serviceId: Int, url: String): Boolean { + return when (this) { + is DownloadMission -> this.serviceId == serviceId && url == this.source + is FinishedMission -> this.serviceId == serviceId && url == this.source + else -> false + } + } + + @VisibleForTesting + @MainThread + internal fun DownloadManager.DownloadStatusSnapshot?.toDownloadStatus(): DownloadStatus { + if (this == null || state == MissionState.None) { + return DownloadStatus.None + } + return when (state) { + MissionState.Pending, MissionState.PendingRunning -> + DownloadStatus.InProgress(state == MissionState.PendingRunning) + MissionState.Finished -> { + val mission = finishedMission + if (mission == null) { + DownloadStatus.None + } else { + val storage = mission.storage + val hasStorage = storage != null && !storage.isInvalid() + val info = CompletedDownload( + displayName = storage?.getName(), + qualityLabel = mission.qualityLabel, + mimeType = if (hasStorage) storage!!.getType() else null, + fileUri = if (hasStorage) storage!!.getUri() else null, + parentUri = if (hasStorage) storage!!.getParentUri() else null, + fileAvailable = fileExists && hasStorage + ) + DownloadStatus.Completed(info) + } + } + else -> DownloadStatus.None + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadedStreamsRepository.kt b/app/src/main/java/org/schabi/newpipe/download/DownloadedStreamsRepository.kt deleted file mode 100644 index 7d2354830fd..00000000000 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadedStreamsRepository.kt +++ /dev/null @@ -1,238 +0,0 @@ -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/download/ui/DownloadStatusUi.kt b/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt new file mode 100644 index 00000000000..ef36b7d09b3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt @@ -0,0 +1,126 @@ +package org.schabi.newpipe.download.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AssistChip +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.schabi.newpipe.R +import org.schabi.newpipe.download.CompletedDownload +import org.schabi.newpipe.fragments.detail.DownloadChipState +import org.schabi.newpipe.fragments.detail.DownloadUiState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DownloadStatusHost( + state: DownloadUiState, + onChipClick: () -> Unit, + onDismissSheet: () -> Unit, + onOpenFile: (CompletedDownload) -> Unit, + onDeleteFile: (CompletedDownload) -> Unit, + onRemoveLink: (CompletedDownload) -> Unit, + onShowInFolder: (CompletedDownload) -> Unit +) { + val chipState = state.chipState + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (state.isSheetVisible && chipState is DownloadChipState.Downloaded) { + ModalBottomSheet( + onDismissRequest = onDismissSheet, + sheetState = sheetState + ) { + DownloadSheetContent( + info = chipState.info, + onOpenFile = { onOpenFile(chipState.info) }, + onDeleteFile = { onDeleteFile(chipState.info) }, + onRemoveLink = { onRemoveLink(chipState.info) }, + onShowInFolder = { onShowInFolder(chipState.info) } + ) + } + } + + when (chipState) { + DownloadChipState.Hidden -> Unit + DownloadChipState.Downloading -> AssistChip( + onClick = onChipClick, + label = { Text(text = stringResource(id = R.string.download_status_downloading)) } + ) + is DownloadChipState.Downloaded -> { + val label = chipState.info.qualityLabel + val text = if (!label.isNullOrBlank()) { + stringResource(R.string.download_status_downloaded, label) + } else { + stringResource(R.string.download_status_downloaded_simple) + } + AssistChip( + onClick = onChipClick, + label = { Text(text = text) } + ) + } + } +} + +@Composable +private fun DownloadSheetContent( + info: CompletedDownload, + onOpenFile: () -> Unit, + onDeleteFile: () -> Unit, + onRemoveLink: () -> Unit, + onShowInFolder: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + ) { + val title = info.displayName ?: stringResource(id = R.string.download) + Text(text = title, style = MaterialTheme.typography.titleLarge) + + val subtitleParts = buildList { + info.qualityLabel?.takeIf { it.isNotBlank() }?.let { add(it) } + if (!info.fileAvailable) { + add(stringResource(id = R.string.download_status_missing)) + } + } + if (subtitleParts.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = subtitleParts.joinToString(" • "), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + val showFileActions = info.fileAvailable && info.fileUri != null + if (showFileActions) { + TextButton(onClick = onOpenFile) { + Text(text = stringResource(id = R.string.download_action_open)) + } + TextButton(onClick = onShowInFolder, enabled = info.parentUri != null) { + Text(text = stringResource(id = R.string.download_action_show_in_folder)) + } + TextButton(onClick = onDeleteFile) { + Text(text = stringResource(id = R.string.download_action_delete)) + } + } + + TextButton(onClick = onRemoveLink) { + Text(text = stringResource(id = R.string.download_action_remove_link), color = MaterialTheme.colorScheme.error) + } + + Spacer(modifier = Modifier.height(8.dp)) + } +} 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 73412eba359..88382bc62e9 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,7 +4,6 @@ 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 @@ -13,7 +12,6 @@ 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 @@ -23,7 +21,6 @@ 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 @@ -35,14 +32,14 @@ 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 import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.Toolbar +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.content.edit @@ -50,7 +47,9 @@ 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.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import coil3.util.CoilUtils import com.evernote.android.state.State @@ -59,22 +58,18 @@ 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 kotlinx.coroutines.launch 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.CompletedDownload import org.schabi.newpipe.download.DownloadDialog -import org.schabi.newpipe.download.DownloadedStreamsRepository +import org.schabi.newpipe.download.ui.DownloadStatusHost import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar @@ -113,6 +108,7 @@ import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.player.playqueue.SinglePlayQueue import org.schabi.newpipe.player.ui.MainPlayerUi import org.schabi.newpipe.player.ui.VideoPlayerUi +import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.DependentPreferenceHelper import org.schabi.newpipe.util.DeviceUtils import org.schabi.newpipe.util.ExtractorHelper @@ -129,7 +125,6 @@ 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 @@ -158,6 +153,7 @@ class VideoDetailFragment : // can't make this lateinit because it needs to be set to null when the view is destroyed private var nullableBinding: FragmentVideoDetailBinding? = null private val binding: FragmentVideoDetailBinding get() = nullableBinding!! + private val downloadStatusViewModel: VideoDownloadStatusViewModel by viewModels() private lateinit var pageAdapter: TabAdapter private var settingsContentObserver: ContentObserver? = null @@ -196,17 +192,6 @@ 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 @@ -295,6 +280,26 @@ class VideoDetailFragment : ): View { val newBinding = FragmentVideoDetailBinding.inflate(inflater, container, false) nullableBinding = newBinding + newBinding.detailDownloadStatusCompose?.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + val uiState = downloadStatusViewModel.uiState.collectAsStateWithLifecycle().value + val composeContext = LocalContext.current + DownloadStatusHost( + state = uiState, + onChipClick = { + downloadStatusViewModel.onChipClicked(composeContext.applicationContext) + }, + onDismissSheet = { downloadStatusViewModel.dismissSheet() }, + onOpenFile = { info -> openDownloaded(info) }, + onDeleteFile = { info -> deleteDownloadedFile(info) }, + onRemoveLink = { info -> removeDownloadLink(info) }, + onShowInFolder = { info -> showDownloadedInFolder(info) } + ) + } + } + } return newBinding.getRoot() } @@ -374,13 +379,6 @@ class VideoDetailFragment : override fun onDestroyView() { super.onDestroyView() - downloadStatusDisposable?.let { - disposables.remove(it) - it.dispose() - } - downloadStatusDisposable = null - currentDownloadedStream = null - currentStreamUid = null nullableBinding = null } @@ -1399,8 +1397,8 @@ class VideoDetailFragment : currentInfo = info setInitialData(info.serviceId, info.originalUrl, info.name, playQueue) - updateDownloadChip(null) - observeDownloadStatus(info) + downloadStatusViewModel.dismissSheet() + downloadStatusViewModel.setStream(requireContext().applicationContext, info.serviceId, info.url) updateTabs(info) @@ -1580,165 +1578,15 @@ 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) + private fun openDownloaded(info: CompletedDownload) { + val uri = info.fileUri + if (uri == null) { + Toast.makeText(requireContext(), R.string.download_open_failed, Toast.LENGTH_SHORT).show() 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 ?: "*/*") + setDataAndType(uri, info.mimeType ?: "*/*") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } @@ -1749,98 +1597,61 @@ class VideoDetailFragment : } } - private fun showInFolder(entity: DownloadedStreamEntity) { - val parent = entity.parentUri?.takeIf { it.isNotBlank() }?.let(Uri::parse) + private fun showDownloadedInFolder(info: CompletedDownload) { + val parent = info.parentUri if (parent == null) { Toast.makeText(requireContext(), R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show() return } - val intent = Intent(Intent.ACTION_VIEW).apply { + val context = requireContext() + val viewIntent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(parent, DocumentsContract.Document.MIME_TYPE_DIR) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - runCatching { startActivity(intent) } + runCatching { startActivity(viewIntent) } .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() + Toast.makeText(context, 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() + private fun deleteDownloadedFile(info: CompletedDownload) { + if (!info.fileAvailable) { + Toast.makeText(requireContext(), 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 + val appContext = requireContext().applicationContext + viewLifecycleOwner.lifecycleScope.launch { + val success = downloadStatusViewModel.deleteFile(appContext) + val message = if (success) { + R.string.download_deleted + } else { + R.string.download_delete_failed + } + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } - - 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 - ) + private fun removeDownloadLink(@Suppress("UNUSED_PARAMETER") info: CompletedDownload) { + val appContext = requireContext().applicationContext + viewLifecycleOwner.lifecycleScope.launch { + val success = downloadStatusViewModel.removeLink(appContext) + val message = if (success) { + R.string.download_link_removed + } else { + R.string.download_delete_failed + } + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } - - 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) } } /*////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt new file mode 100644 index 00000000000..940cd774f68 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt @@ -0,0 +1,119 @@ +package org.schabi.newpipe.fragments.detail + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.schabi.newpipe.download.CompletedDownload +import org.schabi.newpipe.download.DownloadStatus +import org.schabi.newpipe.download.DownloadStatusRepository + +class VideoDownloadStatusViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(DownloadUiState()) + val uiState: StateFlow = _uiState + + private var observeJob: Job? = null + private var currentServiceId: Int = -1 + private var currentUrl: String? = null + + fun setStream(context: Context, serviceId: Int, url: String?) { + val normalizedUrl = url ?: "" + if (serviceId < 0 || normalizedUrl.isBlank()) { + observeJob?.cancel() + observeJob = null + currentServiceId = -1 + currentUrl = null + _uiState.value = DownloadUiState() + return + } + + if (currentServiceId == serviceId && currentUrl == normalizedUrl) { + return + } + + currentServiceId = serviceId + currentUrl = normalizedUrl + + val appContext = context.applicationContext + + observeJob?.cancel() + observeJob = viewModelScope.launch { + DownloadStatusRepository.observe(appContext, serviceId, normalizedUrl).collectLatest { status -> + _uiState.update { it.copy(chipState = status.toChipState()) } + } + } + } + + fun onChipClicked(context: Context) { + val url = currentUrl ?: return + val serviceId = currentServiceId + viewModelScope.launch { + val result = runCatching { + DownloadStatusRepository.refresh(context.applicationContext, serviceId, url) + } + result.getOrNull()?.let { status -> + _uiState.update { + val chipState = status.toChipState() + it.copy( + chipState = chipState, + isSheetVisible = chipState is DownloadChipState.Downloaded + ) + } + } + if (result.isFailure) { + _uiState.update { it.copy(isSheetVisible = false) } + } + } + } + + fun dismissSheet() { + _uiState.update { it.copy(isSheetVisible = false) } + } + + suspend fun deleteFile(context: Context): Boolean { + val url = currentUrl ?: return false + val serviceId = currentServiceId + val success = runCatching { + DownloadStatusRepository.deleteFile(context.applicationContext, serviceId, url) + }.getOrDefault(false) + if (success) { + _uiState.update { it.copy(isSheetVisible = false) } + } + return success + } + + suspend fun removeLink(context: Context): Boolean { + val url = currentUrl ?: return false + val serviceId = currentServiceId + val success = runCatching { + DownloadStatusRepository.removeLink(context.applicationContext, serviceId, url) + }.getOrDefault(false) + if (success) { + _uiState.update { it.copy(isSheetVisible = false) } + } + return success + } + + private fun DownloadStatus.toChipState(): DownloadChipState = when (this) { + DownloadStatus.None -> DownloadChipState.Hidden + is DownloadStatus.InProgress -> DownloadChipState.Downloading + is DownloadStatus.Completed -> DownloadChipState.Downloaded(info) + } +} + +data class DownloadUiState( + val chipState: DownloadChipState = DownloadChipState.Hidden, + val isSheetVisible: Boolean = false +) + +sealed interface DownloadChipState { + data object Hidden : DownloadChipState + data object Downloading : DownloadChipState + data class Downloaded(val info: CompletedDownload) : DownloadChipState +} 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 f0c9374fe2d..726a5510243 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -135,8 +135,8 @@ public class DownloadMission extends Mission { public MissionRecoveryInfo[] recoveryInfo; public long streamUid = -1; - public long downloadedEntityId = -1; public int serviceId = -1; + public String qualityLabel = null; private transient int finishCount; public transient volatile boolean running; diff --git a/app/src/main/java/us/shandian/giga/get/FinishedMission.java b/app/src/main/java/us/shandian/giga/get/FinishedMission.java index 29f3c62968d..94bf508a69a 100644 --- a/app/src/main/java/us/shandian/giga/get/FinishedMission.java +++ b/app/src/main/java/us/shandian/giga/get/FinishedMission.java @@ -4,6 +4,10 @@ public class FinishedMission extends Mission { + public int serviceId = -1; + public long streamUid = -1; + public String qualityLabel = null; + public FinishedMission() { } @@ -13,6 +17,9 @@ public FinishedMission(@NonNull DownloadMission mission) { timestamp = mission.timestamp; kind = mission.kind; storage = mission.storage; + serviceId = mission.serviceId; + streamUid = mission.streamUid; + qualityLabel = mission.qualityLabel; } } diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java index 704385212ab..c535d687de2 100644 --- a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java +++ b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java @@ -27,7 +27,7 @@ public class FinishedMissionStore extends SQLiteOpenHelper { // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) private static final String DATABASE_NAME = "downloads.db"; - private static final int DATABASE_VERSION = 4; + private static final int DATABASE_VERSION = 5; /** * The table name of download missions (old) @@ -56,6 +56,12 @@ public class FinishedMissionStore extends SQLiteOpenHelper { private static final String KEY_PATH = "path"; + private static final String KEY_SERVICE_ID = "service_id"; + + private static final String KEY_STREAM_UID = "stream_uid"; + + private static final String KEY_QUALITY_LABEL = "quality_label"; + /** * The statement to create the table */ @@ -66,6 +72,9 @@ public class FinishedMissionStore extends SQLiteOpenHelper { KEY_DONE + " INTEGER NOT NULL, " + KEY_TIMESTAMP + " INTEGER NOT NULL, " + KEY_KIND + " TEXT NOT NULL, " + + KEY_SERVICE_ID + " INTEGER NOT NULL DEFAULT -1, " + + KEY_STREAM_UID + " INTEGER NOT NULL DEFAULT -1, " + + KEY_QUALITY_LABEL + " TEXT, " + " UNIQUE(" + KEY_TIMESTAMP + ", " + KEY_PATH + "));"; @@ -121,6 +130,17 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { cursor.close(); db.execSQL("DROP TABLE " + MISSIONS_TABLE_NAME_v2); + oldVersion++; + } + + if (oldVersion == 4) { + db.execSQL("ALTER TABLE " + FINISHED_TABLE_NAME + " ADD COLUMN " + + KEY_SERVICE_ID + " INTEGER NOT NULL DEFAULT -1"); + db.execSQL("ALTER TABLE " + FINISHED_TABLE_NAME + " ADD COLUMN " + + KEY_STREAM_UID + " INTEGER NOT NULL DEFAULT -1"); + db.execSQL("ALTER TABLE " + FINISHED_TABLE_NAME + " ADD COLUMN " + + KEY_QUALITY_LABEL + " TEXT"); + oldVersion++; } } @@ -137,6 +157,17 @@ private ContentValues getValuesOfMission(@NonNull Mission downloadMission) { values.put(KEY_DONE, downloadMission.length); values.put(KEY_TIMESTAMP, downloadMission.timestamp); values.put(KEY_KIND, String.valueOf(downloadMission.kind)); + if (downloadMission instanceof DownloadMission) { + DownloadMission dm = (DownloadMission) downloadMission; + values.put(KEY_SERVICE_ID, dm.serviceId); + values.put(KEY_STREAM_UID, dm.streamUid); + values.put(KEY_QUALITY_LABEL, dm.qualityLabel); + } else if (downloadMission instanceof FinishedMission) { + FinishedMission fm = (FinishedMission) downloadMission; + values.put(KEY_SERVICE_ID, fm.serviceId); + values.put(KEY_STREAM_UID, fm.streamUid); + values.put(KEY_QUALITY_LABEL, fm.qualityLabel); + } return values; } @@ -152,6 +183,9 @@ private FinishedMission getMissionFromCursor(Cursor cursor) { mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)); mission.kind = kind.charAt(0); + mission.serviceId = cursor.getInt(cursor.getColumnIndexOrThrow(KEY_SERVICE_ID)); + mission.streamUid = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_STREAM_UID)); + mission.qualityLabel = cursor.getString(cursor.getColumnIndexOrThrow(KEY_QUALITY_LABEL)); try { mission.storage = new StoredFileHelper(context,null, Uri.parse(path), ""); @@ -200,11 +234,10 @@ public void deleteMission(Mission mission) { database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ?", new String[]{ts}); } else { database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ? AND " + KEY_PATH + " = ?", new String[]{ - ts, mission.storage.getUri().toString() - }); + ts, mission.storage.getUri().toString()}); } } else { - throw new UnsupportedOperationException("DownloadMission"); + database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ?", new String[]{ts}); } } @@ -217,11 +250,11 @@ public void updateMission(Mission mission) { if (mission instanceof FinishedMission) { if (mission.storage.isInvalid()) { - rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_TIMESTAMP + " = ?", new String[]{ts}); + rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_TIMESTAMP + " = ?", + new String[]{ts}); } else { - rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_PATH + " = ?", new String[]{ - mission.storage.getUri().toString() - }); + rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_PATH + " = ?", + new String[]{mission.storage.getUri().toString()}); } } else { throw new UnsupportedOperationException("DownloadMission"); diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java index d02f77bc1ab..9ba021c12d9 100644 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -14,6 +14,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.Objects; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; @@ -333,6 +334,16 @@ private DownloadMission getPendingMission(StoredFileHelper storage) { return null; } + @Nullable + private DownloadMission getPendingMission(int serviceId, String url) { + for (DownloadMission mission : mMissionsPending) { + if (mission.serviceId == serviceId && Objects.equals(mission.source, url)) { + return mission; + } + } + return null; + } + /** * Get the index into {@link #mMissionsFinished} of a finished mission by its path, return * {@code -1} if there is no such mission. This function also checks if the matched mission's @@ -342,6 +353,26 @@ private DownloadMission getPendingMission(StoredFileHelper storage) { * @param storage where the file would be stored * @return the mission index or -1 if no such mission exists */ + @Nullable + private FinishedMission getFinishedMission(int serviceId, String url) { + for (FinishedMission mission : mMissionsFinished) { + if (mission.serviceId == serviceId && Objects.equals(mission.source, url)) { + return mission; + } + } + return null; + } + + private boolean isFileAvailable(@NonNull FinishedMission mission) { + if (mission.storage == null || mission.storage.isInvalid()) { + return false; + } + if (!mission.storage.existsAsFile()) { + return false; + } + return mission.storage.length() > 0; + } + private int getFinishedMissionIndex(StoredFileHelper storage) { for (int i = 0; i < mMissionsFinished.size(); i++) { if (mMissionsFinished.get(i).storage.equals(storage)) { @@ -427,6 +458,50 @@ void setFinished(DownloadMission mission) { } } + public static final class DownloadStatusSnapshot { + public final MissionState state; + public final DownloadMission pendingMission; + public final FinishedMission finishedMission; + public final boolean fileExists; + + DownloadStatusSnapshot(MissionState state, DownloadMission pendingMission, + FinishedMission finishedMission, boolean fileExists) { + this.state = state; + this.pendingMission = pendingMission; + this.finishedMission = finishedMission; + this.fileExists = fileExists; + } + } + + DownloadStatusSnapshot getDownloadStatus(int serviceId, String url, boolean revalidateFile) { + synchronized (this) { + DownloadMission pending = getPendingMission(serviceId, url); + if (pending != null) { + MissionState state = pending.running + ? MissionState.PendingRunning + : MissionState.Pending; + return new DownloadStatusSnapshot(state, pending, null, true); + } + + FinishedMission finished = getFinishedMission(serviceId, url); + if (finished != null) { + boolean available = !revalidateFile || isFileAvailable(finished); + return new DownloadStatusSnapshot(MissionState.Finished, null, finished, available); + } + } + + return new DownloadStatusSnapshot(MissionState.None, null, null, false); + } + + boolean deleteFinishedMission(int serviceId, String url, boolean deleteFile) { + FinishedMission mission = getFinishedMission(serviceId, url); + if (mission == null) { + return false; + } + deleteMission(mission, deleteFile); + return true; + } + /** * runs one or multiple missions in from queue if possible * 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 d36e9c79602..53726ab54bb 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -39,8 +39,6 @@ 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; @@ -58,8 +56,6 @@ 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"; @@ -85,8 +81,8 @@ public class DownloadManagerService extends Service { 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 EXTRA_QUALITY_LABEL = "DownloadManagerService.extra.qualityLabel"; 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"; @@ -125,8 +121,6 @@ public class DownloadManagerService extends Service { private PendingIntent mOpenDownloadList; - private final CompositeDisposable disposables = new CompositeDisposable(); - /** * notify media scanner on downloaded media file ... * @@ -253,7 +247,6 @@ public void onDestroy() { if (icLauncher != null) icLauncher.recycle(); mHandler = null; - disposables.clear(); mManager.pauseAllMissions(true); } @@ -269,18 +262,6 @@ private boolean handleMessage(@NonNull Message msg) { 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); @@ -384,7 +365,7 @@ public static void startMission(Context context, String[] urls, StoredFileHelper char kind, int threads, String source, String psName, String[] psArgs, long nearLength, ArrayList recoveryInfo, - long streamUid, long downloadedEntityId, int serviceId) { + long streamUid, int serviceId, String qualityLabel) { final Intent intent = new Intent(context, DownloadManagerService.class) .setAction(Intent.ACTION_RUN) .putExtra(EXTRA_URLS, urls) @@ -399,8 +380,8 @@ public static void startMission(Context context, String[] urls, StoredFileHelper .putExtra(EXTRA_PATH, storage.getUri()) .putExtra(EXTRA_STORAGE_TAG, storage.getTag()) .putExtra(EXTRA_STREAM_UID, streamUid) - .putExtra(EXTRA_DOWNLOADED_ID, downloadedEntityId) - .putExtra(EXTRA_SERVICE_ID, serviceId); + .putExtra(EXTRA_SERVICE_ID, serviceId) + .putExtra(EXTRA_QUALITY_LABEL, qualityLabel); context.startService(intent); } @@ -417,8 +398,8 @@ private void startMission(Intent intent) { 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); + String qualityLabel = intent.getStringExtra(EXTRA_QUALITY_LABEL); final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO, MissionRecoveryInfo.class); Objects.requireNonNull(recovery); @@ -442,8 +423,8 @@ private void startMission(Intent intent) { mission.nearLength = nearLength; mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]); mission.streamUid = streamUid; - mission.downloadedEntityId = downloadedEntityId; mission.serviceId = serviceId; + mission.qualityLabel = qualityLabel; if (ps != null) ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); @@ -615,6 +596,15 @@ public void enableNotifications(boolean enable) { mDownloadNotificationEnable = enable; } + public DownloadManager.DownloadStatusSnapshot getDownloadStatus(int serviceId, String source, + boolean revalidateFile) { + return mManager.getDownloadStatus(serviceId, source, revalidateFile); + } + + public boolean deleteFinishedMission(int serviceId, String source, boolean deleteFile) { + return mManager.deleteFinishedMission(serviceId, source, deleteFile); + } + } } diff --git a/app/src/main/res/layout/download_status_sheet.xml b/app/src/main/res/layout/download_status_sheet.xml deleted file mode 100644 index 07eccf0304a..00000000000 --- a/app/src/main/res/layout/download_status_sheet.xml +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_video_detail.xml b/app/src/main/res/layout/fragment_video_detail.xml index 71a9937bdb4..24e167a5e18 100644 --- a/app/src/main/res/layout/fragment_video_detail.xml +++ b/app/src/main/res/layout/fragment_video_detail.xml @@ -551,27 +551,21 @@ - + android:visibility="visible" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d50f033103f..e8f1440a6a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,10 +25,8 @@ 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 diff --git a/app/src/main/res/values/styles_download.xml b/app/src/main/res/values/styles_download.xml deleted file mode 100644 index 82a01a2e9af..00000000000 --- a/app/src/main/res/values/styles_download.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - From 65ce28cbd797f4ff0899965c47f749a4c71d5af6 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Fri, 3 Oct 2025 20:31:28 -0500 Subject: [PATCH 3/3] Address PR Feedback --- .../newpipe/download/DownloadDialog.java | 24 +-- .../download/DownloadStatusRepository.kt | 178 +++++++++++++----- .../newpipe/download/ui/DownloadStatusUi.kt | 167 +++++++++++----- .../fragments/detail/VideoDetailFragment.kt | 56 +++--- .../detail/VideoDownloadStatusViewModel.kt | 95 +++++----- .../newpipe/util/StreamItemAdapter.java | 24 +-- .../schabi/newpipe/util/StreamLabelUtils.kt | 30 +++ .../giga/service/DownloadManager.java | 78 ++++++-- .../giga/service/DownloadManagerService.java | 12 +- app/src/main/res/values/strings.xml | 15 +- 10 files changed, 446 insertions(+), 233 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/util/StreamLabelUtils.kt 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 98cfbb25cd0..a786e1bbf0e 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -70,6 +70,7 @@ import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; +import org.schabi.newpipe.util.StreamLabelUtils; import org.schabi.newpipe.util.ThemeHelper; import java.io.File; @@ -1132,7 +1133,8 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { ); } - final String qualityLabel = buildQualityLabel(selectedStream); + final String qualityLabel = + StreamLabelUtils.getQualityLabel(requireContext(), selectedStream); DownloadManagerService.startMission( context, @@ -1155,24 +1157,4 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { dismiss(); } - - @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/DownloadStatusRepository.kt b/app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt index 4002ef42c11..8ef710b31a9 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt @@ -23,26 +23,43 @@ import us.shandian.giga.service.MissionState import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -sealed interface DownloadStatus { - data object None : DownloadStatus - data class InProgress(val running: Boolean) : DownloadStatus - data class Completed(val info: CompletedDownload) : DownloadStatus +enum class DownloadStage { + Pending, + Running, + Finished } -data class CompletedDownload( +data class DownloadHandle( + val serviceId: Int, + val url: String, + val streamUid: Long, + val storageUri: Uri?, + val timestamp: Long, + val kind: Char +) + +data class DownloadEntry( + val handle: DownloadHandle, val displayName: String?, val qualityLabel: String?, val mimeType: String?, val fileUri: Uri?, val parentUri: Uri?, - val fileAvailable: Boolean + val fileAvailable: Boolean, + val stage: DownloadStage ) object DownloadStatusRepository { - fun observe(context: Context, serviceId: Int, url: String): Flow = callbackFlow { + /** + * Keeps a one-off binding to [DownloadManagerService] alive for as long as the caller stays + * subscribed. We prime the channel with the latest persisted snapshot and then forward every + * mission event emitted by the service-bound handler. Once the consumer cancels the flow we + * make sure to unregister the handler and unbind the service to avoid leaking the connection. + */ + fun observe(context: Context, serviceId: Int, url: String): Flow> = callbackFlow { if (serviceId < 0 || url.isBlank()) { - trySend(DownloadStatus.None) + trySend(emptyList()) close() return@callbackFlow } @@ -50,6 +67,9 @@ object DownloadStatusRepository { val appContext = context.applicationContext val intent = Intent(appContext, DownloadManagerService::class.java) appContext.startService(intent) + // The download manager service only notifies listeners while a client is bound, so the flow + // keeps a foreground-style binding alive for its entire lifetime. Holding on to + // applicationContext avoids leaking short-lived UI contexts. var binder: DownloadManagerBinder? = null var registeredCallback: Handler.Callback? = null @@ -57,19 +77,23 @@ object DownloadStatusRepository { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { val downloadBinder = service as? DownloadManagerBinder if (downloadBinder == null) { - trySend(DownloadStatus.None) + trySend(emptyList()) appContext.unbindService(this) close() return } binder = downloadBinder - trySend(downloadBinder.getDownloadStatus(serviceId, url, false).toDownloadStatus()) + // First delivery: snapshot persisted on disk so the UI paints immediately even + // before the service emits new events. + trySend(downloadBinder.getDownloadStatuses(serviceId, url, true).toDownloadEntries()) val callback = Handler.Callback { message: Message -> val mission = message.obj if (mission.matches(serviceId, url)) { - val snapshot = downloadBinder.getDownloadStatus(serviceId, url, false) - trySend(snapshot.toDownloadStatus()) + // Each mission event carries opaque state, so we fetch a fresh snapshot to + // guarantee consistent entries while the download progresses or finishes. + val snapshots = downloadBinder.getDownloadStatuses(serviceId, url, false) + trySend(snapshots.toDownloadEntries()) } false } @@ -80,48 +104,57 @@ object DownloadStatusRepository { override fun onServiceDisconnected(name: ComponentName?) { registeredCallback?.let { callback -> binder?.removeMissionEventListener(callback) } binder = null - trySend(DownloadStatus.None) + trySend(emptyList()) } } val bound = appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE) if (!bound) { - trySend(DownloadStatus.None) + trySend(emptyList()) close() return@callbackFlow } awaitClose { + // When the collector disappears we remove listeners and unbind immediately to avoid + // holding the service forever; the service will rebind on the next subscription. registeredCallback?.let { callback -> binder?.removeMissionEventListener(callback) } runCatching { appContext.unbindService(connection) } } } - suspend fun refresh(context: Context, serviceId: Int, url: String): DownloadStatus { - if (serviceId < 0 || url.isBlank()) return DownloadStatus.None + suspend fun refresh(context: Context, serviceId: Int, url: String): List { + if (serviceId < 0 || url.isBlank()) return emptyList() return withBinder(context) { binder -> - binder.getDownloadStatus(serviceId, url, true).toDownloadStatus() + binder.getDownloadStatuses(serviceId, url, true).toDownloadEntries() } } - suspend fun deleteFile(context: Context, serviceId: Int, url: String): Boolean { - if (serviceId < 0 || url.isBlank()) return false + suspend fun deleteFile(context: Context, handle: DownloadHandle): Boolean { + if (handle.serviceId < 0 || handle.url.isBlank()) return false return withBinder(context) { binder -> - binder.deleteFinishedMission(serviceId, url, true) + binder.deleteFinishedMission(handle.serviceId, handle.url, handle.storageUri, handle.timestamp, true) } } - suspend fun removeLink(context: Context, serviceId: Int, url: String): Boolean { - if (serviceId < 0 || url.isBlank()) return false + suspend fun removeLink(context: Context, handle: DownloadHandle): Boolean { + if (handle.serviceId < 0 || handle.url.isBlank()) return false return withBinder(context) { binder -> - binder.deleteFinishedMission(serviceId, url, false) + binder.deleteFinishedMission(handle.serviceId, handle.url, handle.storageUri, handle.timestamp, false) } } + /** + * Helper that briefly binds to [DownloadManagerService], executes [block] against its binder + * and tears everything down in one place. All callers should use this to prevent scattering + * ad-hoc bind/unbind logic across the codebase. + */ private suspend fun withBinder(context: Context, block: (DownloadManagerBinder) -> T): T { val appContext = context.applicationContext val intent = Intent(appContext, DownloadManagerService::class.java) appContext.startService(intent) + // The direct call path still needs the service running long enough to complete the + // binder transaction, so we explicitly start it before establishing the short-lived bind. return suspendCancellableCoroutine { continuation -> val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { @@ -176,32 +209,83 @@ object DownloadStatusRepository { @VisibleForTesting @MainThread - internal fun DownloadManager.DownloadStatusSnapshot?.toDownloadStatus(): DownloadStatus { - if (this == null || state == MissionState.None) { - return DownloadStatus.None + internal fun List.toDownloadEntries(): List { + return buildList { + for (snapshot in this@toDownloadEntries) { + snapshot.toDownloadEntry()?.let { add(it) } + } } - return when (state) { - MissionState.Pending, MissionState.PendingRunning -> - DownloadStatus.InProgress(state == MissionState.PendingRunning) - MissionState.Finished -> { - val mission = finishedMission - if (mission == null) { - DownloadStatus.None - } else { - val storage = mission.storage - val hasStorage = storage != null && !storage.isInvalid() - val info = CompletedDownload( - displayName = storage?.getName(), - qualityLabel = mission.qualityLabel, - mimeType = if (hasStorage) storage!!.getType() else null, - fileUri = if (hasStorage) storage!!.getUri() else null, - parentUri = if (hasStorage) storage!!.getParentUri() else null, - fileAvailable = fileExists && hasStorage - ) - DownloadStatus.Completed(info) - } + } + + @VisibleForTesting + @MainThread + internal fun DownloadManager.DownloadStatusSnapshot.toDownloadEntry(): DownloadEntry? { + val stage = when (state) { + MissionState.Pending -> DownloadStage.Pending + MissionState.PendingRunning -> DownloadStage.Running + MissionState.Finished -> DownloadStage.Finished + else -> return null + } + + val (metadata, storage) = when (stage) { + DownloadStage.Finished -> finishedMission?.let { + MissionMetadata( + serviceId = it.serviceId, + url = it.source, + streamUid = it.streamUid, + timestamp = it.timestamp, + kind = it.kind, + qualityLabel = it.qualityLabel + ) to it.storage } - else -> DownloadStatus.None + else -> pendingMission?.let { + MissionMetadata( + serviceId = it.serviceId, + url = it.source, + streamUid = it.streamUid, + timestamp = it.timestamp, + kind = it.kind, + qualityLabel = it.qualityLabel + ) to it.storage + } + } ?: return null + + val hasStorage = storage != null && !storage.isInvalid() + val fileUri = storage?.getUri() + val parentUri = storage?.getParentUri() + + val handle = DownloadHandle( + serviceId = metadata.serviceId, + url = metadata.url ?: "", + streamUid = metadata.streamUid, + storageUri = fileUri, + timestamp = metadata.timestamp, + kind = metadata.kind + ) + + val fileAvailable = when (stage) { + DownloadStage.Finished -> hasStorage && fileExists + DownloadStage.Pending, DownloadStage.Running -> false } + + return DownloadEntry( + handle = handle, + displayName = storage?.getName(), + qualityLabel = metadata.qualityLabel, + mimeType = if (hasStorage) storage.getType() else null, + fileUri = fileUri, + parentUri = parentUri, + fileAvailable = fileAvailable, + stage = stage + ) } + + private data class MissionMetadata( + val serviceId: Int, + val url: String?, + val streamUid: Long, + val timestamp: Long, + val kind: Char, + val qualityLabel: String? + ) } diff --git a/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt b/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt index ef36b7d09b3..4481dd9dd3a 100644 --- a/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt +++ b/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt @@ -1,11 +1,14 @@ package org.schabi.newpipe.download.ui import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -14,66 +17,134 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.schabi.newpipe.R -import org.schabi.newpipe.download.CompletedDownload -import org.schabi.newpipe.fragments.detail.DownloadChipState +import org.schabi.newpipe.download.DownloadEntry +import org.schabi.newpipe.download.DownloadStage import org.schabi.newpipe.fragments.detail.DownloadUiState +import org.schabi.newpipe.fragments.detail.isPending +import org.schabi.newpipe.fragments.detail.isRunning +import us.shandian.giga.util.Utility +import us.shandian.giga.util.Utility.FileType -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DownloadStatusHost( state: DownloadUiState, - onChipClick: () -> Unit, + onChipClick: (DownloadEntry) -> Unit, onDismissSheet: () -> Unit, - onOpenFile: (CompletedDownload) -> Unit, - onDeleteFile: (CompletedDownload) -> Unit, - onRemoveLink: (CompletedDownload) -> Unit, - onShowInFolder: (CompletedDownload) -> Unit + onOpenFile: (DownloadEntry) -> Unit, + onDeleteFile: (DownloadEntry) -> Unit, + onRemoveLink: (DownloadEntry) -> Unit, + onShowInFolder: (DownloadEntry) -> Unit ) { - val chipState = state.chipState + val selected = state.selected val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - if (state.isSheetVisible && chipState is DownloadChipState.Downloaded) { + if (state.isSheetVisible && selected != null) { ModalBottomSheet( onDismissRequest = onDismissSheet, sheetState = sheetState ) { DownloadSheetContent( - info = chipState.info, - onOpenFile = { onOpenFile(chipState.info) }, - onDeleteFile = { onDeleteFile(chipState.info) }, - onRemoveLink = { onRemoveLink(chipState.info) }, - onShowInFolder = { onShowInFolder(chipState.info) } + entry = selected, + onOpenFile = { onOpenFile(selected) }, + onDeleteFile = { onDeleteFile(selected) }, + onRemoveLink = { onRemoveLink(selected) }, + onShowInFolder = { onShowInFolder(selected) } ) } } - when (chipState) { - DownloadChipState.Hidden -> Unit - DownloadChipState.Downloading -> AssistChip( - onClick = onChipClick, - label = { Text(text = stringResource(id = R.string.download_status_downloading)) } - ) - is DownloadChipState.Downloaded -> { - val label = chipState.info.qualityLabel - val text = if (!label.isNullOrBlank()) { - stringResource(R.string.download_status_downloaded, label) + if (state.entries.isEmpty()) { + return + } + + FlowRow(modifier = Modifier.padding(horizontal = 12.dp)) { + state.entries.forEach { entry -> + DownloadChip(entry = entry, onClick = { onChipClick(entry) }) + } + } +} + +@Composable +private fun DownloadChip(entry: DownloadEntry, onClick: () -> Unit) { + val context = LocalContext.current + val type = Utility.getFileType(entry.handle.kind, entry.displayName ?: "") + val backgroundColor = Utility.getBackgroundForFileType(context, type) + val stripeColor = Utility.getForegroundForFileType(context, type) + + val typeLabelRes = when (type) { + FileType.MUSIC -> R.string.download_type_audio + FileType.VIDEO -> R.string.download_type_video + FileType.SUBTITLE -> R.string.download_type_captions + FileType.UNKNOWN -> R.string.download_type_media + } + val typeLabel = stringResource(typeLabelRes) + + val stageText = when (entry.stage) { + DownloadStage.Finished -> stringResource(R.string.download_status_downloaded_type, typeLabel) + DownloadStage.Running -> stringResource(R.string.download_status_downloading_type, typeLabel) + DownloadStage.Pending -> stringResource(R.string.download_status_pending_type, typeLabel) + } + + val chipText = entry.qualityLabel?.takeIf { it.isNotBlank() }?.let { "$stageText • $it" } ?: stageText + + val chipShape = MaterialTheme.shapes.small + + val baseModifier = Modifier + .padding(end = 8.dp, bottom = 8.dp) + .clip(chipShape) + .drawWithContent { + if (entry.stage == DownloadStage.Finished) { + drawRect(Color(backgroundColor)) + drawContent() + } else if (entry.isPending) { + drawRect(Color(backgroundColor)) + val stripePaint = Color(stripeColor).copy(alpha = 0.35f) + val stripeWidth = 12.dp.toPx() + var offset = -size.height + val diagonal = size.height + while (offset < size.width + size.height) { + drawLine( + color = stripePaint, + start = Offset(offset, 0f), + end = Offset(offset + diagonal, diagonal), + strokeWidth = stripeWidth + ) + offset += stripeWidth * 2f + } + drawContent() } else { - stringResource(R.string.download_status_downloaded_simple) + drawContent() } - AssistChip( - onClick = onChipClick, - label = { Text(text = text) } - ) } - } + + val labelColor = MaterialTheme.colorScheme.onSurface + + val chipColors = AssistChipDefaults.assistChipColors( + containerColor = Color.Transparent, + labelColor = labelColor + ) + + AssistChip( + onClick = onClick, + label = { Text(text = chipText) }, + colors = chipColors, + modifier = baseModifier, + border = null + ) } @Composable private fun DownloadSheetContent( - info: CompletedDownload, + entry: DownloadEntry, onOpenFile: () -> Unit, onDeleteFile: () -> Unit, onRemoveLink: () -> Unit, @@ -84,13 +155,22 @@ private fun DownloadSheetContent( .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 16.dp) ) { - val title = info.displayName ?: stringResource(id = R.string.download) + val title = entry.displayName ?: stringResource(id = R.string.download) Text(text = title, style = MaterialTheme.typography.titleLarge) val subtitleParts = buildList { - info.qualityLabel?.takeIf { it.isNotBlank() }?.let { add(it) } - if (!info.fileAvailable) { - add(stringResource(id = R.string.download_status_missing)) + entry.qualityLabel?.takeIf { it.isNotBlank() }?.let { add(it) } + when (entry.stage) { + DownloadStage.Finished -> if (!entry.fileAvailable) { + add(stringResource(id = R.string.download_status_missing)) + } + DownloadStage.Pending, DownloadStage.Running -> { + if (entry.isRunning) { + add(stringResource(R.string.download_status_downloading)) + } else { + add(stringResource(R.string.missions_header_pending)) + } + } } } if (subtitleParts.isNotEmpty()) { @@ -104,21 +184,24 @@ private fun DownloadSheetContent( Spacer(modifier = Modifier.height(12.dp)) - val showFileActions = info.fileAvailable && info.fileUri != null + val showFileActions = entry.fileAvailable && entry.fileUri != null if (showFileActions) { TextButton(onClick = onOpenFile) { - Text(text = stringResource(id = R.string.download_action_open)) + Text(text = stringResource(id = R.string.open_with)) } - TextButton(onClick = onShowInFolder, enabled = info.parentUri != null) { + TextButton(onClick = onShowInFolder, enabled = entry.parentUri != null) { Text(text = stringResource(id = R.string.download_action_show_in_folder)) } TextButton(onClick = onDeleteFile) { - Text(text = stringResource(id = R.string.download_action_delete)) + Text(text = stringResource(id = R.string.delete_file)) } } - TextButton(onClick = onRemoveLink) { - Text(text = stringResource(id = R.string.download_action_remove_link), color = MaterialTheme.colorScheme.error) + TextButton(onClick = onRemoveLink, enabled = entry.stage == DownloadStage.Finished) { + Text( + text = stringResource(id = R.string.delete_entry), + color = MaterialTheme.colorScheme.error + ) } Spacer(modifier = Modifier.height(8.dp)) 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 88382bc62e9..1ac996361bb 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 @@ -67,8 +67,8 @@ import org.schabi.newpipe.App import org.schabi.newpipe.R import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.databinding.FragmentVideoDetailBinding -import org.schabi.newpipe.download.CompletedDownload import org.schabi.newpipe.download.DownloadDialog +import org.schabi.newpipe.download.DownloadEntry import org.schabi.newpipe.download.ui.DownloadStatusHost import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar @@ -288,14 +288,12 @@ class VideoDetailFragment : val composeContext = LocalContext.current DownloadStatusHost( state = uiState, - onChipClick = { - downloadStatusViewModel.onChipClicked(composeContext.applicationContext) - }, + onChipClick = { entry -> downloadStatusViewModel.onChipSelected(entry) }, onDismissSheet = { downloadStatusViewModel.dismissSheet() }, - onOpenFile = { info -> openDownloaded(info) }, - onDeleteFile = { info -> deleteDownloadedFile(info) }, - onRemoveLink = { info -> removeDownloadLink(info) }, - onShowInFolder = { info -> showDownloadedInFolder(info) } + onOpenFile = { entry -> openDownloaded(entry) }, + onDeleteFile = { entry -> deleteDownloadedFile(entry) }, + onRemoveLink = { entry -> removeDownloadLink(entry) }, + onShowInFolder = { entry -> showDownloadedInFolder(entry) } ) } } @@ -1578,29 +1576,29 @@ class VideoDetailFragment : } } - private fun openDownloaded(info: CompletedDownload) { - val uri = info.fileUri + private fun openDownloaded(entry: DownloadEntry) { + val uri = entry.fileUri if (uri == null) { - Toast.makeText(requireContext(), R.string.download_open_failed, Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.missing_file, Toast.LENGTH_SHORT).show() return } val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, info.mimeType ?: "*/*") + setDataAndType(uri, entry.mimeType ?: "*/*") 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() + Toast.makeText(requireContext(), R.string.missing_file, Toast.LENGTH_SHORT).show() } } - private fun showDownloadedInFolder(info: CompletedDownload) { - val parent = info.parentUri + private fun showDownloadedInFolder(entry: DownloadEntry) { + val parent = entry.parentUri if (parent == null) { - Toast.makeText(requireContext(), R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.invalid_directory, Toast.LENGTH_SHORT).show() return } @@ -1619,37 +1617,29 @@ class VideoDetailFragment : runCatching { startActivity(treeIntent) } .onFailure { throwable -> if (DEBUG) Log.e(TAG, "Failed to open folder", throwable) - Toast.makeText(context, R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.invalid_directory, Toast.LENGTH_SHORT).show() } } } - private fun deleteDownloadedFile(info: CompletedDownload) { - if (!info.fileAvailable) { - Toast.makeText(requireContext(), R.string.download_delete_failed, Toast.LENGTH_SHORT).show() + private fun deleteDownloadedFile(entry: DownloadEntry) { + if (!entry.fileAvailable) { + Toast.makeText(requireContext(), R.string.general_error, Toast.LENGTH_SHORT).show() return } val appContext = requireContext().applicationContext viewLifecycleOwner.lifecycleScope.launch { - val success = downloadStatusViewModel.deleteFile(appContext) - val message = if (success) { - R.string.download_deleted - } else { - R.string.download_delete_failed - } + val success = downloadStatusViewModel.deleteFile(appContext, entry.handle) + val message = if (success) R.string.file_deleted else R.string.general_error Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } } - private fun removeDownloadLink(@Suppress("UNUSED_PARAMETER") info: CompletedDownload) { + private fun removeDownloadLink(entry: DownloadEntry) { val appContext = requireContext().applicationContext viewLifecycleOwner.lifecycleScope.launch { - val success = downloadStatusViewModel.removeLink(appContext) - val message = if (success) { - R.string.download_link_removed - } else { - R.string.download_delete_failed - } + val success = downloadStatusViewModel.removeLink(appContext, entry.handle) + val message = if (success) R.string.entry_deleted else R.string.general_error Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt index 940cd774f68..3516c964828 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt @@ -9,8 +9,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.schabi.newpipe.download.CompletedDownload -import org.schabi.newpipe.download.DownloadStatus +import org.schabi.newpipe.download.DownloadEntry +import org.schabi.newpipe.download.DownloadHandle +import org.schabi.newpipe.download.DownloadStage import org.schabi.newpipe.download.DownloadStatusRepository class VideoDownloadStatusViewModel : ViewModel() { @@ -44,76 +45,70 @@ class VideoDownloadStatusViewModel : ViewModel() { observeJob?.cancel() observeJob = viewModelScope.launch { - DownloadStatusRepository.observe(appContext, serviceId, normalizedUrl).collectLatest { status -> - _uiState.update { it.copy(chipState = status.toChipState()) } - } + DownloadStatusRepository.observe(appContext, serviceId, normalizedUrl) + .collectLatest { entries -> + _uiState.update { current -> + val selectedHandle = current.selected?.handle + val newSelected = selectedHandle?.let { handle -> + entries.firstOrNull { it.handle == handle } + } + current.copy(entries = entries, selected = newSelected) + } + } } } - fun onChipClicked(context: Context) { - val url = currentUrl ?: return - val serviceId = currentServiceId - viewModelScope.launch { - val result = runCatching { - DownloadStatusRepository.refresh(context.applicationContext, serviceId, url) - } - result.getOrNull()?.let { status -> - _uiState.update { - val chipState = status.toChipState() - it.copy( - chipState = chipState, - isSheetVisible = chipState is DownloadChipState.Downloaded - ) - } - } - if (result.isFailure) { - _uiState.update { it.copy(isSheetVisible = false) } - } - } + fun onChipSelected(entry: DownloadEntry) { + _uiState.update { it.copy(selected = entry) } } fun dismissSheet() { - _uiState.update { it.copy(isSheetVisible = false) } + _uiState.update { it.copy(selected = null) } } - suspend fun deleteFile(context: Context): Boolean { - val url = currentUrl ?: return false - val serviceId = currentServiceId + suspend fun deleteFile(context: Context, handle: DownloadHandle): Boolean { val success = runCatching { - DownloadStatusRepository.deleteFile(context.applicationContext, serviceId, url) + DownloadStatusRepository.deleteFile(context.applicationContext, handle) }.getOrDefault(false) if (success) { - _uiState.update { it.copy(isSheetVisible = false) } + _uiState.update { state -> + state.copy( + entries = state.entries.filterNot { it.handle == handle }, + selected = null + ) + } } return success } - suspend fun removeLink(context: Context): Boolean { - val url = currentUrl ?: return false - val serviceId = currentServiceId + suspend fun removeLink(context: Context, handle: DownloadHandle): Boolean { val success = runCatching { - DownloadStatusRepository.removeLink(context.applicationContext, serviceId, url) + DownloadStatusRepository.removeLink(context.applicationContext, handle) }.getOrDefault(false) if (success) { - _uiState.update { it.copy(isSheetVisible = false) } + _uiState.update { state -> + state.copy( + entries = state.entries.filterNot { it.handle == handle }, + selected = null + ) + } } return success } - - private fun DownloadStatus.toChipState(): DownloadChipState = when (this) { - DownloadStatus.None -> DownloadChipState.Hidden - is DownloadStatus.InProgress -> DownloadChipState.Downloading - is DownloadStatus.Completed -> DownloadChipState.Downloaded(info) - } } data class DownloadUiState( - val chipState: DownloadChipState = DownloadChipState.Hidden, - val isSheetVisible: Boolean = false -) - -sealed interface DownloadChipState { - data object Hidden : DownloadChipState - data object Downloading : DownloadChipState - data class Downloaded(val info: CompletedDownload) : DownloadChipState + val entries: List = emptyList(), + val selected: DownloadEntry? = null +) { + val isSheetVisible: Boolean get() = selected != null } + +val DownloadEntry.isPending: Boolean + get() = when (stage) { + DownloadStage.Pending, DownloadStage.Running -> true + DownloadStage.Finished -> false + } + +val DownloadEntry.isRunning: Boolean + get() = stage == DownloadStage.Running diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index 2eeb14b1b41..7068304a703 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -134,7 +134,6 @@ private View getCustomView(final int position, if (stream instanceof VideoStream) { final VideoStream videoStream = ((VideoStream) stream); - qualityString = videoStream.getResolution(); if (hasAnyVideoOnlyStreamWithNoSecondaryStream) { if (videoStream.isVideoOnly()) { @@ -149,24 +148,13 @@ private View getCustomView(final int position, woSoundIconVisibility = View.INVISIBLE; } } - } else if (stream instanceof AudioStream) { - final AudioStream audioStream = ((AudioStream) stream); - if (audioStream.getAverageBitrate() > 0) { - qualityString = audioStream.getAverageBitrate() + "kbps"; - } else { - qualityString = context.getString(R.string.unknown_quality); - } - } else if (stream instanceof SubtitlesStream) { - qualityString = ((SubtitlesStream) stream).getDisplayLanguageName(); - if (((SubtitlesStream) stream).isAutoGenerated()) { - qualityString += " (" + context.getString(R.string.caption_auto_generated) + ")"; - } } else { - if (mediaFormat == null) { - qualityString = context.getString(R.string.unknown_quality); - } else { - qualityString = mediaFormat.getSuffix(); - } + woSoundIconVisibility = View.GONE; + } + + qualityString = StreamLabelUtils.getQualityLabel(context, stream); + if (qualityString == null) { + qualityString = context.getString(R.string.unknown_quality); } if (streamsWrapper.getSizeInBytes(position) > 0) { diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamLabelUtils.kt b/app/src/main/java/org/schabi/newpipe/util/StreamLabelUtils.kt new file mode 100644 index 00000000000..908c64cae2c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/StreamLabelUtils.kt @@ -0,0 +1,30 @@ +package org.schabi.newpipe.util + +import android.content.Context +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.stream.AudioStream +import org.schabi.newpipe.extractor.stream.Stream +import org.schabi.newpipe.extractor.stream.SubtitlesStream +import org.schabi.newpipe.extractor.stream.VideoStream + +object StreamLabelUtils { + @JvmStatic + fun getQualityLabel(context: Context, stream: Stream): String? = when (stream) { + is VideoStream -> stream.resolution?.takeIf { it.isNotBlank() } + is AudioStream -> { + val bitrate = stream.averageBitrate + if (bitrate > 0) "$bitrate kbps" else null + } + is SubtitlesStream -> { + val language = stream.displayLanguageName + if (language.isNullOrBlank()) { + null + } else if (stream.isAutoGenerated) { + "$language (${context.getString(R.string.caption_auto_generated)})" + } else { + language + } + } + else -> stream.format?.suffix?.takeIf { it.isNotBlank() } + } +} diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java index 9ba021c12d9..54858193722 100644 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -1,6 +1,7 @@ package us.shandian.giga.service; import android.content.Context; +import android.net.Uri; import android.os.Handler; import android.util.Log; @@ -363,6 +364,30 @@ private FinishedMission getFinishedMission(int serviceId, String url) { return null; } + @Nullable + private FinishedMission getFinishedMission(Uri storageUri) { + String uriString = storageUri.toString(); + for (FinishedMission mission : mMissionsFinished) { + if (mission.storage != null && !mission.storage.isInvalid()) { + Uri missionUri = mission.storage.getUri(); + if (missionUri != null && uriString.equals(missionUri.toString())) { + return mission; + } + } + } + return null; + } + + @Nullable + private FinishedMission getFinishedMission(long timestamp) { + for (FinishedMission mission : mMissionsFinished) { + if (mission.timestamp == timestamp) { + return mission; + } + } + return null; + } + private boolean isFileAvailable(@NonNull FinishedMission mission) { if (mission.storage == null || mission.storage.isInvalid()) { return false; @@ -474,27 +499,56 @@ public static final class DownloadStatusSnapshot { } DownloadStatusSnapshot getDownloadStatus(int serviceId, String url, boolean revalidateFile) { + List snapshots = getDownloadStatuses(serviceId, url, revalidateFile); + if (snapshots.isEmpty()) { + return new DownloadStatusSnapshot(MissionState.None, null, null, false); + } + return snapshots.get(0); + } + + List getDownloadStatuses(int serviceId, String url, boolean revalidateFile) { + List result = new ArrayList<>(); synchronized (this) { - DownloadMission pending = getPendingMission(serviceId, url); - if (pending != null) { - MissionState state = pending.running - ? MissionState.PendingRunning - : MissionState.Pending; - return new DownloadStatusSnapshot(state, pending, null, true); + for (DownloadMission mission : mMissionsPending) { + if (mission.serviceId == serviceId && Objects.equals(mission.source, url)) { + MissionState state = mission.running + ? MissionState.PendingRunning + : MissionState.Pending; + result.add(new DownloadStatusSnapshot(state, mission, null, true)); + } } - FinishedMission finished = getFinishedMission(serviceId, url); - if (finished != null) { - boolean available = !revalidateFile || isFileAvailable(finished); - return new DownloadStatusSnapshot(MissionState.Finished, null, finished, available); + for (FinishedMission mission : mMissionsFinished) { + if (mission.serviceId == serviceId && Objects.equals(mission.source, url)) { + boolean available = !revalidateFile || isFileAvailable(mission); + result.add(new DownloadStatusSnapshot(MissionState.Finished, null, mission, available)); + } } } - return new DownloadStatusSnapshot(MissionState.None, null, null, false); + if (result.isEmpty()) { + result.add(new DownloadStatusSnapshot(MissionState.None, null, null, false)); + } + return result; } + @Deprecated boolean deleteFinishedMission(int serviceId, String url, boolean deleteFile) { - FinishedMission mission = getFinishedMission(serviceId, url); + return deleteFinishedMission(serviceId, url, null, -1L, deleteFile); + } + + boolean deleteFinishedMission(int serviceId, String url, @Nullable Uri storageUri, + long timestamp, boolean deleteFile) { + FinishedMission mission = null; + if (storageUri != null) { + mission = getFinishedMission(storageUri); + } + if (mission == null && timestamp > 0) { + mission = getFinishedMission(timestamp); + } + if (mission == null) { + mission = getFinishedMission(serviceId, url); + } if (mission == null) { return false; } 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 53726ab54bb..3826455f34d 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -38,6 +38,8 @@ import androidx.core.content.IntentCompat; import androidx.preference.PreferenceManager; +import java.util.List; + import org.schabi.newpipe.R; import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.player.helper.LockManager; @@ -601,8 +603,14 @@ public DownloadManager.DownloadStatusSnapshot getDownloadStatus(int serviceId, S return mManager.getDownloadStatus(serviceId, source, revalidateFile); } - public boolean deleteFinishedMission(int serviceId, String source, boolean deleteFile) { - return mManager.deleteFinishedMission(serviceId, source, deleteFile); + public List getDownloadStatuses(int serviceId, + String source, boolean revalidateFile) { + return mManager.getDownloadStatuses(serviceId, source, revalidateFile); + } + + public boolean deleteFinishedMission(int serviceId, String source, @Nullable Uri storageUri, + long timestamp, boolean deleteFile) { + return mManager.deleteFinishedMission(serviceId, source, storageUri, timestamp, deleteFile); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8f1440a6a8..05f2abacf9e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,17 +18,16 @@ Download stream file Downloaded • %1$s Downloaded + Downloaded %1$s + Downloading %1$s + Pending %1$s + Audio + Video + Captions + Media Downloading… Previously downloaded – file missing - Open file Show in folder - Delete file - Remove link - Download link removed - Unable to open downloaded file - Unable to open folder - Unable to delete downloaded file - Deleted downloaded file Search Search %1$s Search %1$s (%2$s)