1
0
Fork 0
mirror of https://github.com/TeamNewPipe/NewPipe.git synced 2025-10-03 17:59:41 +02:00

Refactor download status persistence and UI

Restore finshed mission conditoina
This commit is contained in:
Josh Mandel 2025-09-17 10:58:41 -04:00
parent b5cb367edb
commit 13b10b6e52
28 changed files with 682 additions and 1852 deletions

View file

@ -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')"
]
}
}

View file

@ -25,7 +25,6 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins
import org.acra.ACRA.init import org.acra.ACRA.init
import org.acra.ACRA.isACRASenderServiceProcess import org.acra.ACRA.isACRASenderServiceProcess
import org.acra.config.CoreConfigurationBuilder import org.acra.config.CoreConfigurationBuilder
import org.schabi.newpipe.download.DownloadMaintenance
import org.schabi.newpipe.error.ReCaptchaActivity import org.schabi.newpipe.error.ReCaptchaActivity
import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.downloader.Downloader import org.schabi.newpipe.extractor.downloader.Downloader
@ -121,8 +120,6 @@ open class App :
configureRxJavaErrorHandler() configureRxJavaErrorHandler()
YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl) YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl)
DownloadMaintenance.schedule(this)
} }
override fun newImageLoader(context: Context): ImageLoader = override fun newImageLoader(context: Context): ImageLoader =

View file

@ -9,7 +9,6 @@ import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6;
import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7; import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7;
import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8; 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_8_9;
import static org.schabi.newpipe.database.Migrations.MIGRATION_9_10;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
@ -30,7 +29,7 @@ public final class NewPipeDatabase {
return Room return Room
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME) .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, .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(); .build();
} }

View file

@ -1,13 +1,11 @@
package org.schabi.newpipe.database; 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.Database;
import androidx.room.RoomDatabase; import androidx.room.RoomDatabase;
import androidx.room.TypeConverters; 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.FeedDAO;
import org.schabi.newpipe.database.feed.dao.FeedGroupDAO; import org.schabi.newpipe.database.feed.dao.FeedGroupDAO;
import org.schabi.newpipe.database.feed.model.FeedEntity; import org.schabi.newpipe.database.feed.model.FeedEntity;
@ -38,9 +36,9 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class, StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class,
PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class, PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class,
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.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 abstract class AppDatabase extends RoomDatabase {
public static final String DATABASE_NAME = "newpipe.db"; public static final String DATABASE_NAME = "newpipe.db";
@ -64,6 +62,4 @@ public abstract class AppDatabase extends RoomDatabase {
public abstract FeedGroupDAO feedGroupDAO(); public abstract FeedGroupDAO feedGroupDAO();
public abstract SubscriptionDAO subscriptionDAO(); public abstract SubscriptionDAO subscriptionDAO();
public abstract DownloadedStreamsDao downloadedStreamsDao();
} }

View file

@ -1,7 +1,6 @@
package org.schabi.newpipe.database package org.schabi.newpipe.database
import androidx.room.TypeConverter import androidx.room.TypeConverter
import org.schabi.newpipe.database.download.DownloadedStreamStatus
import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.local.subscription.FeedGroupIcon import org.schabi.newpipe.local.subscription.FeedGroupIcon
import java.time.Instant import java.time.Instant
@ -50,14 +49,4 @@ class Converters {
fun feedGroupIconOf(id: Int): FeedGroupIcon { fun feedGroupIconOf(id: Int): FeedGroupIcon {
return FeedGroupIcon.entries.first { it.id == id } 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
}
} }

View file

@ -27,7 +27,6 @@ public final class Migrations {
public static final int DB_VER_7 = 7; public static final int DB_VER_7 = 7;
public static final int DB_VER_8 = 8; public static final int DB_VER_8 = 8;
public static final int DB_VER_9 = 9; public static final int DB_VER_9 = 9;
public static final int DB_VER_10 = 10;
private static final String TAG = Migrations.class.getName(); private static final String TAG = Migrations.class.getName();
public static final boolean DEBUG = MainActivity.DEBUG; public static final boolean DEBUG = MainActivity.DEBUG;
@ -303,22 +302,6 @@ public final class Migrations {
} }
}; };
public static final Migration MIGRATION_9_10 = new Migration(DB_VER_9, DB_VER_10) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("CREATE TABLE IF NOT EXISTS downloaded_streams "
+ "(id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "stream_uid INTEGER NOT NULL, service_id INTEGER NOT NULL, "
+ "url TEXT NOT NULL, file_uri TEXT NOT NULL, parent_uri TEXT, "
+ "display_name TEXT, mime TEXT, size_bytes INTEGER, quality_label TEXT, "
+ "duration_ms INTEGER, status INTEGER NOT NULL, added_at INTEGER NOT NULL, "
+ "last_checked_at INTEGER, missing_since INTEGER, FOREIGN KEY(stream_uid) "
+ "REFERENCES streams(uid) ON UPDATE CASCADE ON DELETE CASCADE)");
database.execSQL("CREATE UNIQUE INDEX index_downloaded_streams_stream_uid "
+ "ON downloaded_streams (stream_uid)");
}
};
private Migrations() { private Migrations() {
} }
} }

View file

@ -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"
}
}

View file

@ -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
}
}

View file

@ -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<List<DownloadedStreamEntity>>
@Query("SELECT * FROM downloaded_streams WHERE stream_uid = :streamUid LIMIT 1")
fun getByStreamUid(streamUid: Long): Maybe<DownloadedStreamEntity>
@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<DownloadedStreamEntity>
}

View file

@ -81,14 +81,6 @@ public class DownloadActivity extends AppCompatActivity {
return true; return true;
} }
@Override
protected void onResume() {
super.onResume();
new Thread(() ->
DownloadMaintenance.revalidateAvailable(DownloadActivity.this, 10)
).start();
}
@Override @Override
public boolean onOptionsItemSelected(final MenuItem item) { public boolean onOptionsItemSelected(final MenuItem item) {
switch (item.getItemId()) { switch (item.getItemId()) {

View file

@ -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
}
}
}

View file

@ -79,9 +79,7 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.get.MissionRecoveryInfo;
import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.postprocessing.Postprocessing;
@ -1135,50 +1133,27 @@ public class DownloadDialog extends DialogFragment
} }
final String qualityLabel = buildQualityLabel(selectedStream); 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( DownloadManagerService.startMission(
context, context,
urls, urls,
storage, storage,
missionKind, kind,
missionThreads, threads,
missionSourceUrl, currentInfo.getUrl(),
missionPsName, psName,
missionPsArgs, psArgs,
missionNearLength, nearLength,
new ArrayList<>(recoveryInfo), new ArrayList<>(recoveryInfo),
association.getStreamUid(), -1L,
association.getEntityId(), currentInfo.getServiceId(),
currentInfo.getServiceId() qualityLabel
); );
Toast.makeText(context, getString(R.string.download_has_started), Toast.makeText(context, getString(R.string.download_has_started),
Toast.LENGTH_SHORT).show(); Toast.LENGTH_SHORT).show();
dismiss(); dismiss();
},
throwable -> ErrorUtil.createNotification(requireContext(),
new ErrorInfo(throwable, UserAction.DOWNLOAD_FAILED,
"Preparing download metadata", currentInfo))
));
} }
@Nullable @Nullable

View file

@ -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<DownloadRevalidationWorker>(1, TimeUnit.DAYS)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, workRequest)
}
}

View file

@ -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"
}
}

View file

@ -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<DownloadStatus> = 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 <T> 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
}
}
}

View file

@ -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<List<DownloadedStreamEntity>> {
return downloadedDao(context)
.observeByStreamUid(streamUid)
.subscribeOn(Schedulers.io())
}
fun getByStreamUid(context: Context, streamUid: Long): Maybe<DownloadedStreamEntity> {
return downloadedDao(context)
.getByStreamUid(streamUid)
.subscribeOn(Schedulers.io())
}
fun ensureStreamEntry(context: Context, info: StreamInfo): Single<Long> {
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<DownloadAssociation> {
return Single.fromCallable {
val db = database(context)
db.runInTransaction<DownloadAssociation> {
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()
}
}

View file

@ -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))
}
}

View file

@ -4,7 +4,6 @@ import android.animation.ValueAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
@ -13,7 +12,6 @@ import android.content.pm.ActivityInfo
import android.database.ContentObserver import android.database.ContentObserver
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
@ -23,7 +21,6 @@ import android.provider.Settings
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.util.Log import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.ContextThemeWrapper
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
@ -35,14 +32,14 @@ import android.view.WindowManager
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit import androidx.core.content.edit
@ -50,7 +47,9 @@ import androidx.core.net.toUri
import androidx.core.os.postDelayed import androidx.core.os.postDelayed
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible 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 androidx.preference.PreferenceManager
import coil3.util.CoilUtils import coil3.util.CoilUtils
import com.evernote.android.state.State 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.appbar.AppBarLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback 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.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch
import org.schabi.newpipe.App import org.schabi.newpipe.App
import org.schabi.newpipe.R 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.database.stream.model.StreamEntity
import org.schabi.newpipe.databinding.DownloadStatusSheetBinding
import org.schabi.newpipe.databinding.FragmentVideoDetailBinding import org.schabi.newpipe.databinding.FragmentVideoDetailBinding
import org.schabi.newpipe.download.DownloadActivity import org.schabi.newpipe.download.CompletedDownload
import org.schabi.newpipe.download.DownloadAvailabilityChecker
import org.schabi.newpipe.download.DownloadDialog 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.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar
import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar 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.playqueue.SinglePlayQueue
import org.schabi.newpipe.player.ui.MainPlayerUi import org.schabi.newpipe.player.ui.MainPlayerUi
import org.schabi.newpipe.player.ui.VideoPlayerUi 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.DependentPreferenceHelper
import org.schabi.newpipe.util.DeviceUtils import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.ExtractorHelper 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.KoreUtils
import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.external_communication.ShareUtils
import org.schabi.newpipe.util.image.CoilHelper import org.schabi.newpipe.util.image.CoilHelper
import java.io.File
import java.util.LinkedList import java.util.LinkedList
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.abs 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 // can't make this lateinit because it needs to be set to null when the view is destroyed
private var nullableBinding: FragmentVideoDetailBinding? = null private var nullableBinding: FragmentVideoDetailBinding? = null
private val binding: FragmentVideoDetailBinding get() = nullableBinding!! private val binding: FragmentVideoDetailBinding get() = nullableBinding!!
private val downloadStatusViewModel: VideoDownloadStatusViewModel by viewModels()
private lateinit var pageAdapter: TabAdapter private lateinit var pageAdapter: TabAdapter
private var settingsContentObserver: ContentObserver? = null private var settingsContentObserver: ContentObserver? = null
@ -196,17 +192,6 @@ class VideoDetailFragment :
private var currentWorker: Disposable? = null private var currentWorker: Disposable? = null
private val disposables = CompositeDisposable() private val disposables = CompositeDisposable()
private var positionSubscriber: Disposable? = null 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 // Service management
@ -295,6 +280,26 @@ class VideoDetailFragment :
): View { ): View {
val newBinding = FragmentVideoDetailBinding.inflate(inflater, container, false) val newBinding = FragmentVideoDetailBinding.inflate(inflater, container, false)
nullableBinding = newBinding 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() return newBinding.getRoot()
} }
@ -374,13 +379,6 @@ class VideoDetailFragment :
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
downloadStatusDisposable?.let {
disposables.remove(it)
it.dispose()
}
downloadStatusDisposable = null
currentDownloadedStream = null
currentStreamUid = null
nullableBinding = null nullableBinding = null
} }
@ -1399,8 +1397,8 @@ class VideoDetailFragment :
currentInfo = info currentInfo = info
setInitialData(info.serviceId, info.originalUrl, info.name, playQueue) setInitialData(info.serviceId, info.originalUrl, info.name, playQueue)
updateDownloadChip(null) downloadStatusViewModel.dismissSheet()
observeDownloadStatus(info) downloadStatusViewModel.setStream(requireContext().applicationContext, info.serviceId, info.url)
updateTabs(info) updateTabs(info)
@ -1580,165 +1578,15 @@ class VideoDetailFragment :
} }
} }
private fun observeDownloadStatus(info: StreamInfo) { private fun openDownloaded(info: CompletedDownload) {
val context = context ?: return val uri = info.fileUri
downloadStatusDisposable?.let { if (uri == null) {
disposables.remove(it) Toast.makeText(requireContext(), R.string.download_open_failed, Toast.LENGTH_SHORT).show()
it.dispose()
}
val disposable = DownloadedStreamsRepository.ensureStreamEntry(context, info)
.flatMapPublisher { streamUid: Long ->
currentStreamUid = streamUid
DownloadedStreamsRepository.observeByStreamUid(context, streamUid)
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ entities: List<DownloadedStreamEntity> ->
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 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<String>()
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 { val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, entity.mime ?: "*/*") setDataAndType(uri, info.mimeType ?: "*/*")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
@ -1749,98 +1597,61 @@ class VideoDetailFragment :
} }
} }
private fun showInFolder(entity: DownloadedStreamEntity) { private fun showDownloadedInFolder(info: CompletedDownload) {
val parent = entity.parentUri?.takeIf { it.isNotBlank() }?.let(Uri::parse) val parent = info.parentUri
if (parent == null) { if (parent == null) {
Toast.makeText(requireContext(), R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show()
return 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) setDataAndType(parent, DocumentsContract.Document.MIME_TYPE_DIR)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
runCatching { startActivity(intent) } runCatching { startActivity(viewIntent) }
.onFailure { .onFailure {
val treeIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { val treeIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra(DocumentsContract.EXTRA_INITIAL_URI, parent) putExtra(DocumentsContract.EXTRA_INITIAL_URI, parent)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
runCatching { startActivity(treeIntent) } runCatching { startActivity(treeIntent) }
.onFailure { throwable -> .onFailure { throwable ->
if (DEBUG) Log.e(TAG, "Failed to open folder", 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) { private fun deleteDownloadedFile(info: CompletedDownload) {
val context = requireContext() if (!info.fileAvailable) {
disposables.add( Toast.makeText(requireContext(), R.string.download_delete_failed, Toast.LENGTH_SHORT).show()
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 return
} }
val appContext = requireContext().applicationContext
val uri = Uri.parse(uriString) viewLifecycleOwner.lifecycleScope.launch {
val deleted = when (uri.scheme?.lowercase()) { val success = downloadStatusViewModel.deleteFile(appContext)
ContentResolver.SCHEME_CONTENT -> DocumentFile.fromSingleUri(context, uri)?.delete() ?: false val message = if (success) {
ContentResolver.SCHEME_FILE -> uri.path?.let { File(it).delete() } ?: false R.string.download_deleted
else -> runCatching { context.contentResolver.delete(uri, null, null) > 0 }.getOrDefault(false) } else {
R.string.download_delete_failed
}
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
}
} }
if (!deleted) { private fun removeDownloadLink(@Suppress("UNUSED_PARAMETER") info: CompletedDownload) {
Toast.makeText(context, R.string.download_delete_failed, Toast.LENGTH_SHORT).show() val appContext = requireContext().applicationContext
return 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()
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) }
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////

View file

@ -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<DownloadUiState> = _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
}

View file

@ -135,8 +135,8 @@ public class DownloadMission extends Mission {
public MissionRecoveryInfo[] recoveryInfo; public MissionRecoveryInfo[] recoveryInfo;
public long streamUid = -1; public long streamUid = -1;
public long downloadedEntityId = -1;
public int serviceId = -1; public int serviceId = -1;
public String qualityLabel = null;
private transient int finishCount; private transient int finishCount;
public transient volatile boolean running; public transient volatile boolean running;

View file

@ -4,6 +4,10 @@ import androidx.annotation.NonNull;
public class FinishedMission extends Mission { public class FinishedMission extends Mission {
public int serviceId = -1;
public long streamUid = -1;
public String qualityLabel = null;
public FinishedMission() { public FinishedMission() {
} }
@ -13,6 +17,9 @@ public class FinishedMission extends Mission {
timestamp = mission.timestamp; timestamp = mission.timestamp;
kind = mission.kind; kind = mission.kind;
storage = mission.storage; storage = mission.storage;
serviceId = mission.serviceId;
streamUid = mission.streamUid;
qualityLabel = mission.qualityLabel;
} }
} }

View file

@ -27,7 +27,7 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
// TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?)
private static final String DATABASE_NAME = "downloads.db"; 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) * 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_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 * The statement to create the table
*/ */
@ -66,6 +72,9 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
KEY_DONE + " INTEGER NOT NULL, " + KEY_DONE + " INTEGER NOT NULL, " +
KEY_TIMESTAMP + " INTEGER NOT NULL, " + KEY_TIMESTAMP + " INTEGER NOT NULL, " +
KEY_KIND + " TEXT 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 + "));"; " UNIQUE(" + KEY_TIMESTAMP + ", " + KEY_PATH + "));";
@ -121,6 +130,17 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
cursor.close(); cursor.close();
db.execSQL("DROP TABLE " + MISSIONS_TABLE_NAME_v2); 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 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
values.put(KEY_DONE, downloadMission.length); values.put(KEY_DONE, downloadMission.length);
values.put(KEY_TIMESTAMP, downloadMission.timestamp); values.put(KEY_TIMESTAMP, downloadMission.timestamp);
values.put(KEY_KIND, String.valueOf(downloadMission.kind)); 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; return values;
} }
@ -152,6 +183,9 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE));
mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)); mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP));
mission.kind = kind.charAt(0); 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 { try {
mission.storage = new StoredFileHelper(context,null, Uri.parse(path), ""); mission.storage = new StoredFileHelper(context,null, Uri.parse(path), "");
@ -200,11 +234,10 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ?", new String[]{ts}); database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ?", new String[]{ts});
} else { } else {
database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ? AND " + KEY_PATH + " = ?", new String[]{ database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ? AND " + KEY_PATH + " = ?", new String[]{
ts, mission.storage.getUri().toString() ts, mission.storage.getUri().toString()});
});
} }
} else { } else {
throw new UnsupportedOperationException("DownloadMission"); database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ?", new String[]{ts});
} }
} }
@ -217,11 +250,11 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
if (mission instanceof FinishedMission) { if (mission instanceof FinishedMission) {
if (mission.storage.isInvalid()) { 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 { } else {
rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_PATH + " = ?", new String[]{ rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_PATH + " = ?",
mission.storage.getUri().toString() new String[]{mission.storage.getUri().toString()});
});
} }
} else { } else {
throw new UnsupportedOperationException("DownloadMission"); throw new UnsupportedOperationException("DownloadMission");

View file

@ -14,6 +14,7 @@ import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects;
import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.DownloadMission;
import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.FinishedMission;
@ -333,6 +334,16 @@ public class DownloadManager {
return null; 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 * 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 * {@code -1} if there is no such mission. This function also checks if the matched mission's
@ -342,6 +353,26 @@ public class DownloadManager {
* @param storage where the file would be stored * @param storage where the file would be stored
* @return the mission index or -1 if no such mission exists * @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) { private int getFinishedMissionIndex(StoredFileHelper storage) {
for (int i = 0; i < mMissionsFinished.size(); i++) { for (int i = 0; i < mMissionsFinished.size(); i++) {
if (mMissionsFinished.get(i).storage.equals(storage)) { if (mMissionsFinished.get(i).storage.equals(storage)) {
@ -427,6 +458,50 @@ public class DownloadManager {
} }
} }
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 * runs one or multiple missions in from queue if possible
* *

View file

@ -39,8 +39,6 @@ import androidx.core.content.IntentCompat;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R; 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.download.DownloadActivity;
import org.schabi.newpipe.player.helper.LockManager; import org.schabi.newpipe.player.helper.LockManager;
import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import org.schabi.newpipe.streams.io.StoredDirectoryHelper;
@ -58,8 +56,6 @@ import us.shandian.giga.get.MissionRecoveryInfo;
import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.postprocessing.Postprocessing;
import us.shandian.giga.service.DownloadManager.NetworkState; import us.shandian.giga.service.DownloadManager.NetworkState;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class DownloadManagerService extends Service { public class DownloadManagerService extends Service {
private static final String TAG = "DownloadManagerService"; 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_STORAGE_TAG = "DownloadManagerService.extra.storageTag";
private static final String EXTRA_RECOVERY_INFO = "DownloadManagerService.extra.recoveryInfo"; 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_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_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_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished";
private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_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 PendingIntent mOpenDownloadList;
private final CompositeDisposable disposables = new CompositeDisposable();
/** /**
* notify media scanner on downloaded media file ... * notify media scanner on downloaded media file ...
* *
@ -253,7 +247,6 @@ public class DownloadManagerService extends Service {
if (icLauncher != null) icLauncher.recycle(); if (icLauncher != null) icLauncher.recycle();
mHandler = null; mHandler = null;
disposables.clear();
mManager.pauseAllMissions(true); mManager.pauseAllMissions(true);
} }
@ -269,18 +262,6 @@ public class DownloadManagerService extends Service {
switch (msg.what) { switch (msg.what) {
case MESSAGE_FINISHED: 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()); notifyMediaScanner(mission.storage.getUri());
notifyFinishedDownload(mission.storage.getName()); notifyFinishedDownload(mission.storage.getName());
mManager.setFinished(mission); mManager.setFinished(mission);
@ -384,7 +365,7 @@ public class DownloadManagerService extends Service {
char kind, int threads, String source, String psName, char kind, int threads, String source, String psName,
String[] psArgs, long nearLength, String[] psArgs, long nearLength,
ArrayList<MissionRecoveryInfo> recoveryInfo, ArrayList<MissionRecoveryInfo> recoveryInfo,
long streamUid, long downloadedEntityId, int serviceId) { long streamUid, int serviceId, String qualityLabel) {
final Intent intent = new Intent(context, DownloadManagerService.class) final Intent intent = new Intent(context, DownloadManagerService.class)
.setAction(Intent.ACTION_RUN) .setAction(Intent.ACTION_RUN)
.putExtra(EXTRA_URLS, urls) .putExtra(EXTRA_URLS, urls)
@ -399,8 +380,8 @@ public class DownloadManagerService extends Service {
.putExtra(EXTRA_PATH, storage.getUri()) .putExtra(EXTRA_PATH, storage.getUri())
.putExtra(EXTRA_STORAGE_TAG, storage.getTag()) .putExtra(EXTRA_STORAGE_TAG, storage.getTag())
.putExtra(EXTRA_STREAM_UID, streamUid) .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); context.startService(intent);
} }
@ -417,8 +398,8 @@ public class DownloadManagerService extends Service {
long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0);
String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); String tag = intent.getStringExtra(EXTRA_STORAGE_TAG);
long streamUid = intent.getLongExtra(EXTRA_STREAM_UID, -1L); long streamUid = intent.getLongExtra(EXTRA_STREAM_UID, -1L);
long downloadedEntityId = intent.getLongExtra(EXTRA_DOWNLOADED_ID, -1L);
int serviceId = intent.getIntExtra(EXTRA_SERVICE_ID, -1); int serviceId = intent.getIntExtra(EXTRA_SERVICE_ID, -1);
String qualityLabel = intent.getStringExtra(EXTRA_QUALITY_LABEL);
final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO, final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO,
MissionRecoveryInfo.class); MissionRecoveryInfo.class);
Objects.requireNonNull(recovery); Objects.requireNonNull(recovery);
@ -442,8 +423,8 @@ public class DownloadManagerService extends Service {
mission.nearLength = nearLength; mission.nearLength = nearLength;
mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]); mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]);
mission.streamUid = streamUid; mission.streamUid = streamUid;
mission.downloadedEntityId = downloadedEntityId;
mission.serviceId = serviceId; mission.serviceId = serviceId;
mission.qualityLabel = qualityLabel;
if (ps != null) if (ps != null)
ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this));
@ -615,6 +596,15 @@ public class DownloadManagerService extends Service {
mDownloadNotificationEnable = 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);
}
} }
} }

View file

@ -1,76 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="?attr/contrast_background_color"
android:paddingStart="24dp"
android:paddingTop="16dp"
android:paddingEnd="24dp"
android:paddingBottom="12dp">
<TextView
android:id="@+id/download_status_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.AppCompat.Headline"
android:textColor="?android:textColorPrimary"
tools:text="Sample Video" />
<TextView
android:id="@+id/download_status_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="4dp"
android:paddingBottom="12dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
android:textColor="?android:textColorSecondary"
tools:text="1080p • 12 Apr 2024" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/separator_color" />
<TextView
android:id="@+id/download_status_open"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selector"
android:paddingVertical="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?android:textColorPrimary"
tools:text="Open downloaded" />
<TextView
android:id="@+id/download_status_delete"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selector"
android:paddingVertical="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?android:textColorPrimary"
tools:text="Delete file" />
<TextView
android:id="@+id/download_status_show_in_folder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selector"
android:paddingVertical="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?android:textColorPrimary"
tools:text="Show in folder" />
<TextView
android:id="@+id/download_status_remove_link"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selector"
android:paddingVertical="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?attr/colorAccent"
tools:text="Remove link" />
</LinearLayout>

View file

@ -551,27 +551,21 @@
</LinearLayout> </LinearLayout>
<com.google.android.material.chip.Chip <androidx.compose.ui.platform.ComposeView
android:id="@+id/detail_download_status_chip" android:id="@+id/detail_download_status_compose"
style="@style/Widget.MaterialComponents.Chip.Action"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/detail_control_panel" android:layout_below="@id/detail_control_panel"
android:layout_marginStart="12dp" android:layout_marginStart="12dp"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_marginEnd="12dp" android:layout_marginEnd="12dp"
android:theme="@style/Theme.MaterialComponents.DayNight.Bridge" android:visibility="visible" />
android:visibility="gone"
app:chipIconVisible="false"
app:chipStrokeWidth="0dp"
tools:text="Downloaded • 1080p"
tools:visibility="visible" />
<View <View
android:id="@+id/detail_meta_info_separator" android:id="@+id/detail_meta_info_separator"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1px" android:layout_height="1px"
android:layout_below="@id/detail_download_status_chip" android:layout_below="@id/detail_download_status_compose"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:layout_marginRight="8dp" android:layout_marginRight="8dp"
android:background="?attr/separator_color" /> android:background="?attr/separator_color" />

View file

@ -25,10 +25,8 @@
<string name="download_action_delete">Delete file</string> <string name="download_action_delete">Delete file</string>
<string name="download_action_remove_link">Remove link</string> <string name="download_action_remove_link">Remove link</string>
<string name="download_link_removed">Download link removed</string> <string name="download_link_removed">Download link removed</string>
<string name="download_relinked">Download relinked</string>
<string name="download_open_failed">Unable to open downloaded file</string> <string name="download_open_failed">Unable to open downloaded file</string>
<string name="download_folder_open_failed">Unable to open folder</string> <string name="download_folder_open_failed">Unable to open folder</string>
<string name="download_relink_failed">Unable to relink file</string>
<string name="download_delete_failed">Unable to delete downloaded file</string> <string name="download_delete_failed">Unable to delete downloaded file</string>
<string name="download_deleted">Deleted downloaded file</string> <string name="download_deleted">Deleted downloaded file</string>
<string name="search">Search</string> <string name="search">Search</string>

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MaterialComponents.DayNight.BottomSheet" parent="Theme.MaterialComponents.DayNight">
<item name="textAppearanceBody1">@style/TextAppearance.MaterialComponents.Body1</item>
<item name="textAppearanceBody2">@style/TextAppearance.MaterialComponents.Body2</item>
<item name="textAppearanceHeadline6">@style/TextAppearance.MaterialComponents.Headline6</item>
<item name="textAppearanceSubtitle1">@style/TextAppearance.MaterialComponents.Body2</item>
</style>
</resources>