Merge pull request #2309 from mauriciocolli/feed
Feed order and subscriptions groups
This commit is contained in:
commit
f8cc3180e8
|
@ -17,6 +17,12 @@ android {
|
|||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
@ -55,6 +61,15 @@ android {
|
|||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
// Required and used only by groupie
|
||||
androidExtensions {
|
||||
experimental = true
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
}
|
||||
}
|
||||
|
||||
ext {
|
||||
|
@ -71,6 +86,8 @@ ext {
|
|||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
||||
androidTestImplementation "android.arch.persistence.room:testing:1.1.1"
|
||||
androidTestImplementation('androidx.test.espresso:espresso-core:3.1.0', {
|
||||
exclude module: 'support-annotations'
|
||||
})
|
||||
|
@ -87,6 +104,13 @@ dependencies {
|
|||
implementation "androidx.cardview:cardview:${androidxLibVersion}"
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
|
||||
implementation 'com.xwray:groupie:2.7.0'
|
||||
implementation 'com.xwray:groupie-kotlin-android-extensions:2.7.0'
|
||||
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata:2.0.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel:2.0.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
|
||||
|
||||
// Originally in NewPipeExtractor
|
||||
implementation 'com.grack:nanojson:1.1'
|
||||
implementation 'org.jsoup:jsoup:1.9.2'
|
||||
|
|
|
@ -0,0 +1,479 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "b7856223e2595ddf20a3ce6243ce9527",
|
||||
"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)",
|
||||
"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
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_subscriptions_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "search_history",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_search_history_search",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"search"
|
||||
],
|
||||
"createSql": "CREATE INDEX `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, `title` TEXT, `stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, `thumbnail_url` TEXT)",
|
||||
"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": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "streamType",
|
||||
"columnName": "stream_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploader",
|
||||
"columnName": "uploader",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_streams_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX `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": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"access_date"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_stream_history_stream_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX `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": "progressTime",
|
||||
"columnName": "progress_time",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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, `thumbnail_url` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlists_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"createSql": "CREATE INDEX `index_playlists_name` ON `${TABLE_NAME}` (`name`)"
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlist_stream_join_playlist_id_join_index",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX `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"
|
||||
],
|
||||
"createSql": "CREATE INDEX `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, `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": "streamCount",
|
||||
"columnName": "stream_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_remote_playlists_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"createSql": "CREATE INDEX `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_remote_playlists_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"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, \"b7856223e2595ddf20a3ce6243ce9527\")"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,707 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "9f825b1ee281480bedd38b971feac327",
|
||||
"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)",
|
||||
"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
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_subscriptions_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "search_history",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_search_history_search",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"search"
|
||||
],
|
||||
"createSql": "CREATE INDEX `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, `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": "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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_streams_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX `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": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"access_date"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_stream_history_stream_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX `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": "progressTime",
|
||||
"columnName": "progress_time",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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, `thumbnail_url` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlists_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"createSql": "CREATE INDEX `index_playlists_name` ON `${TABLE_NAME}` (`name`)"
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlist_stream_join_playlist_id_join_index",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX `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"
|
||||
],
|
||||
"createSql": "CREATE INDEX `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, `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": "streamCount",
|
||||
"columnName": "stream_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_remote_playlists_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"createSql": "CREATE INDEX `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_remote_playlists_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX `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": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_subscription_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX `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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_group_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"createSql": "CREATE INDEX `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": {
|
||||
"columnNames": [
|
||||
"group_id",
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_group_subscription_join_subscription_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX `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": {
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"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, '9f825b1ee281480bedd38b971feac327')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package org.schabi.newpipe.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AppDatabaseTest {
|
||||
companion object {
|
||||
private const val DEFAULT_SERVICE_ID = 0
|
||||
private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4"
|
||||
private const val DEFAULT_TITLE = "Test Title"
|
||||
private val DEFAULT_TYPE = StreamType.VIDEO_STREAM
|
||||
private const val DEFAULT_DURATION = 480L
|
||||
private const val DEFAULT_UPLOADER_NAME = "Uploader Test"
|
||||
private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg"
|
||||
}
|
||||
|
||||
@get:Rule val testHelper = MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory());
|
||||
|
||||
@Test
|
||||
fun migrateDatabaseFrom2to3() {
|
||||
val databaseInV2 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_2)
|
||||
|
||||
databaseInV2.run {
|
||||
insert("streams", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply {
|
||||
// put("uid", null)
|
||||
put("service_id", DEFAULT_SERVICE_ID)
|
||||
put("url", DEFAULT_URL)
|
||||
put("title", DEFAULT_TITLE)
|
||||
put("stream_type", DEFAULT_TYPE.name)
|
||||
put("duration", DEFAULT_DURATION)
|
||||
put("uploader", DEFAULT_UPLOADER_NAME)
|
||||
put("thumbnail_url", DEFAULT_THUMBNAIL)
|
||||
})
|
||||
close()
|
||||
}
|
||||
|
||||
testHelper.runMigrationsAndValidate(AppDatabase.DATABASE_NAME, Migrations.DB_VER_3,
|
||||
true, Migrations.MIGRATION_2_3);
|
||||
|
||||
val migratedDatabaseV3 = getMigratedDatabase()
|
||||
val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
|
||||
assertEquals(1, listFromDB.size)
|
||||
|
||||
val streamFromMigratedDatabase = listFromDB.first()
|
||||
assertEquals(DEFAULT_SERVICE_ID, streamFromMigratedDatabase.serviceId)
|
||||
assertEquals(DEFAULT_URL, streamFromMigratedDatabase.url)
|
||||
assertEquals(DEFAULT_TITLE, streamFromMigratedDatabase.title)
|
||||
assertEquals(DEFAULT_TYPE, streamFromMigratedDatabase.streamType)
|
||||
assertEquals(DEFAULT_DURATION, streamFromMigratedDatabase.duration)
|
||||
assertEquals(DEFAULT_UPLOADER_NAME, streamFromMigratedDatabase.uploader)
|
||||
assertEquals(DEFAULT_THUMBNAIL, streamFromMigratedDatabase.thumbnailUrl)
|
||||
assertNull(streamFromMigratedDatabase.viewCount)
|
||||
assertNull(streamFromMigratedDatabase.textualUploadDate)
|
||||
assertNull(streamFromMigratedDatabase.uploadDate)
|
||||
assertNull(streamFromMigratedDatabase.isUploadDateApproximation)
|
||||
}
|
||||
|
||||
private fun getMigratedDatabase(): AppDatabase {
|
||||
val database: AppDatabase = Room.databaseBuilder(ApplicationProvider.getApplicationContext(),
|
||||
AppDatabase::class.java, AppDatabase.DATABASE_NAME)
|
||||
.build()
|
||||
testHelper.closeWhenFinished(database)
|
||||
return database
|
||||
}
|
||||
}
|
|
@ -74,6 +74,7 @@
|
|||
|
||||
<service android:name=".local.subscription.services.SubscriptionsImportService"/>
|
||||
<service android:name=".local.subscription.services.SubscriptionsExportService"/>
|
||||
<service android:name=".local.feed.service.FeedLoadService"/>
|
||||
|
||||
<activity
|
||||
android:name=".PanicResponderActivity"
|
||||
|
|
|
@ -159,7 +159,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
.add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, R.string.tab_subscriptions)
|
||||
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel));
|
||||
drawerItems.getMenu()
|
||||
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_whats_new)
|
||||
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title)
|
||||
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.rss));
|
||||
drawerItems.getMenu()
|
||||
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
|
||||
|
@ -240,7 +240,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
NavigationHelper.openSubscriptionFragment(getSupportFragmentManager());
|
||||
break;
|
||||
case ITEM_ID_FEED:
|
||||
NavigationHelper.openWhatsNewFragment(getSupportFragmentManager());
|
||||
NavigationHelper.openFeedFragment(getSupportFragmentManager());
|
||||
break;
|
||||
case ITEM_ID_BOOKMARKS:
|
||||
NavigationHelper.openBookmarksFragment(getSupportFragmentManager());
|
||||
|
@ -389,7 +389,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
.add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, R.string.tab_subscriptions)
|
||||
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel));
|
||||
drawerItems.getMenu()
|
||||
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_whats_new)
|
||||
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title)
|
||||
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.rss));
|
||||
drawerItems.getMenu()
|
||||
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
|
||||
|
|
|
@ -9,7 +9,8 @@ import androidx.room.Room;
|
|||
import org.schabi.newpipe.database.AppDatabase;
|
||||
|
||||
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_11_12;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
||||
|
||||
public final class NewPipeDatabase {
|
||||
|
||||
|
@ -22,8 +23,7 @@ public final class NewPipeDatabase {
|
|||
private static AppDatabase getDatabase(Context context) {
|
||||
return Room
|
||||
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
||||
.addMigrations(MIGRATION_11_12)
|
||||
.fallbackToDestructiveMigration()
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
|
|
@ -45,7 +45,8 @@ public class AboutActivity extends AppCompatActivity {
|
|||
new SoftwareComponent("RxJava", "2016 - 2020", "RxJava Contributors", "https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2),
|
||||
new SoftwareComponent("RxBinding", "2015 - 2018", "Jake Wharton", "https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2),
|
||||
new SoftwareComponent("PrettyTime", "2012 - 2020", "Lincoln Baxter, III", "https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2),
|
||||
new SoftwareComponent("Markwon", "2017 - 2020", "Noties", "https://github.com/noties/Markwon", StandardLicenses.APACHE2)
|
||||
new SoftwareComponent("Markwon", "2017 - 2020", "Noties", "https://github.com/noties/Markwon", StandardLicenses.APACHE2),
|
||||
new SoftwareComponent("Groupie", "2016", "Lisa Wray", "https://github.com/lisawray/groupie", StandardLicenses.MIT)
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -4,6 +4,12 @@ import androidx.room.Database;
|
|||
import androidx.room.RoomDatabase;
|
||||
import androidx.room.TypeConverters;
|
||||
|
||||
import org.schabi.newpipe.database.feed.dao.FeedDAO;
|
||||
import org.schabi.newpipe.database.feed.dao.FeedGroupDAO;
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity;
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity;
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity;
|
||||
import org.schabi.newpipe.database.history.dao.SearchHistoryDAO;
|
||||
import org.schabi.newpipe.database.history.dao.StreamHistoryDAO;
|
||||
import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
|
||||
|
@ -21,35 +27,33 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
|||
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
|
||||
import static org.schabi.newpipe.database.Migrations.DB_VER_12_0;
|
||||
import static org.schabi.newpipe.database.Migrations.DB_VER_3;
|
||||
|
||||
@TypeConverters({Converters.class})
|
||||
@Database(
|
||||
entities = {
|
||||
SubscriptionEntity.class, SearchHistoryEntry.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,
|
||||
FeedLastUpdatedEntity.class
|
||||
},
|
||||
version = DB_VER_12_0,
|
||||
exportSchema = false
|
||||
version = DB_VER_3
|
||||
)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
||||
public static final String DATABASE_NAME = "newpipe.db";
|
||||
|
||||
public abstract SubscriptionDAO subscriptionDAO();
|
||||
|
||||
public abstract SearchHistoryDAO searchHistoryDAO();
|
||||
|
||||
public abstract StreamDAO streamDAO();
|
||||
|
||||
public abstract StreamHistoryDAO streamHistoryDAO();
|
||||
|
||||
public abstract StreamStateDAO streamStateDAO();
|
||||
|
||||
public abstract PlaylistDAO playlistDAO();
|
||||
|
||||
public abstract PlaylistStreamDAO playlistStreamDAO();
|
||||
|
||||
public abstract PlaylistRemoteDAO playlistRemoteDAO();
|
||||
|
||||
public abstract FeedDAO feedDAO();
|
||||
public abstract FeedGroupDAO feedGroupDAO();
|
||||
public abstract SubscriptionDAO subscriptionDAO();
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package org.schabi.newpipe.database;
|
|||
import androidx.room.TypeConverter;
|
||||
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
|
@ -37,4 +38,18 @@ public class Converters {
|
|||
public static String stringOf(StreamType streamType) {
|
||||
return streamType.name();
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static Integer integerOf(FeedGroupIcon feedGroupIcon) {
|
||||
return feedGroupIcon.getId();
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static FeedGroupIcon feedGroupIconOf(Integer id) {
|
||||
for (FeedGroupIcon icon : FeedGroupIcon.values()) {
|
||||
if (icon.getId() == id) return icon;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("There's no feed group icon with the id \"" + id + "\"");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,14 +8,14 @@ import android.util.Log;
|
|||
import org.schabi.newpipe.BuildConfig;
|
||||
|
||||
public class Migrations {
|
||||
|
||||
public static final int DB_VER_11_0 = 1;
|
||||
public static final int DB_VER_12_0 = 2;
|
||||
public static final int DB_VER_1 = 1;
|
||||
public static final int DB_VER_2 = 2;
|
||||
public static final int DB_VER_3 = 3;
|
||||
|
||||
public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release");
|
||||
private static final String TAG = Migrations.class.getName();
|
||||
|
||||
public static final Migration MIGRATION_11_12 = new Migration(DB_VER_11_0, DB_VER_12_0) {
|
||||
public static final Migration MIGRATION_1_2 = new Migration(DB_VER_1, DB_VER_2) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
if(DEBUG) {
|
||||
|
@ -71,4 +71,32 @@ public class Migrations {
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_2_3 = new Migration(DB_VER_2, DB_VER_3) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
// Add NOT NULLs and new fields
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS streams_new " +
|
||||
"(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, thumbnail_url TEXT, view_count INTEGER, textual_upload_date TEXT, upload_date INTEGER," +
|
||||
" is_upload_date_approximation INTEGER)");
|
||||
|
||||
database.execSQL("INSERT INTO streams_new (uid, service_id, url, title, stream_type, duration, uploader, thumbnail_url, view_count, textual_upload_date, upload_date, is_upload_date_approximation)"+
|
||||
" SELECT uid, service_id, url, title, stream_type, duration, uploader, thumbnail_url, NULL, NULL, NULL, NULL FROM streams");
|
||||
|
||||
database.execSQL("DROP TABLE streams");
|
||||
database.execSQL("ALTER TABLE streams_new RENAME TO streams");
|
||||
database.execSQL("CREATE UNIQUE INDEX index_streams_service_id_url ON streams (service_id, url)");
|
||||
|
||||
// Tables for feed feature
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS feed (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)");
|
||||
database.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)");
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS feed_group (uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)");
|
||||
database.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)");
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS feed_group_subscription_join (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)");
|
||||
database.execSQL("CREATE INDEX index_feed_group_subscription_join_subscription_id ON feed_group_subscription_join (subscription_id)");
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS feed_last_updated (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)");
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
package org.schabi.newpipe.database.feed.dao
|
||||
|
||||
import androidx.room.*
|
||||
import io.reactivex.Flowable
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import java.util.*
|
||||
|
||||
@Dao
|
||||
abstract class FeedDAO {
|
||||
@Query("DELETE FROM feed")
|
||||
abstract fun deleteAll(): Int
|
||||
|
||||
@Query("""
|
||||
SELECT s.* FROM streams s
|
||||
|
||||
INNER JOIN feed f
|
||||
ON s.uid = f.stream_id
|
||||
|
||||
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
|
||||
|
||||
LIMIT 500
|
||||
""")
|
||||
abstract fun getAllStreams(): Flowable<List<StreamEntity>>
|
||||
|
||||
@Query("""
|
||||
SELECT s.* FROM streams s
|
||||
|
||||
INNER JOIN feed f
|
||||
ON s.uid = f.stream_id
|
||||
|
||||
INNER JOIN feed_group_subscription_join fgs
|
||||
ON fgs.subscription_id = f.subscription_id
|
||||
|
||||
INNER JOIN feed_group fg
|
||||
ON fg.uid = fgs.group_id
|
||||
|
||||
WHERE fgs.group_id = :groupId
|
||||
|
||||
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
|
||||
LIMIT 500
|
||||
""")
|
||||
abstract fun getAllStreamsFromGroup(groupId: Long): Flowable<List<StreamEntity>>
|
||||
|
||||
@Query("""
|
||||
DELETE FROM feed WHERE
|
||||
|
||||
feed.stream_id IN (
|
||||
SELECT s.uid FROM streams s
|
||||
|
||||
INNER JOIN feed f
|
||||
ON s.uid = f.stream_id
|
||||
|
||||
WHERE s.upload_date < :date
|
||||
)
|
||||
""")
|
||||
abstract fun unlinkStreamsOlderThan(date: Date)
|
||||
|
||||
@Query("""
|
||||
DELETE FROM feed
|
||||
|
||||
WHERE feed.subscription_id = :subscriptionId
|
||||
|
||||
AND feed.stream_id IN (
|
||||
SELECT s.uid FROM streams s
|
||||
|
||||
INNER JOIN feed f
|
||||
ON s.uid = f.stream_id
|
||||
|
||||
WHERE s.stream_type = "LIVE_STREAM" OR s.stream_type = "AUDIO_LIVE_STREAM"
|
||||
)
|
||||
""")
|
||||
abstract fun unlinkOldLivestreams(subscriptionId: Long)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract fun insert(feedEntity: FeedEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract fun insertAll(entities: List<FeedEntity>): List<Long>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
internal abstract fun insertLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity): Long
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.IGNORE)
|
||||
internal abstract fun updateLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity)
|
||||
|
||||
@Transaction
|
||||
open fun setLastUpdatedForSubscription(lastUpdatedEntity: FeedLastUpdatedEntity) {
|
||||
val id = insertLastUpdated(lastUpdatedEntity)
|
||||
|
||||
if (id == -1L) {
|
||||
updateLastUpdated(lastUpdatedEntity)
|
||||
}
|
||||
}
|
||||
|
||||
@Query("""
|
||||
SELECT MIN(lu.last_updated) FROM feed_last_updated lu
|
||||
|
||||
INNER JOIN feed_group_subscription_join fgs
|
||||
ON fgs.subscription_id = lu.subscription_id AND fgs.group_id = :groupId
|
||||
""")
|
||||
abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable<List<Date>>
|
||||
|
||||
@Query("SELECT MIN(last_updated) FROM feed_last_updated")
|
||||
abstract fun oldestSubscriptionUpdateFromAll(): Flowable<List<Date>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM feed_last_updated WHERE last_updated IS NULL")
|
||||
abstract fun notLoadedCount(): Flowable<Long>
|
||||
|
||||
@Query("""
|
||||
SELECT COUNT(*) FROM subscriptions s
|
||||
|
||||
INNER JOIN feed_group_subscription_join fgs
|
||||
ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId
|
||||
|
||||
LEFT JOIN feed_last_updated lu
|
||||
ON s.uid = lu.subscription_id
|
||||
|
||||
WHERE lu.last_updated IS NULL
|
||||
""")
|
||||
abstract fun notLoadedCountForGroup(groupId: Long): Flowable<Long>
|
||||
|
||||
@Query("""
|
||||
SELECT s.* FROM subscriptions s
|
||||
|
||||
LEFT JOIN feed_last_updated lu
|
||||
ON s.uid = lu.subscription_id
|
||||
|
||||
WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold
|
||||
""")
|
||||
abstract fun getAllOutdated(outdatedThreshold: Date): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@Query("""
|
||||
SELECT s.* FROM subscriptions s
|
||||
|
||||
INNER JOIN feed_group_subscription_join fgs
|
||||
ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId
|
||||
|
||||
LEFT JOIN feed_last_updated lu
|
||||
ON s.uid = lu.subscription_id
|
||||
|
||||
WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold
|
||||
""")
|
||||
abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: Date): Flowable<List<SubscriptionEntity>>
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package org.schabi.newpipe.database.feed.dao
|
||||
|
||||
import androidx.room.*
|
||||
import io.reactivex.Flowable
|
||||
import io.reactivex.Maybe
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity
|
||||
|
||||
@Dao
|
||||
abstract class FeedGroupDAO {
|
||||
|
||||
@Query("SELECT * FROM feed_group ORDER BY sort_order ASC")
|
||||
abstract fun getAll(): Flowable<List<FeedGroupEntity>>
|
||||
|
||||
@Query("SELECT * FROM feed_group WHERE uid = :groupId")
|
||||
abstract fun getGroup(groupId: Long): Maybe<FeedGroupEntity>
|
||||
|
||||
@Transaction
|
||||
open fun insert(feedGroupEntity: FeedGroupEntity): Long {
|
||||
val nextSortOrder = nextSortOrder()
|
||||
feedGroupEntity.sortOrder = nextSortOrder
|
||||
return insertInternal(feedGroupEntity)
|
||||
}
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract fun update(feedGroupEntity: FeedGroupEntity): Int
|
||||
|
||||
@Query("DELETE FROM feed_group")
|
||||
abstract fun deleteAll(): Int
|
||||
|
||||
@Query("DELETE FROM feed_group WHERE uid = :groupId")
|
||||
abstract fun delete(groupId: Long): Int
|
||||
|
||||
@Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId")
|
||||
abstract fun getSubscriptionIdsFor(groupId: Long): Flowable<List<Long>>
|
||||
|
||||
@Query("DELETE FROM feed_group_subscription_join WHERE group_id = :groupId")
|
||||
abstract fun deleteSubscriptionsFromGroup(groupId: Long): Int
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract fun insertSubscriptionsToGroup(entities: List<FeedGroupSubscriptionEntity>): List<Long>
|
||||
|
||||
@Transaction
|
||||
open fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List<Long>) {
|
||||
deleteSubscriptionsFromGroup(groupId)
|
||||
insertSubscriptionsToGroup(subscriptionIds.map { FeedGroupSubscriptionEntity(groupId, it) })
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open fun updateOrder(orderMap: Map<Long, Long>) {
|
||||
orderMap.forEach { (groupId, sortOrder) -> updateOrder(groupId, sortOrder) }
|
||||
}
|
||||
|
||||
@Query("UPDATE feed_group SET sort_order = :sortOrder WHERE uid = :groupId")
|
||||
abstract fun updateOrder(groupId: Long, sortOrder: Long): Int
|
||||
|
||||
@Query("SELECT IFNULL(MAX(sort_order) + 1, 0) FROM feed_group")
|
||||
protected abstract fun nextSortOrder(): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
protected abstract fun insertInternal(feedGroupEntity: FeedGroupEntity): Long
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package org.schabi.newpipe.database.feed.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.FEED_TABLE
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.STREAM_ID
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.SUBSCRIPTION_ID
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
|
||||
@Entity(tableName = FEED_TABLE,
|
||||
primaryKeys = [STREAM_ID, SUBSCRIPTION_ID],
|
||||
indices = [Index(SUBSCRIPTION_ID)],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = StreamEntity::class,
|
||||
parentColumns = [StreamEntity.STREAM_ID],
|
||||
childColumns = [STREAM_ID],
|
||||
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true),
|
||||
ForeignKey(
|
||||
entity = SubscriptionEntity::class,
|
||||
parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
|
||||
childColumns = [SUBSCRIPTION_ID],
|
||||
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true)
|
||||
]
|
||||
)
|
||||
data class FeedEntity(
|
||||
@ColumnInfo(name = STREAM_ID)
|
||||
var streamId: Long,
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_ID)
|
||||
var subscriptionId: Long
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val FEED_TABLE = "feed"
|
||||
|
||||
const val STREAM_ID = "stream_id"
|
||||
const val SUBSCRIPTION_ID = "subscription_id"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package org.schabi.newpipe.database.feed.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.FEED_GROUP_TABLE
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.SORT_ORDER
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||
|
||||
@Entity(
|
||||
tableName = FEED_GROUP_TABLE,
|
||||
indices = [Index(SORT_ORDER)]
|
||||
)
|
||||
data class FeedGroupEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = ID)
|
||||
val uid: Long,
|
||||
|
||||
@ColumnInfo(name = NAME)
|
||||
var name: String,
|
||||
|
||||
@ColumnInfo(name = ICON)
|
||||
var icon: FeedGroupIcon,
|
||||
|
||||
@ColumnInfo(name = SORT_ORDER)
|
||||
var sortOrder: Long = -1
|
||||
) {
|
||||
companion object {
|
||||
const val FEED_GROUP_TABLE = "feed_group"
|
||||
|
||||
const val ID = "uid"
|
||||
const val NAME = "name"
|
||||
const val ICON = "icon_id"
|
||||
const val SORT_ORDER = "sort_order"
|
||||
|
||||
const val GROUP_ALL_ID = -1L
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package org.schabi.newpipe.database.feed.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.CASCADE
|
||||
import androidx.room.Index
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.FEED_GROUP_SUBSCRIPTION_TABLE
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.GROUP_ID
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.SUBSCRIPTION_ID
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
|
||||
@Entity(
|
||||
tableName = FEED_GROUP_SUBSCRIPTION_TABLE,
|
||||
primaryKeys = [GROUP_ID, SUBSCRIPTION_ID],
|
||||
indices = [Index(SUBSCRIPTION_ID)],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = FeedGroupEntity::class,
|
||||
parentColumns = [FeedGroupEntity.ID],
|
||||
childColumns = [GROUP_ID],
|
||||
onDelete = CASCADE, onUpdate = CASCADE, deferred = true),
|
||||
|
||||
ForeignKey(
|
||||
entity = SubscriptionEntity::class,
|
||||
parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
|
||||
childColumns = [SUBSCRIPTION_ID],
|
||||
onDelete = CASCADE, onUpdate = CASCADE, deferred = true)
|
||||
]
|
||||
)
|
||||
data class FeedGroupSubscriptionEntity(
|
||||
@ColumnInfo(name = GROUP_ID)
|
||||
var feedGroupId: Long,
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_ID)
|
||||
var subscriptionId: Long
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val FEED_GROUP_SUBSCRIPTION_TABLE = "feed_group_subscription_join"
|
||||
|
||||
const val GROUP_ID = "group_id"
|
||||
const val SUBSCRIPTION_ID = "subscription_id"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package org.schabi.newpipe.database.feed.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.FEED_LAST_UPDATED_TABLE
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.SUBSCRIPTION_ID
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import java.util.*
|
||||
|
||||
@Entity(
|
||||
tableName = FEED_LAST_UPDATED_TABLE,
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = SubscriptionEntity::class,
|
||||
parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
|
||||
childColumns = [SUBSCRIPTION_ID],
|
||||
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true)
|
||||
]
|
||||
)
|
||||
data class FeedLastUpdatedEntity(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = SUBSCRIPTION_ID)
|
||||
var subscriptionId: Long,
|
||||
|
||||
@ColumnInfo(name = LAST_UPDATED)
|
||||
var lastUpdated: Date? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val FEED_LAST_UPDATED_TABLE = "feed_last_updated"
|
||||
|
||||
const val SUBSCRIPTION_ID = "subscription_id"
|
||||
const val LAST_UPDATED = "last_updated"
|
||||
}
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
package org.schabi.newpipe.database.history.model;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public class StreamHistoryEntry {
|
||||
@ColumnInfo(name = StreamEntity.STREAM_ID)
|
||||
final public long uid;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_SERVICE_ID)
|
||||
final public int serviceId;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_URL)
|
||||
final public String url;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_TITLE)
|
||||
final public String title;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_TYPE)
|
||||
final public StreamType streamType;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_DURATION)
|
||||
final public long duration;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_UPLOADER)
|
||||
final public String uploader;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_THUMBNAIL_URL)
|
||||
final public String thumbnailUrl;
|
||||
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
|
||||
final public long streamId;
|
||||
@ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE)
|
||||
final public Date accessDate;
|
||||
@ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT)
|
||||
final public long repeatCount;
|
||||
|
||||
public StreamHistoryEntry(long uid, int serviceId, String url, String title,
|
||||
StreamType streamType, long duration, String uploader,
|
||||
String thumbnailUrl, long streamId, Date accessDate,
|
||||
long repeatCount) {
|
||||
this.uid = uid;
|
||||
this.serviceId = serviceId;
|
||||
this.url = url;
|
||||
this.title = title;
|
||||
this.streamType = streamType;
|
||||
this.duration = duration;
|
||||
this.uploader = uploader;
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
this.streamId = streamId;
|
||||
this.accessDate = accessDate;
|
||||
this.repeatCount = repeatCount;
|
||||
}
|
||||
|
||||
public StreamHistoryEntity toStreamHistoryEntity() {
|
||||
return new StreamHistoryEntity(streamId, accessDate, repeatCount);
|
||||
}
|
||||
|
||||
public boolean hasEqualValues(StreamHistoryEntry other) {
|
||||
return this.uid == other.uid && streamId == other.streamId &&
|
||||
accessDate.compareTo(other.accessDate) == 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package org.schabi.newpipe.database.history.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import java.util.*
|
||||
|
||||
data class StreamHistoryEntry(
|
||||
@Embedded
|
||||
val streamEntity: StreamEntity,
|
||||
|
||||
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
|
||||
val streamId: Long,
|
||||
|
||||
@ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE)
|
||||
val accessDate: Date,
|
||||
|
||||
@ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT)
|
||||
val repeatCount: Long
|
||||
) {
|
||||
|
||||
fun toStreamHistoryEntity(): StreamHistoryEntity {
|
||||
return StreamHistoryEntity(streamId, accessDate, repeatCount)
|
||||
}
|
||||
|
||||
fun hasEqualValues(other: StreamHistoryEntry): Boolean {
|
||||
return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId &&
|
||||
accessDate.compareTo(other.accessDate) == 0
|
||||
}
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
package org.schabi.newpipe.database.playlist;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
|
||||
public class PlaylistStreamEntry implements LocalItem {
|
||||
@ColumnInfo(name = StreamEntity.STREAM_ID)
|
||||
final public long uid;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_SERVICE_ID)
|
||||
final public int serviceId;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_URL)
|
||||
final public String url;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_TITLE)
|
||||
final public String title;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_TYPE)
|
||||
final public StreamType streamType;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_DURATION)
|
||||
final public long duration;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_UPLOADER)
|
||||
final public String uploader;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_THUMBNAIL_URL)
|
||||
final public String thumbnailUrl;
|
||||
@ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID)
|
||||
final public long streamId;
|
||||
@ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX)
|
||||
final public int joinIndex;
|
||||
|
||||
public PlaylistStreamEntry(long uid, int serviceId, String url, String title,
|
||||
StreamType streamType, long duration, String uploader,
|
||||
String thumbnailUrl, long streamId, int joinIndex) {
|
||||
this.uid = uid;
|
||||
this.serviceId = serviceId;
|
||||
this.url = url;
|
||||
this.title = title;
|
||||
this.streamType = streamType;
|
||||
this.duration = duration;
|
||||
this.uploader = uploader;
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
this.streamId = streamId;
|
||||
this.joinIndex = joinIndex;
|
||||
}
|
||||
|
||||
public StreamInfoItem toStreamInfoItem() throws IllegalArgumentException {
|
||||
StreamInfoItem item = new StreamInfoItem(serviceId, url, title, streamType);
|
||||
item.setThumbnailUrl(thumbnailUrl);
|
||||
item.setUploaderName(uploader);
|
||||
item.setDuration(duration);
|
||||
return item;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalItemType getLocalItemType() {
|
||||
return LocalItemType.PLAYLIST_STREAM_ITEM;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package org.schabi.newpipe.database.playlist
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import org.schabi.newpipe.database.LocalItem
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
|
||||
class PlaylistStreamEntry(
|
||||
@Embedded
|
||||
val streamEntity: StreamEntity,
|
||||
|
||||
@ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID)
|
||||
val streamId: Long,
|
||||
|
||||
@ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX)
|
||||
val joinIndex: Int
|
||||
) : LocalItem {
|
||||
|
||||
@Throws(IllegalArgumentException::class)
|
||||
fun toStreamInfoItem(): StreamInfoItem {
|
||||
val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType)
|
||||
item.duration = streamEntity.duration
|
||||
item.uploaderName = streamEntity.uploader
|
||||
item.thumbnailUrl = streamEntity.thumbnailUrl
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
override fun getLocalItemType(): LocalItem.LocalItemType {
|
||||
return LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM
|
||||
}
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
package org.schabi.newpipe.database.stream;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public class StreamStatisticsEntry implements LocalItem {
|
||||
final public static String STREAM_LATEST_DATE = "latestAccess";
|
||||
final public static String STREAM_WATCH_COUNT = "watchCount";
|
||||
|
||||
@ColumnInfo(name = StreamEntity.STREAM_ID)
|
||||
final public long uid;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_SERVICE_ID)
|
||||
final public int serviceId;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_URL)
|
||||
final public String url;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_TITLE)
|
||||
final public String title;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_TYPE)
|
||||
final public StreamType streamType;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_DURATION)
|
||||
final public long duration;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_UPLOADER)
|
||||
final public String uploader;
|
||||
@ColumnInfo(name = StreamEntity.STREAM_THUMBNAIL_URL)
|
||||
final public String thumbnailUrl;
|
||||
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
|
||||
final public long streamId;
|
||||
@ColumnInfo(name = StreamStatisticsEntry.STREAM_LATEST_DATE)
|
||||
final public Date latestAccessDate;
|
||||
@ColumnInfo(name = StreamStatisticsEntry.STREAM_WATCH_COUNT)
|
||||
final public long watchCount;
|
||||
|
||||
public StreamStatisticsEntry(long uid, int serviceId, String url, String title,
|
||||
StreamType streamType, long duration, String uploader,
|
||||
String thumbnailUrl, long streamId, Date latestAccessDate,
|
||||
long watchCount) {
|
||||
this.uid = uid;
|
||||
this.serviceId = serviceId;
|
||||
this.url = url;
|
||||
this.title = title;
|
||||
this.streamType = streamType;
|
||||
this.duration = duration;
|
||||
this.uploader = uploader;
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
this.streamId = streamId;
|
||||
this.latestAccessDate = latestAccessDate;
|
||||
this.watchCount = watchCount;
|
||||
}
|
||||
|
||||
public StreamInfoItem toStreamInfoItem() {
|
||||
StreamInfoItem item = new StreamInfoItem(serviceId, url, title, streamType);
|
||||
item.setDuration(duration);
|
||||
item.setUploaderName(uploader);
|
||||
item.setThumbnailUrl(thumbnailUrl);
|
||||
return item;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalItemType getLocalItemType() {
|
||||
return LocalItemType.STATISTIC_STREAM_ITEM;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package org.schabi.newpipe.database.stream
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import org.schabi.newpipe.database.LocalItem
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import java.util.*
|
||||
|
||||
class StreamStatisticsEntry(
|
||||
@Embedded
|
||||
val streamEntity: StreamEntity,
|
||||
|
||||
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
|
||||
val streamId: Long,
|
||||
|
||||
@ColumnInfo(name = STREAM_LATEST_DATE)
|
||||
val latestAccessDate: Date,
|
||||
|
||||
@ColumnInfo(name = STREAM_WATCH_COUNT)
|
||||
val watchCount: Long
|
||||
) : LocalItem {
|
||||
|
||||
fun toStreamInfoItem(): StreamInfoItem {
|
||||
val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType)
|
||||
item.duration = streamEntity.duration
|
||||
item.uploaderName = streamEntity.uploader
|
||||
item.thumbnailUrl = streamEntity.thumbnailUrl
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
override fun getLocalItemType(): LocalItem.LocalItemType {
|
||||
return LocalItem.LocalItemType.STATISTIC_STREAM_ITEM
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val STREAM_LATEST_DATE = "latestAccess"
|
||||
const val STREAM_WATCH_COUNT = "watchCount"
|
||||
}
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
package org.schabi.newpipe.database.stream.dao;
|
||||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Insert;
|
||||
import androidx.room.OnConflictStrategy;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.Transaction;
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.Flowable;
|
||||
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_SERVICE_ID;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL;
|
||||
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE;
|
||||
|
||||
@Dao
|
||||
public abstract class StreamDAO implements BasicDAO<StreamEntity> {
|
||||
@Override
|
||||
@Query("SELECT * FROM " + STREAM_TABLE)
|
||||
public abstract Flowable<List<StreamEntity>> getAll();
|
||||
|
||||
@Override
|
||||
@Query("DELETE FROM " + STREAM_TABLE)
|
||||
public abstract int deleteAll();
|
||||
|
||||
@Override
|
||||
@Query("SELECT * FROM " + STREAM_TABLE + " WHERE " + STREAM_SERVICE_ID + " = :serviceId")
|
||||
public abstract Flowable<List<StreamEntity>> listByService(int serviceId);
|
||||
|
||||
@Query("SELECT * FROM " + STREAM_TABLE + " WHERE " +
|
||||
STREAM_URL + " = :url AND " +
|
||||
STREAM_SERVICE_ID + " = :serviceId")
|
||||
public abstract Flowable<List<StreamEntity>> getStream(long serviceId, String url);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract void silentInsertAllInternal(final List<StreamEntity> streams);
|
||||
|
||||
@Query("SELECT " + STREAM_ID + " FROM " + STREAM_TABLE + " WHERE " +
|
||||
STREAM_URL + " = :url AND " +
|
||||
STREAM_SERVICE_ID + " = :serviceId")
|
||||
abstract Long getStreamIdInternal(long serviceId, String url);
|
||||
|
||||
@Transaction
|
||||
public long upsert(StreamEntity stream) {
|
||||
final Long streamIdCandidate = getStreamIdInternal(stream.getServiceId(), stream.getUrl());
|
||||
|
||||
if (streamIdCandidate == null) {
|
||||
return insert(stream);
|
||||
} else {
|
||||
stream.setUid(streamIdCandidate);
|
||||
update(stream);
|
||||
return streamIdCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
public List<Long> upsertAll(List<StreamEntity> streams) {
|
||||
silentInsertAllInternal(streams);
|
||||
|
||||
final List<Long> streamIds = new ArrayList<>(streams.size());
|
||||
for (StreamEntity stream : streams) {
|
||||
final Long streamId = getStreamIdInternal(stream.getServiceId(), stream.getUrl());
|
||||
if (streamId == null) {
|
||||
throw new IllegalStateException("StreamID cannot be null just after insertion.");
|
||||
}
|
||||
|
||||
streamIds.add(streamId);
|
||||
stream.setUid(streamId);
|
||||
}
|
||||
|
||||
update(streams);
|
||||
return streamIds;
|
||||
}
|
||||
|
||||
@Query("DELETE FROM " + STREAM_TABLE + " WHERE " + STREAM_ID +
|
||||
" NOT IN " +
|
||||
"(SELECT DISTINCT " + STREAM_ID + " FROM " + STREAM_TABLE +
|
||||
|
||||
" LEFT JOIN " + STREAM_HISTORY_TABLE +
|
||||
" ON " + STREAM_ID + " = " +
|
||||
StreamHistoryEntity.STREAM_HISTORY_TABLE + "." + StreamHistoryEntity.JOIN_STREAM_ID +
|
||||
|
||||
" LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE +
|
||||
" ON " + STREAM_ID + " = " +
|
||||
PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE + "." + PlaylistStreamEntity.JOIN_STREAM_ID +
|
||||
")")
|
||||
public abstract int deleteOrphans();
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
package org.schabi.newpipe.database.stream.dao
|
||||
|
||||
import androidx.room.*
|
||||
import io.reactivex.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM
|
||||
import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
@Dao
|
||||
abstract class StreamDAO : BasicDAO<StreamEntity> {
|
||||
@Query("SELECT * FROM streams")
|
||||
abstract override fun getAll(): Flowable<List<StreamEntity>>
|
||||
|
||||
@Query("DELETE FROM streams")
|
||||
abstract override fun deleteAll(): Int
|
||||
|
||||
@Query("SELECT * FROM streams WHERE service_id = :serviceId")
|
||||
abstract override fun listByService(serviceId: Int): Flowable<List<StreamEntity>>
|
||||
|
||||
@Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
|
||||
abstract fun getStream(serviceId: Long, url: String): Flowable<List<StreamEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
internal abstract fun silentInsertInternal(stream: StreamEntity): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long>
|
||||
|
||||
@Query("""
|
||||
SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration
|
||||
FROM streams WHERE url = :url AND service_id = :serviceId
|
||||
""")
|
||||
internal abstract fun getMinimalStreamForCompare(serviceId: Int, url: String): StreamCompareFeed?
|
||||
|
||||
@Transaction
|
||||
open fun upsert(newerStream: StreamEntity): Long {
|
||||
val uid = silentInsertInternal(newerStream)
|
||||
|
||||
if (uid != -1L) {
|
||||
newerStream.uid = uid
|
||||
return uid
|
||||
}
|
||||
|
||||
compareAndUpdateStream(newerStream)
|
||||
|
||||
update(newerStream)
|
||||
return newerStream.uid
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open fun upsertAll(streams: List<StreamEntity>): List<Long> {
|
||||
val insertUidList = silentInsertAllInternal(streams)
|
||||
|
||||
val streamIds = ArrayList<Long>(streams.size)
|
||||
for ((index, uid) in insertUidList.withIndex()) {
|
||||
val newerStream = streams[index]
|
||||
if (uid != -1L) {
|
||||
streamIds.add(uid)
|
||||
newerStream.uid = uid
|
||||
continue
|
||||
}
|
||||
|
||||
compareAndUpdateStream(newerStream)
|
||||
streamIds.add(newerStream.uid)
|
||||
}
|
||||
|
||||
update(streams)
|
||||
return streamIds
|
||||
}
|
||||
|
||||
private fun compareAndUpdateStream(newerStream: StreamEntity) {
|
||||
val existentMinimalStream = getMinimalStreamForCompare(newerStream.serviceId, newerStream.url)
|
||||
?: throw IllegalStateException("Stream cannot be null just after insertion.")
|
||||
newerStream.uid = existentMinimalStream.uid
|
||||
|
||||
val isNewerStreamLive = newerStream.streamType == AUDIO_LIVE_STREAM || newerStream.streamType == LIVE_STREAM
|
||||
if (!isNewerStreamLive) {
|
||||
if (existentMinimalStream.uploadDate != null && existentMinimalStream.isUploadDateApproximation != true) {
|
||||
newerStream.uploadDate = existentMinimalStream.uploadDate
|
||||
newerStream.textualUploadDate = existentMinimalStream.textualUploadDate
|
||||
newerStream.isUploadDateApproximation = existentMinimalStream.isUploadDateApproximation
|
||||
}
|
||||
|
||||
if (existentMinimalStream.duration > 0 && newerStream.duration < 0) {
|
||||
newerStream.duration = existentMinimalStream.duration
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Query("""
|
||||
DELETE FROM streams WHERE
|
||||
|
||||
NOT EXISTS (SELECT 1 FROM stream_history sh
|
||||
WHERE sh.stream_id = streams.uid)
|
||||
|
||||
AND NOT EXISTS (SELECT 1 FROM playlist_stream_join ps
|
||||
WHERE ps.stream_id = streams.uid)
|
||||
|
||||
AND NOT EXISTS (SELECT 1 FROM feed f
|
||||
WHERE f.stream_id = streams.uid)
|
||||
""")
|
||||
abstract fun deleteOrphans(): Int
|
||||
|
||||
/**
|
||||
* Minimal entry class used when comparing/updating an existent stream.
|
||||
*/
|
||||
internal data class StreamCompareFeed(
|
||||
@ColumnInfo(name = STREAM_ID)
|
||||
var uid: Long = 0,
|
||||
|
||||
@ColumnInfo(name = StreamEntity.STREAM_TYPE)
|
||||
var streamType: StreamType,
|
||||
|
||||
@ColumnInfo(name = StreamEntity.STREAM_TEXTUAL_UPLOAD_DATE)
|
||||
var textualUploadDate: String? = null,
|
||||
|
||||
@ColumnInfo(name = StreamEntity.STREAM_UPLOAD_DATE)
|
||||
var uploadDate: Date? = null,
|
||||
|
||||
@ColumnInfo(name = StreamEntity.STREAM_IS_UPLOAD_DATE_APPROXIMATION)
|
||||
var isUploadDateApproximation: Boolean? = null,
|
||||
|
||||
@ColumnInfo(name = StreamEntity.STREAM_DURATION)
|
||||
var duration: Long)
|
||||
}
|
|
@ -1,153 +0,0 @@
|
|||
package org.schabi.newpipe.database.stream.model;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.Ignore;
|
||||
import androidx.room.Index;
|
||||
import androidx.room.PrimaryKey;
|
||||
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_SERVICE_ID;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL;
|
||||
|
||||
@Entity(tableName = STREAM_TABLE,
|
||||
indices = {@Index(value = {STREAM_SERVICE_ID, STREAM_URL}, unique = true)})
|
||||
public class StreamEntity implements Serializable {
|
||||
|
||||
final public static String STREAM_TABLE = "streams";
|
||||
final public static String STREAM_ID = "uid";
|
||||
final public static String STREAM_SERVICE_ID = "service_id";
|
||||
final public static String STREAM_URL = "url";
|
||||
final public static String STREAM_TITLE = "title";
|
||||
final public static String STREAM_TYPE = "stream_type";
|
||||
final public static String STREAM_DURATION = "duration";
|
||||
final public static String STREAM_UPLOADER = "uploader";
|
||||
final public static String STREAM_THUMBNAIL_URL = "thumbnail_url";
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = STREAM_ID)
|
||||
private long uid = 0;
|
||||
|
||||
@ColumnInfo(name = STREAM_SERVICE_ID)
|
||||
private int serviceId = Constants.NO_SERVICE_ID;
|
||||
|
||||
@ColumnInfo(name = STREAM_URL)
|
||||
private String url;
|
||||
|
||||
@ColumnInfo(name = STREAM_TITLE)
|
||||
private String title;
|
||||
|
||||
@ColumnInfo(name = STREAM_TYPE)
|
||||
private StreamType streamType;
|
||||
|
||||
@ColumnInfo(name = STREAM_DURATION)
|
||||
private Long duration;
|
||||
|
||||
@ColumnInfo(name = STREAM_UPLOADER)
|
||||
private String uploader;
|
||||
|
||||
@ColumnInfo(name = STREAM_THUMBNAIL_URL)
|
||||
private String thumbnailUrl;
|
||||
|
||||
public StreamEntity(final int serviceId, final String title, final String url,
|
||||
final StreamType streamType, final String thumbnailUrl, final String uploader,
|
||||
final long duration) {
|
||||
this.serviceId = serviceId;
|
||||
this.title = title;
|
||||
this.url = url;
|
||||
this.streamType = streamType;
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
this.uploader = uploader;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public StreamEntity(final StreamInfoItem item) {
|
||||
this(item.getServiceId(), item.getName(), item.getUrl(), item.getStreamType(), item.getThumbnailUrl(),
|
||||
item.getUploaderName(), item.getDuration());
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public StreamEntity(final StreamInfo info) {
|
||||
this(info.getServiceId(), info.getName(), info.getUrl(), info.getStreamType(), info.getThumbnailUrl(),
|
||||
info.getUploaderName(), info.getDuration());
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public StreamEntity(final PlayQueueItem item) {
|
||||
this(item.getServiceId(), item.getTitle(), item.getUrl(), item.getStreamType(),
|
||||
item.getThumbnailUrl(), item.getUploader(), item.getDuration());
|
||||
}
|
||||
|
||||
public long getUid() {
|
||||
return uid;
|
||||
}
|
||||
|
||||
public void setUid(long uid) {
|
||||
this.uid = uid;
|
||||
}
|
||||
|
||||
public int getServiceId() {
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
public void setServiceId(int serviceId) {
|
||||
this.serviceId = serviceId;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public StreamType getStreamType() {
|
||||
return streamType;
|
||||
}
|
||||
|
||||
public void setStreamType(StreamType type) {
|
||||
this.streamType = type;
|
||||
}
|
||||
|
||||
public Long getDuration() {
|
||||
return duration;
|
||||
}
|
||||
|
||||
public void setDuration(Long duration) {
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
public String getUploader() {
|
||||
return uploader;
|
||||
}
|
||||
|
||||
public void setUploader(String uploader) {
|
||||
this.uploader = uploader;
|
||||
}
|
||||
|
||||
public String getThumbnailUrl() {
|
||||
return thumbnailUrl;
|
||||
}
|
||||
|
||||
public void setThumbnailUrl(String thumbnailUrl) {
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
package org.schabi.newpipe.database.stream.model
|
||||
|
||||
import androidx.room.*
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_SERVICE_ID
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_TABLE
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_URL
|
||||
import org.schabi.newpipe.extractor.localization.DateWrapper
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem
|
||||
import java.io.Serializable
|
||||
import java.util.*
|
||||
|
||||
@Entity(tableName = STREAM_TABLE,
|
||||
indices = [
|
||||
Index(value = [STREAM_SERVICE_ID, STREAM_URL], unique = true)
|
||||
]
|
||||
)
|
||||
data class StreamEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = STREAM_ID)
|
||||
var uid: Long = 0,
|
||||
|
||||
@ColumnInfo(name = STREAM_SERVICE_ID)
|
||||
var serviceId: Int,
|
||||
|
||||
@ColumnInfo(name = STREAM_URL)
|
||||
var url: String,
|
||||
|
||||
@ColumnInfo(name = STREAM_TITLE)
|
||||
var title: String,
|
||||
|
||||
@ColumnInfo(name = STREAM_TYPE)
|
||||
var streamType: StreamType,
|
||||
|
||||
@ColumnInfo(name = STREAM_DURATION)
|
||||
var duration: Long,
|
||||
|
||||
@ColumnInfo(name = STREAM_UPLOADER)
|
||||
var uploader: String,
|
||||
|
||||
@ColumnInfo(name = STREAM_THUMBNAIL_URL)
|
||||
var thumbnailUrl: String? = null,
|
||||
|
||||
@ColumnInfo(name = STREAM_VIEWS)
|
||||
var viewCount: Long? = null,
|
||||
|
||||
@ColumnInfo(name = STREAM_TEXTUAL_UPLOAD_DATE)
|
||||
var textualUploadDate: String? = null,
|
||||
|
||||
@ColumnInfo(name = STREAM_UPLOAD_DATE)
|
||||
var uploadDate: Date? = null,
|
||||
|
||||
@ColumnInfo(name = STREAM_IS_UPLOAD_DATE_APPROXIMATION)
|
||||
var isUploadDateApproximation: Boolean? = null
|
||||
) : Serializable {
|
||||
|
||||
@Ignore
|
||||
constructor(item: StreamInfoItem) : this(
|
||||
serviceId = item.serviceId, url = item.url, title = item.name,
|
||||
streamType = item.streamType, duration = item.duration, uploader = item.uploaderName,
|
||||
thumbnailUrl = item.thumbnailUrl, viewCount = item.viewCount,
|
||||
textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.date()?.time,
|
||||
isUploadDateApproximation = item.uploadDate?.isApproximation
|
||||
)
|
||||
|
||||
@Ignore
|
||||
constructor(info: StreamInfo) : this(
|
||||
serviceId = info.serviceId, url = info.url, title = info.name,
|
||||
streamType = info.streamType, duration = info.duration, uploader = info.uploaderName,
|
||||
thumbnailUrl = info.thumbnailUrl, viewCount = info.viewCount,
|
||||
textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.date()?.time,
|
||||
isUploadDateApproximation = info.uploadDate?.isApproximation
|
||||
)
|
||||
|
||||
@Ignore
|
||||
constructor(item: PlayQueueItem) : this(
|
||||
serviceId = item.serviceId, url = item.url, title = item.title,
|
||||
streamType = item.streamType, duration = item.duration, uploader = item.uploader,
|
||||
thumbnailUrl = item.thumbnailUrl
|
||||
)
|
||||
|
||||
fun toStreamInfoItem(): StreamInfoItem {
|
||||
val item = StreamInfoItem(serviceId, url, title, streamType)
|
||||
item.duration = duration
|
||||
item.uploaderName = uploader
|
||||
item.thumbnailUrl = thumbnailUrl
|
||||
|
||||
if (viewCount != null) item.viewCount = viewCount as Long
|
||||
item.textualUploadDate = textualUploadDate
|
||||
item.uploadDate = uploadDate?.let {
|
||||
DateWrapper(Calendar.getInstance().apply { time = it }, isUploadDateApproximation ?: false)
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val STREAM_TABLE = "streams"
|
||||
const val STREAM_ID = "uid"
|
||||
const val STREAM_SERVICE_ID = "service_id"
|
||||
const val STREAM_URL = "url"
|
||||
const val STREAM_TITLE = "title"
|
||||
const val STREAM_TYPE = "stream_type"
|
||||
const val STREAM_DURATION = "duration"
|
||||
const val STREAM_UPLOADER = "uploader"
|
||||
const val STREAM_THUMBNAIL_URL = "thumbnail_url"
|
||||
|
||||
const val STREAM_VIEWS = "view_count"
|
||||
const val STREAM_TEXTUAL_UPLOAD_DATE = "textual_upload_date"
|
||||
const val STREAM_UPLOAD_DATE = "upload_date"
|
||||
const val STREAM_IS_UPLOAD_DATE_APPROXIMATION = "is_upload_date_approximation"
|
||||
}
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
package org.schabi.newpipe.database.subscription;
|
||||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Insert;
|
||||
import androidx.room.OnConflictStrategy;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.Transaction;
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.Flowable;
|
||||
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_UID;
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL;
|
||||
|
||||
@Dao
|
||||
public abstract class SubscriptionDAO implements BasicDAO<SubscriptionEntity> {
|
||||
@Override
|
||||
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE)
|
||||
public abstract Flowable<List<SubscriptionEntity>> getAll();
|
||||
|
||||
@Override
|
||||
@Query("DELETE FROM " + SUBSCRIPTION_TABLE)
|
||||
public abstract int deleteAll();
|
||||
|
||||
@Override
|
||||
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + SUBSCRIPTION_SERVICE_ID + " = :serviceId")
|
||||
public abstract Flowable<List<SubscriptionEntity>> listByService(int serviceId);
|
||||
|
||||
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " +
|
||||
SUBSCRIPTION_URL + " LIKE :url AND " +
|
||||
SUBSCRIPTION_SERVICE_ID + " = :serviceId")
|
||||
public abstract Flowable<List<SubscriptionEntity>> getSubscription(int serviceId, String url);
|
||||
|
||||
@Query("SELECT " + SUBSCRIPTION_UID + " FROM " + SUBSCRIPTION_TABLE + " WHERE " +
|
||||
SUBSCRIPTION_URL + " LIKE :url AND " +
|
||||
SUBSCRIPTION_SERVICE_ID + " = :serviceId")
|
||||
abstract Long getSubscriptionIdInternal(int serviceId, String url);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract Long insertInternal(final SubscriptionEntity entities);
|
||||
|
||||
@Transaction
|
||||
public List<SubscriptionEntity> upsertAll(List<SubscriptionEntity> entities) {
|
||||
for (SubscriptionEntity entity : entities) {
|
||||
Long uid = insertInternal(entity);
|
||||
|
||||
if (uid != -1) {
|
||||
entity.setUid(uid);
|
||||
continue;
|
||||
}
|
||||
|
||||
uid = getSubscriptionIdInternal(entity.getServiceId(), entity.getUrl());
|
||||
entity.setUid(uid);
|
||||
|
||||
if (uid == -1) {
|
||||
throw new IllegalStateException("Invalid subscription id (-1)");
|
||||
}
|
||||
|
||||
update(entity);
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package org.schabi.newpipe.database.subscription
|
||||
|
||||
import androidx.room.*
|
||||
import io.reactivex.Flowable
|
||||
import io.reactivex.Maybe
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
|
||||
@Dao
|
||||
abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||
@Query("SELECT COUNT(*) FROM subscriptions")
|
||||
abstract fun rowCount(): Flowable<Long>
|
||||
|
||||
@Query("SELECT * FROM subscriptions WHERE service_id = :serviceId")
|
||||
abstract override fun listByService(serviceId: Int): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC")
|
||||
abstract override fun getAll(): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
|
||||
abstract fun getSubscriptionFlowable(serviceId: Int, url: String): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
|
||||
abstract fun getSubscription(serviceId: Int, url: String): Maybe<SubscriptionEntity>
|
||||
|
||||
@Query("SELECT * FROM subscriptions WHERE uid = :subscriptionId")
|
||||
abstract fun getSubscription(subscriptionId: Long): SubscriptionEntity
|
||||
|
||||
@Query("DELETE FROM subscriptions")
|
||||
abstract override fun deleteAll(): Int
|
||||
|
||||
@Query("DELETE FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
|
||||
abstract fun deleteSubscription(serviceId: Int, url: String): Int
|
||||
|
||||
@Query("SELECT uid FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
|
||||
internal abstract fun getSubscriptionIdInternal(serviceId: Int, url: String): Long?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long>
|
||||
|
||||
@Transaction
|
||||
open fun upsertAll(entities: List<SubscriptionEntity>): List<SubscriptionEntity> {
|
||||
val insertUidList = silentInsertAllInternal(entities)
|
||||
|
||||
insertUidList.forEachIndexed { index: Int, uidFromInsert: Long ->
|
||||
val entity = entities[index]
|
||||
|
||||
if (uidFromInsert != -1L) {
|
||||
entity.uid = uidFromInsert
|
||||
} else {
|
||||
val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url)
|
||||
?: throw IllegalStateException("Subscription cannot be null just after insertion.")
|
||||
entity.uid = subscriptionIdFromDb
|
||||
|
||||
update(entity)
|
||||
}
|
||||
}
|
||||
|
||||
return entities
|
||||
}
|
||||
}
|
|
@ -19,14 +19,14 @@ import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCR
|
|||
indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)})
|
||||
public class SubscriptionEntity {
|
||||
|
||||
final static String SUBSCRIPTION_UID = "uid";
|
||||
final static String SUBSCRIPTION_TABLE = "subscriptions";
|
||||
final static String SUBSCRIPTION_SERVICE_ID = "service_id";
|
||||
final static String SUBSCRIPTION_URL = "url";
|
||||
final static String SUBSCRIPTION_NAME = "name";
|
||||
final static String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
||||
final static String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
||||
final static String SUBSCRIPTION_DESCRIPTION = "description";
|
||||
public static final String SUBSCRIPTION_UID = "uid";
|
||||
public static final String SUBSCRIPTION_TABLE = "subscriptions";
|
||||
public static final String SUBSCRIPTION_SERVICE_ID = "service_id";
|
||||
public static final String SUBSCRIPTION_URL = "url";
|
||||
public static final String SUBSCRIPTION_NAME = "name";
|
||||
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
||||
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
||||
public static final String SUBSCRIPTION_DESCRIPTION = "description";
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
private long uid = 0;
|
||||
|
|
|
@ -1246,12 +1246,22 @@ public class VideoDetailFragment
|
|||
final boolean playbackResumeEnabled =
|
||||
prefs.getBoolean(activity.getString(R.string.enable_watch_history_key), true)
|
||||
&& prefs.getBoolean(activity.getString(R.string.enable_playback_resume_key), true);
|
||||
|
||||
if (!playbackResumeEnabled || info.getDuration() <= 0) {
|
||||
positionView.setVisibility(View.INVISIBLE);
|
||||
detailPositionView.setVisibility(View.GONE);
|
||||
return;
|
||||
|
||||
// TODO: Remove this check when separation of concerns is done.
|
||||
// (live streams weren't getting updated because they are mixed)
|
||||
if (!info.getStreamType().equals(StreamType.LIVE_STREAM) &&
|
||||
!info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext());
|
||||
|
||||
// TODO: Separate concerns when updating database data.
|
||||
// (move the updating part to when the loading happens)
|
||||
positionSubscriber = recordManager.loadStreamState(info)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.onErrorComplete()
|
||||
|
|
|
@ -59,7 +59,10 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
@Override
|
||||
public void onAttach(Context context) {
|
||||
super.onAttach(context);
|
||||
infoListAdapter = new InfoListAdapter(activity);
|
||||
|
||||
if (infoListAdapter == null) {
|
||||
infoListAdapter = new InfoListAdapter(activity);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -78,7 +81,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
StateSaver.onDestroy(savedState);
|
||||
if (useDefaultStateSaving) StateSaver.onDestroy(savedState);
|
||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
.unregisterOnSharedPreferenceChangeListener(this);
|
||||
}
|
||||
|
@ -103,6 +106,16 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
protected StateSaver.SavedState savedState;
|
||||
protected boolean useDefaultStateSaving = true;
|
||||
|
||||
/**
|
||||
* If the default implementation of {@link StateSaver.WriteRead} should be used.
|
||||
*
|
||||
* @see StateSaver
|
||||
*/
|
||||
public void useDefaultStateSaving(boolean useDefault) {
|
||||
this.useDefaultStateSaving = useDefault;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateSuffix() {
|
||||
|
@ -112,26 +125,28 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
|
||||
@Override
|
||||
public void writeTo(Queue<Object> objectsToSave) {
|
||||
objectsToSave.add(infoListAdapter.getItemsList());
|
||||
if (useDefaultStateSaving) objectsToSave.add(infoListAdapter.getItemsList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void readFrom(@NonNull Queue<Object> savedObjects) throws Exception {
|
||||
infoListAdapter.getItemsList().clear();
|
||||
infoListAdapter.getItemsList().addAll((List<InfoItem>) savedObjects.poll());
|
||||
if (useDefaultStateSaving) {
|
||||
infoListAdapter.getItemsList().clear();
|
||||
infoListAdapter.getItemsList().addAll((List<InfoItem>) savedObjects.poll());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(Bundle bundle) {
|
||||
super.onSaveInstanceState(bundle);
|
||||
savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this);
|
||||
if (useDefaultStateSaving) savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(@NonNull Bundle bundle) {
|
||||
super.onRestoreInstanceState(bundle);
|
||||
savedState = StateSaver.tryToRestore(bundle, this);
|
||||
if (useDefaultStateSaving) savedState = StateSaver.tryToRestore(bundle, this);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
@ -33,7 +33,7 @@ import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
|||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
|
@ -66,7 +66,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
private Disposable subscribeButtonMonitor;
|
||||
private SubscriptionService subscriptionService;
|
||||
private SubscriptionManager subscriptionManager;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Views
|
||||
|
@ -109,7 +109,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
@Override
|
||||
public void onAttach(Context context) {
|
||||
super.onAttach(context);
|
||||
subscriptionService = SubscriptionService.getInstance(activity);
|
||||
subscriptionManager = new SubscriptionManager(activity);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -212,8 +212,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
0);
|
||||
};
|
||||
|
||||
final Observable<List<SubscriptionEntity>> observable = subscriptionService.subscriptionTable()
|
||||
.getSubscription(info.getServiceId(), info.getUrl())
|
||||
final Observable<List<SubscriptionEntity>> observable = subscriptionManager.subscriptionTable()
|
||||
.getSubscriptionFlowable(info.getServiceId(), info.getUrl())
|
||||
.toObservable();
|
||||
|
||||
disposables.add(observable
|
||||
|
@ -231,16 +231,16 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
|
||||
}
|
||||
|
||||
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription) {
|
||||
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription, ChannelInfo info) {
|
||||
return (@NonNull Object o) -> {
|
||||
subscriptionService.subscriptionTable().insert(subscription);
|
||||
subscriptionManager.insertSubscription(subscription, info);
|
||||
return o;
|
||||
};
|
||||
}
|
||||
|
||||
private Function<Object, Object> mapOnUnsubscribe(final SubscriptionEntity subscription) {
|
||||
return (@NonNull Object o) -> {
|
||||
subscriptionService.subscriptionTable().delete(subscription);
|
||||
subscriptionManager.deleteSubscription(subscription);
|
||||
return o;
|
||||
};
|
||||
}
|
||||
|
@ -258,7 +258,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
"Updating Subscription for " + info.getUrl(),
|
||||
R.string.subscription_update_failed);
|
||||
|
||||
disposables.add(subscriptionService.updateChannelInfo(info)
|
||||
disposables.add(subscriptionManager.updateChannelInfo(info)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(onComplete, onError));
|
||||
|
@ -288,7 +288,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
private Consumer<List<SubscriptionEntity>> getSubscribeUpdateMonitor(final ChannelInfo info) {
|
||||
return (List<SubscriptionEntity> subscriptionEntities) -> {
|
||||
if (DEBUG)
|
||||
Log.d(TAG, "subscriptionService.subscriptionTable.doOnNext() called with: subscriptionEntities = [" + subscriptionEntities + "]");
|
||||
Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: subscriptionEntities = [" + subscriptionEntities + "]");
|
||||
if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose();
|
||||
|
||||
if (subscriptionEntities.isEmpty()) {
|
||||
|
@ -300,7 +300,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
info.getAvatarUrl(),
|
||||
info.getDescription(),
|
||||
info.getSubscriberCount());
|
||||
subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel));
|
||||
subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel, info));
|
||||
} else {
|
||||
if (DEBUG) Log.d(TAG, "Found subscription to this channel!");
|
||||
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
||||
|
|
|
@ -122,7 +122,7 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
|||
this.useGridVariant = useGridVariant;
|
||||
}
|
||||
|
||||
public void addInfoItemList(@Nullable final List<InfoItem> data) {
|
||||
public void addInfoItemList(@Nullable final List<? extends InfoItem> data) {
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
|
@ -147,6 +147,12 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
|||
}
|
||||
}
|
||||
|
||||
public void setInfoItemList(List<? extends InfoItem> data) {
|
||||
infoItemList.clear();
|
||||
infoItemList.addAll(data);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void addInfoItem(@Nullable InfoItem data) {
|
||||
if (data == null) {
|
||||
return;
|
||||
|
|
|
@ -0,0 +1,166 @@
|
|||
package org.schabi.newpipe.local.feed
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import io.reactivex.Completable
|
||||
import io.reactivex.Flowable
|
||||
import io.reactivex.Maybe
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import org.schabi.newpipe.MainActivity.DEBUG
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class FeedDatabaseManager(context: Context) {
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
private val feedTable = database.feedDAO()
|
||||
private val feedGroupTable = database.feedGroupDAO()
|
||||
private val streamTable = database.streamDAO()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Only items that are newer than this will be saved.
|
||||
*/
|
||||
val FEED_OLDEST_ALLOWED_DATE: Calendar = Calendar.getInstance().apply {
|
||||
add(Calendar.WEEK_OF_YEAR, -13)
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun groups() = feedGroupTable.getAll()
|
||||
|
||||
fun database() = database
|
||||
|
||||
fun asStreamItems(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<List<StreamInfoItem>> {
|
||||
val streams = when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedTable.getAllStreams()
|
||||
else -> feedTable.getAllStreamsFromGroup(groupId)
|
||||
}
|
||||
|
||||
return streams.map<List<StreamInfoItem>> {
|
||||
val items = ArrayList<StreamInfoItem>(it.size)
|
||||
for (streamEntity in it) items.add(streamEntity.toStreamInfoItem())
|
||||
return@map items
|
||||
}
|
||||
}
|
||||
|
||||
fun outdatedSubscriptions(outdatedThreshold: Date) = feedTable.getAllOutdated(outdatedThreshold)
|
||||
|
||||
fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<Long> {
|
||||
return when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount()
|
||||
else -> feedTable.notLoadedCountForGroup(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
fun outdatedSubscriptionsForGroup(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, outdatedThreshold: Date) =
|
||||
feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold)
|
||||
|
||||
fun markAsOutdated(subscriptionId: Long) = feedTable
|
||||
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
|
||||
|
||||
fun upsertAll(subscriptionId: Long, items: List<StreamInfoItem>,
|
||||
oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time) {
|
||||
val itemsToInsert = ArrayList<StreamInfoItem>()
|
||||
loop@ for (streamItem in items) {
|
||||
val uploadDate = streamItem.uploadDate
|
||||
|
||||
itemsToInsert += when {
|
||||
uploadDate == null && streamItem.streamType == StreamType.LIVE_STREAM -> streamItem
|
||||
uploadDate != null && uploadDate.date().time >= oldestAllowedDate -> streamItem
|
||||
else -> continue@loop
|
||||
}
|
||||
}
|
||||
|
||||
feedTable.unlinkOldLivestreams(subscriptionId)
|
||||
|
||||
if (itemsToInsert.isNotEmpty()) {
|
||||
val streamEntities = itemsToInsert.map { StreamEntity(it) }
|
||||
val streamIds = streamTable.upsertAll(streamEntities)
|
||||
val feedEntities = streamIds.map { FeedEntity(it, subscriptionId) }
|
||||
|
||||
feedTable.insertAll(feedEntities)
|
||||
}
|
||||
|
||||
feedTable.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, Calendar.getInstance().time))
|
||||
}
|
||||
|
||||
fun removeOrphansOrOlderStreams(oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time) {
|
||||
feedTable.unlinkStreamsOlderThan(oldestAllowedDate)
|
||||
streamTable.deleteOrphans()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
feedTable.deleteAll()
|
||||
val deletedOrphans = streamTable.deleteOrphans()
|
||||
if (DEBUG) Log.d(this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans")
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Feed Groups
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
fun subscriptionIdsForGroup(groupId: Long): Flowable<List<Long>> {
|
||||
return feedGroupTable.getSubscriptionIdsFor(groupId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List<Long>): Completable {
|
||||
return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun createGroup(name: String, icon: FeedGroupIcon): Maybe<Long> {
|
||||
return Maybe.fromCallable { feedGroupTable.insert(FeedGroupEntity(0, name, icon)) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun getGroup(groupId: Long): Maybe<FeedGroupEntity> {
|
||||
return feedGroupTable.getGroup(groupId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun updateGroup(feedGroupEntity: FeedGroupEntity): Completable {
|
||||
return Completable.fromCallable { feedGroupTable.update(feedGroupEntity) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun deleteGroup(groupId: Long): Completable {
|
||||
return Completable.fromCallable { feedGroupTable.delete(groupId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun updateGroupsOrder(groupIdList: List<Long>): Completable {
|
||||
var index = 0L
|
||||
val orderMap = groupIdList.associateBy({ it }, { index++ })
|
||||
|
||||
return Completable.fromCallable { feedGroupTable.updateOrder(orderMap) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun oldestSubscriptionUpdate(groupId: Long): Flowable<List<Date>> {
|
||||
return when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedTable.oldestSubscriptionUpdateFromAll()
|
||||
else -> feedTable.oldestSubscriptionUpdate(groupId)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -1,444 +0,0 @@
|
|||
package org.schabi.newpipe.local.feed;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.fragments.list.BaseListFragment;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import io.reactivex.Flowable;
|
||||
import io.reactivex.MaybeObserver;
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.disposables.CompositeDisposable;
|
||||
import io.reactivex.disposables.Disposable;
|
||||
|
||||
public class FeedFragment extends BaseListFragment<List<SubscriptionEntity>, Void> {
|
||||
|
||||
private static final int OFF_SCREEN_ITEMS_COUNT = 3;
|
||||
private static final int MIN_ITEMS_INITIAL_LOAD = 8;
|
||||
private int FEED_LOAD_COUNT = MIN_ITEMS_INITIAL_LOAD;
|
||||
|
||||
private int subscriptionPoolSize;
|
||||
|
||||
private SubscriptionService subscriptionService;
|
||||
|
||||
private AtomicBoolean allItemsLoaded = new AtomicBoolean(false);
|
||||
private HashSet<String> itemsLoaded = new HashSet<>();
|
||||
private final AtomicInteger requestLoadedAtomic = new AtomicInteger();
|
||||
|
||||
private CompositeDisposable compositeDisposable = new CompositeDisposable();
|
||||
private Disposable subscriptionObserver;
|
||||
private Subscription feedSubscriber;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Fragment LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
subscriptionService = SubscriptionService.getInstance(activity);
|
||||
|
||||
FEED_LOAD_COUNT = howManyItemsToLoad();
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
|
||||
|
||||
if(!useAsFrontPage) {
|
||||
setTitle(activity.getString(R.string.fragment_whats_new));
|
||||
}
|
||||
return inflater.inflate(R.layout.fragment_feed, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
disposeEverything();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (wasLoading.get()) doInitialLoadLogic();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
||||
disposeEverything();
|
||||
subscriptionService = null;
|
||||
compositeDisposable = null;
|
||||
subscriptionObserver = null;
|
||||
feedSubscriber = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
// Do not monitor for updates when user is not viewing the feed fragment.
|
||||
// This is a waste of bandwidth.
|
||||
disposeEverything();
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUserVisibleHint(boolean isVisibleToUser) {
|
||||
super.setUserVisibleHint(isVisibleToUser);
|
||||
if (activity != null && isVisibleToUser) {
|
||||
setTitle(activity.getString(R.string.fragment_whats_new));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
|
||||
ActionBar supportActionBar = activity.getSupportActionBar();
|
||||
|
||||
if(useAsFrontPage) {
|
||||
supportActionBar.setDisplayShowTitleEnabled(true);
|
||||
//supportActionBar.setDisplayShowTitleEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reloadContent() {
|
||||
resetFragment();
|
||||
super.reloadContent();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// StateSaving
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void writeTo(Queue<Object> objectsToSave) {
|
||||
super.writeTo(objectsToSave);
|
||||
objectsToSave.add(allItemsLoaded);
|
||||
objectsToSave.add(itemsLoaded);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void readFrom(@NonNull Queue<Object> savedObjects) throws Exception {
|
||||
super.readFrom(savedObjects);
|
||||
allItemsLoaded = (AtomicBoolean) savedObjects.poll();
|
||||
itemsLoaded = (HashSet<String>) savedObjects.poll();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Feed Loader
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void startLoading(boolean forceLoad) {
|
||||
if (DEBUG) Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]");
|
||||
if (subscriptionObserver != null) subscriptionObserver.dispose();
|
||||
|
||||
if (allItemsLoaded.get()) {
|
||||
if (infoListAdapter.getItemsList().size() == 0) {
|
||||
showEmptyState();
|
||||
} else {
|
||||
showListFooter(false);
|
||||
hideLoading();
|
||||
}
|
||||
|
||||
isLoading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.set(true);
|
||||
showLoading();
|
||||
showListFooter(true);
|
||||
subscriptionObserver = subscriptionService.getSubscription()
|
||||
.onErrorReturnItem(Collections.emptyList())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(this::handleResult, this::onError);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleResult(@androidx.annotation.NonNull List<SubscriptionEntity> result) {
|
||||
super.handleResult(result);
|
||||
|
||||
if (result.isEmpty()) {
|
||||
infoListAdapter.clearStreamItemList();
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
subscriptionPoolSize = result.size();
|
||||
Flowable.fromIterable(result)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getSubscriptionObserver());
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for reacting to user pulling request and starting a request for new feed stream.
|
||||
* <p>
|
||||
* On initialization, it automatically requests the amount of feed needed to display
|
||||
* a minimum amount required (FEED_LOAD_SIZE).
|
||||
* <p>
|
||||
* Upon receiving a user pull, it creates a Single Observer to fetch the ChannelInfo
|
||||
* containing the feed streams.
|
||||
**/
|
||||
private Subscriber<SubscriptionEntity> getSubscriptionObserver() {
|
||||
return new Subscriber<SubscriptionEntity>() {
|
||||
@Override
|
||||
public void onSubscribe(Subscription s) {
|
||||
if (feedSubscriber != null) feedSubscriber.cancel();
|
||||
feedSubscriber = s;
|
||||
|
||||
int requestSize = FEED_LOAD_COUNT - infoListAdapter.getItemsList().size();
|
||||
if (wasLoading.getAndSet(false)) requestSize = FEED_LOAD_COUNT;
|
||||
|
||||
boolean hasToLoad = requestSize > 0;
|
||||
if (hasToLoad) {
|
||||
requestLoadedAtomic.set(infoListAdapter.getItemsList().size());
|
||||
requestFeed(requestSize);
|
||||
}
|
||||
isLoading.set(hasToLoad);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(SubscriptionEntity subscriptionEntity) {
|
||||
if (!itemsLoaded.contains(subscriptionEntity.getServiceId() + subscriptionEntity.getUrl())) {
|
||||
subscriptionService.getChannelInfo(subscriptionEntity)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.onErrorComplete(
|
||||
(@io.reactivex.annotations.NonNull Throwable throwable) ->
|
||||
FeedFragment.super.onError(throwable))
|
||||
.subscribe(
|
||||
getChannelInfoObserver(subscriptionEntity.getServiceId(),
|
||||
subscriptionEntity.getUrl()));
|
||||
} else {
|
||||
requestFeed(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable exception) {
|
||||
FeedFragment.this.onError(exception);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
if (DEBUG) Log.d(TAG, "getSubscriptionObserver > onComplete() called");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* On each request, a subscription item from the updated table is transformed
|
||||
* into a ChannelInfo, containing the latest streams from the channel.
|
||||
* <p>
|
||||
* Currently, the feed uses the first into from the list of streams.
|
||||
* <p>
|
||||
* If chosen feed already displayed, then we request another feed from another
|
||||
* subscription, until the subscription table runs out of new items.
|
||||
* <p>
|
||||
* This Observer is self-contained and will close itself when complete. However, this
|
||||
* does not obey the fragment lifecycle and may continue running in the background
|
||||
* until it is complete. This is done due to RxJava2 no longer propagate errors once
|
||||
* an observer is unsubscribed while the thread process is still running.
|
||||
* <p>
|
||||
* To solve the above issue, we can either set a global RxJava Error Handler, or
|
||||
* manage exceptions case by case. This should be done if the current implementation is
|
||||
* too costly when dealing with larger subscription sets.
|
||||
*
|
||||
* @param url + serviceId to put in {@link #allItemsLoaded} to signal that this specific entity has been loaded.
|
||||
*/
|
||||
private MaybeObserver<ChannelInfo> getChannelInfoObserver(final int serviceId, final String url) {
|
||||
return new MaybeObserver<ChannelInfo>() {
|
||||
private Disposable observer;
|
||||
|
||||
@Override
|
||||
public void onSubscribe(Disposable d) {
|
||||
observer = d;
|
||||
compositeDisposable.add(d);
|
||||
isLoading.set(true);
|
||||
}
|
||||
|
||||
// Called only when response is non-empty
|
||||
@Override
|
||||
public void onSuccess(final ChannelInfo channelInfo) {
|
||||
if (infoListAdapter == null || channelInfo.getRelatedItems().isEmpty()) {
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
|
||||
final InfoItem item = channelInfo.getRelatedItems().get(0);
|
||||
// Keep requesting new items if the current one already exists
|
||||
boolean itemExists = doesItemExist(infoListAdapter.getItemsList(), item);
|
||||
if (!itemExists) {
|
||||
infoListAdapter.addInfoItem(item);
|
||||
//updateSubscription(channelInfo);
|
||||
} else {
|
||||
requestFeed(1);
|
||||
}
|
||||
onDone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable exception) {
|
||||
showSnackBarError(exception,
|
||||
UserAction.SUBSCRIPTION,
|
||||
NewPipe.getNameOfService(serviceId),
|
||||
url, 0);
|
||||
requestFeed(1);
|
||||
onDone();
|
||||
}
|
||||
|
||||
// Called only when response is empty
|
||||
@Override
|
||||
public void onComplete() {
|
||||
onDone();
|
||||
}
|
||||
|
||||
private void onDone() {
|
||||
if (observer.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
itemsLoaded.add(serviceId + url);
|
||||
compositeDisposable.remove(observer);
|
||||
|
||||
int loaded = requestLoadedAtomic.incrementAndGet();
|
||||
if (loaded >= Math.min(FEED_LOAD_COUNT, subscriptionPoolSize)) {
|
||||
requestLoadedAtomic.set(0);
|
||||
isLoading.set(false);
|
||||
}
|
||||
|
||||
if (itemsLoaded.size() == subscriptionPoolSize) {
|
||||
if (DEBUG) Log.d(TAG, "getChannelInfoObserver > All Items Loaded");
|
||||
allItemsLoaded.set(true);
|
||||
showListFooter(false);
|
||||
isLoading.set(false);
|
||||
hideLoading();
|
||||
if (infoListAdapter.getItemsList().size() == 0) {
|
||||
showEmptyState();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void loadMoreItems() {
|
||||
isLoading.set(true);
|
||||
delayHandler.removeCallbacksAndMessages(null);
|
||||
// Add a little of a delay when requesting more items because the cache is so fast,
|
||||
// that the view seems stuck to the user when he scroll to the bottom
|
||||
delayHandler.postDelayed(() -> requestFeed(FEED_LOAD_COUNT), 300);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean hasMoreItems() {
|
||||
return !allItemsLoaded.get();
|
||||
}
|
||||
|
||||
private final Handler delayHandler = new Handler();
|
||||
|
||||
private void requestFeed(final int count) {
|
||||
if (DEBUG) Log.d(TAG, "requestFeed() called with: count = [" + count + "], feedSubscriber = [" + feedSubscriber + "]");
|
||||
if (feedSubscriber == null) return;
|
||||
|
||||
isLoading.set(true);
|
||||
delayHandler.removeCallbacksAndMessages(null);
|
||||
feedSubscriber.request(count);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void resetFragment() {
|
||||
if (DEBUG) Log.d(TAG, "resetFragment() called");
|
||||
if (subscriptionObserver != null) subscriptionObserver.dispose();
|
||||
if (compositeDisposable != null) compositeDisposable.clear();
|
||||
if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
|
||||
|
||||
delayHandler.removeCallbacksAndMessages(null);
|
||||
requestLoadedAtomic.set(0);
|
||||
allItemsLoaded.set(false);
|
||||
showListFooter(false);
|
||||
itemsLoaded.clear();
|
||||
}
|
||||
|
||||
private void disposeEverything() {
|
||||
if (subscriptionObserver != null) subscriptionObserver.dispose();
|
||||
if (compositeDisposable != null) compositeDisposable.clear();
|
||||
if (feedSubscriber != null) feedSubscriber.cancel();
|
||||
delayHandler.removeCallbacksAndMessages(null);
|
||||
}
|
||||
|
||||
private boolean doesItemExist(final List<InfoItem> items, final InfoItem item) {
|
||||
for (final InfoItem existingItem : items) {
|
||||
if (existingItem.getInfoType() == item.getInfoType() &&
|
||||
existingItem.getServiceId() == item.getServiceId() &&
|
||||
existingItem.getName().equals(item.getName()) &&
|
||||
existingItem.getUrl().equals(item.getUrl())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private int howManyItemsToLoad() {
|
||||
int heightPixels = getResources().getDisplayMetrics().heightPixels;
|
||||
int itemHeightPixels = activity.getResources().getDimensionPixelSize(R.dimen.video_item_search_height);
|
||||
|
||||
int items = itemHeightPixels > 0
|
||||
? heightPixels / itemHeightPixels + OFF_SCREEN_ITEMS_COUNT
|
||||
: MIN_ITEMS_INITIAL_LOAD;
|
||||
return Math.max(MIN_ITEMS_INITIAL_LOAD, items);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Fragment Error Handling
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void showError(String message, boolean showRetryButton) {
|
||||
resetFragment();
|
||||
super.showError(message, showRetryButton);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onError(Throwable exception) {
|
||||
if (super.onError(exception)) return true;
|
||||
|
||||
int errorId = exception instanceof ExtractionException
|
||||
? R.string.parsing_error
|
||||
: R.string.general_error;
|
||||
onUnrecoverableError(exception,
|
||||
UserAction.SOMETHING_ELSE,
|
||||
"none",
|
||||
"Requesting feed",
|
||||
errorId);
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,327 @@
|
|||
/*
|
||||
* Copyright 2019 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* FeedFragment.kt is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.feed
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.*
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.preference.PreferenceManager
|
||||
import icepick.State
|
||||
import kotlinx.android.synthetic.main.error_retry.*
|
||||
import kotlinx.android.synthetic.main.fragment_feed.*
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.fragments.list.BaseListFragment
|
||||
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||
import org.schabi.newpipe.report.UserAction
|
||||
import org.schabi.newpipe.util.AnimationUtils.animateView
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import java.util.*
|
||||
|
||||
class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
private lateinit var viewModel: FeedViewModel
|
||||
@State @JvmField var listState: Parcelable? = null
|
||||
|
||||
private var groupId = FeedGroupEntity.GROUP_ALL_ID
|
||||
private var groupName = ""
|
||||
private var oldestSubscriptionUpdate: Calendar? = null
|
||||
|
||||
init {
|
||||
setHasOptionsMenu(true)
|
||||
useDefaultStateSaving(false)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) ?: FeedGroupEntity.GROUP_ALL_ID
|
||||
groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_feed, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(rootView, savedInstanceState)
|
||||
|
||||
viewModel = ViewModelProviders.of(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java)
|
||||
viewModel.stateLiveData.observe(viewLifecycleOwner, Observer { it?.let(::handleResult) })
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
listState = items_list?.layoutManager?.onSaveInstanceState()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
updateRelativeTimeViews()
|
||||
}
|
||||
|
||||
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
|
||||
super.setUserVisibleHint(isVisibleToUser)
|
||||
|
||||
if (!isVisibleToUser && view != null) {
|
||||
updateRelativeTimeViews()
|
||||
}
|
||||
}
|
||||
|
||||
override fun initListeners() {
|
||||
super.initListeners()
|
||||
refresh_root_view.setOnClickListener {
|
||||
triggerUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Menu
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
activity.supportActionBar?.setTitle(R.string.fragment_feed_title)
|
||||
activity.supportActionBar?.subtitle = groupName
|
||||
|
||||
inflater.inflate(R.menu.menu_feed_fragment, menu)
|
||||
|
||||
if (useAsFrontPage) {
|
||||
menu.findItem(R.id.menu_item_feed_help).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER);
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == R.id.menu_item_feed_help) {
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
|
||||
val usingDedicatedMethod = sharedPreferences.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
|
||||
val enableDisableButtonText = when {
|
||||
usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button
|
||||
else -> R.string.feed_use_dedicated_fetch_method_enable_button
|
||||
}
|
||||
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.feed_use_dedicated_fetch_method_help_text)
|
||||
.setNeutralButton(enableDisableButtonText) { _, _ ->
|
||||
sharedPreferences.edit()
|
||||
.putBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), !usingDedicatedMethod)
|
||||
.apply()
|
||||
}
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.create()
|
||||
.show()
|
||||
return true
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onDestroyOptionsMenu() {
|
||||
super.onDestroyOptionsMenu()
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Handling
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
override fun showLoading() {
|
||||
animateView(refresh_root_view, false, 0)
|
||||
animateView(items_list, false, 0)
|
||||
|
||||
animateView(loading_progress_bar, true, 200)
|
||||
animateView(loading_progress_text, true, 200)
|
||||
|
||||
empty_state_view?.let { animateView(it, false, 0) }
|
||||
animateView(error_panel, false, 0)
|
||||
}
|
||||
|
||||
override fun hideLoading() {
|
||||
animateView(refresh_root_view, true, 200)
|
||||
animateView(items_list, true, 300)
|
||||
|
||||
animateView(loading_progress_bar, false, 0)
|
||||
animateView(loading_progress_text, false, 0)
|
||||
|
||||
empty_state_view?.let { animateView(it, false, 0) }
|
||||
animateView(error_panel, false, 0)
|
||||
}
|
||||
|
||||
override fun showEmptyState() {
|
||||
animateView(refresh_root_view, true, 200)
|
||||
animateView(items_list, false, 0)
|
||||
|
||||
animateView(loading_progress_bar, false, 0)
|
||||
animateView(loading_progress_text, false, 0)
|
||||
|
||||
empty_state_view?.let { animateView(it, true, 800) }
|
||||
animateView(error_panel, false, 0)
|
||||
}
|
||||
|
||||
override fun showError(message: String, showRetryButton: Boolean) {
|
||||
infoListAdapter.clearStreamItemList()
|
||||
animateView(refresh_root_view, false, 120)
|
||||
animateView(items_list, false, 120)
|
||||
|
||||
animateView(loading_progress_bar, false, 120)
|
||||
animateView(loading_progress_text, false, 120)
|
||||
|
||||
error_message_view.text = message
|
||||
animateView(error_button_retry, showRetryButton, if (showRetryButton) 600 else 0)
|
||||
animateView(error_panel, true, 300)
|
||||
}
|
||||
|
||||
override fun handleResult(result: FeedState) {
|
||||
when (result) {
|
||||
is FeedState.ProgressState -> handleProgressState(result)
|
||||
is FeedState.LoadedState -> handleLoadedState(result)
|
||||
is FeedState.ErrorState -> if (handleErrorState(result)) return
|
||||
}
|
||||
|
||||
updateRefreshViewState()
|
||||
}
|
||||
|
||||
private fun handleProgressState(progressState: FeedState.ProgressState) {
|
||||
showLoading()
|
||||
|
||||
val isIndeterminate = progressState.currentProgress == -1 &&
|
||||
progressState.maxProgress == -1
|
||||
|
||||
if (!isIndeterminate) {
|
||||
loading_progress_text.text = "${progressState.currentProgress}/${progressState.maxProgress}"
|
||||
} else if (progressState.progressMessage > 0) {
|
||||
loading_progress_text?.setText(progressState.progressMessage)
|
||||
} else {
|
||||
loading_progress_text?.text = "∞/∞"
|
||||
}
|
||||
|
||||
loading_progress_bar.isIndeterminate = isIndeterminate ||
|
||||
(progressState.maxProgress > 0 && progressState.currentProgress == 0)
|
||||
loading_progress_bar.progress = progressState.currentProgress
|
||||
|
||||
loading_progress_bar.max = progressState.maxProgress
|
||||
}
|
||||
|
||||
private fun handleLoadedState(loadedState: FeedState.LoadedState) {
|
||||
infoListAdapter.setInfoItemList(loadedState.items)
|
||||
listState?.run {
|
||||
items_list.layoutManager?.onRestoreInstanceState(listState)
|
||||
listState = null
|
||||
}
|
||||
|
||||
oldestSubscriptionUpdate = loadedState.oldestUpdate
|
||||
|
||||
if (loadedState.notLoadedCount > 0) {
|
||||
refresh_subtitle_text.visibility = View.VISIBLE
|
||||
refresh_subtitle_text.text = getString(R.string.feed_subscription_not_loaded_count, loadedState.notLoadedCount)
|
||||
} else {
|
||||
refresh_subtitle_text.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (loadedState.itemsErrors.isNotEmpty()) {
|
||||
showSnackBarError(loadedState.itemsErrors, UserAction.REQUESTED_FEED,
|
||||
"none", "Loading feed", R.string.general_error)
|
||||
}
|
||||
|
||||
if (loadedState.items.isEmpty()) {
|
||||
showEmptyState()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun handleErrorState(errorState: FeedState.ErrorState): Boolean {
|
||||
hideLoading()
|
||||
errorState.error?.let {
|
||||
onError(errorState.error)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun updateRelativeTimeViews() {
|
||||
updateRefreshViewState()
|
||||
infoListAdapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun updateRefreshViewState() {
|
||||
val oldestSubscriptionUpdateText = when {
|
||||
oldestSubscriptionUpdate != null -> Localization.relativeTime(oldestSubscriptionUpdate!!)
|
||||
else -> "—"
|
||||
}
|
||||
|
||||
refresh_text?.text = getString(R.string.feed_oldest_subscription_update, oldestSubscriptionUpdateText)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Load Service Handling
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
override fun doInitialLoadLogic() {}
|
||||
override fun reloadContent() = triggerUpdate()
|
||||
override fun loadMoreItems() {}
|
||||
override fun hasMoreItems() = false
|
||||
|
||||
private fun triggerUpdate() {
|
||||
getActivity()?.startService(Intent(requireContext(), FeedLoadService::class.java).apply {
|
||||
putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId)
|
||||
})
|
||||
listState = null
|
||||
}
|
||||
|
||||
override fun onError(exception: Throwable): Boolean {
|
||||
if (super.onError(exception)) return true
|
||||
|
||||
if (useAsFrontPage) {
|
||||
showSnackBarError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0)
|
||||
return true
|
||||
}
|
||||
|
||||
onUnrecoverableError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0)
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_GROUP_ID = "ARG_GROUP_ID"
|
||||
const val KEY_GROUP_NAME = "ARG_GROUP_NAME"
|
||||
|
||||
@JvmStatic
|
||||
fun newInstance(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, groupName: String? = null): FeedFragment {
|
||||
val feedFragment = FeedFragment()
|
||||
|
||||
feedFragment.arguments = Bundle().apply {
|
||||
putLong(KEY_GROUP_ID, groupId)
|
||||
putString(KEY_GROUP_NAME, groupName)
|
||||
}
|
||||
|
||||
return feedFragment
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package org.schabi.newpipe.local.feed
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import java.util.*
|
||||
|
||||
sealed class FeedState {
|
||||
data class ProgressState(
|
||||
val currentProgress: Int = -1,
|
||||
val maxProgress: Int = -1,
|
||||
@StringRes val progressMessage: Int = 0
|
||||
) : FeedState()
|
||||
|
||||
data class LoadedState(
|
||||
val items: List<StreamInfoItem>,
|
||||
val oldestUpdate: Calendar? = null,
|
||||
val notLoadedCount: Long,
|
||||
val itemsErrors: List<Throwable> = emptyList()
|
||||
) : FeedState()
|
||||
|
||||
data class ErrorState(
|
||||
val error: Throwable? = null
|
||||
) : FeedState()
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
package org.schabi.newpipe.local.feed
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.Flowable
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.functions.Function4
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.*
|
||||
import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() {
|
||||
class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return FeedViewModel(context.applicationContext, groupId) as T
|
||||
}
|
||||
}
|
||||
|
||||
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
|
||||
|
||||
private val mutableStateLiveData = MutableLiveData<FeedState>()
|
||||
val stateLiveData: LiveData<FeedState> = mutableStateLiveData
|
||||
|
||||
private var combineDisposable = Flowable
|
||||
.combineLatest(
|
||||
FeedEventManager.events(),
|
||||
feedDatabaseManager.asStreamItems(groupId),
|
||||
feedDatabaseManager.notLoadedCount(groupId),
|
||||
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
|
||||
|
||||
Function4 { t1: FeedEventManager.Event, t2: List<StreamInfoItem>, t3: Long, t4: List<Date> ->
|
||||
return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull())
|
||||
}
|
||||
)
|
||||
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
val (event, listFromDB, notLoadedCount, oldestUpdate) = it
|
||||
|
||||
val oldestUpdateCalendar =
|
||||
oldestUpdate?.let { Calendar.getInstance().apply { time = it } }
|
||||
|
||||
mutableStateLiveData.postValue(when (event) {
|
||||
is IdleEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount)
|
||||
is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
|
||||
is SuccessResultEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount, event.itemsErrors)
|
||||
is ErrorResultEvent -> FeedState.ErrorState(event.error)
|
||||
})
|
||||
|
||||
if (event is ErrorResultEvent || event is SuccessResultEvent) {
|
||||
FeedEventManager.reset()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
combineDisposable.dispose()
|
||||
}
|
||||
|
||||
private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List<StreamInfoItem>, val t3: Long, val t4: Date?)
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import io.reactivex.Flowable
|
||||
import io.reactivex.processors.BehaviorProcessor
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object FeedEventManager {
|
||||
private var processor: BehaviorProcessor<Event> = BehaviorProcessor.create()
|
||||
private var ignoreUpstream = AtomicBoolean()
|
||||
private var eventsFlowable = processor.startWith(IdleEvent)
|
||||
|
||||
fun postEvent(event: Event) {
|
||||
processor.onNext(event)
|
||||
}
|
||||
|
||||
fun events(): Flowable<Event> {
|
||||
return eventsFlowable.filter { !ignoreUpstream.get() }
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
ignoreUpstream.set(true)
|
||||
postEvent(IdleEvent)
|
||||
ignoreUpstream.set(false)
|
||||
}
|
||||
|
||||
sealed class Event {
|
||||
object IdleEvent : Event()
|
||||
data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() {
|
||||
constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage)
|
||||
}
|
||||
|
||||
data class SuccessResultEvent(val itemsErrors: List<Throwable> = emptyList()) : Event()
|
||||
data class ErrorResultEvent(val error: Throwable) : Event()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,464 @@
|
|||
/*
|
||||
* Copyright 2019 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* FeedLoadService.kt is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.preference.PreferenceManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import io.reactivex.Flowable
|
||||
import io.reactivex.Notification
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.functions.Consumer
|
||||
import io.reactivex.functions.Function
|
||||
import io.reactivex.processors.PublishProcessor
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import org.reactivestreams.Subscriber
|
||||
import org.reactivestreams.Subscription
|
||||
import org.schabi.newpipe.MainActivity.DEBUG
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.*
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class FeedLoadService : Service() {
|
||||
companion object {
|
||||
private val TAG = FeedLoadService::class.java.simpleName
|
||||
private const val NOTIFICATION_ID = 7293450
|
||||
private const val ACTION_CANCEL = "org.schabi.newpipe.local.feed.service.FeedLoadService.CANCEL"
|
||||
|
||||
/**
|
||||
* How often the notification will be updated.
|
||||
*/
|
||||
private const val NOTIFICATION_SAMPLING_PERIOD = 1500
|
||||
|
||||
/**
|
||||
* How many extractions will be running in parallel.
|
||||
*/
|
||||
private const val PARALLEL_EXTRACTIONS = 6
|
||||
|
||||
/**
|
||||
* Number of items to buffer to mass-insert in the database.
|
||||
*/
|
||||
private const val BUFFER_COUNT_BEFORE_INSERT = 20
|
||||
|
||||
const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID"
|
||||
}
|
||||
|
||||
private var loadingSubscription: Subscription? = null
|
||||
private lateinit var subscriptionManager: SubscriptionManager
|
||||
|
||||
private lateinit var feedDatabaseManager: FeedDatabaseManager
|
||||
private lateinit var feedResultsHolder: ResultsHolder
|
||||
|
||||
private var disposables = CompositeDisposable()
|
||||
private var notificationUpdater = PublishProcessor.create<String>()
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Lifecycle
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
subscriptionManager = SubscriptionManager(this)
|
||||
feedDatabaseManager = FeedDatabaseManager(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "]," +
|
||||
" flags = [" + flags + "], startId = [" + startId + "]")
|
||||
}
|
||||
|
||||
if (intent == null || loadingSubscription != null) {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
setupNotification()
|
||||
setupBroadcastReceiver()
|
||||
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
||||
val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
|
||||
val useFeedExtractor = defaultSharedPreferences
|
||||
.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
|
||||
|
||||
val thresholdOutdatedSecondsString = defaultSharedPreferences
|
||||
.getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value))
|
||||
val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt()
|
||||
|
||||
startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds)
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun disposeAll() {
|
||||
unregisterReceiver(broadcastReceiver)
|
||||
|
||||
loadingSubscription?.cancel()
|
||||
loadingSubscription = null
|
||||
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
private fun stopService() {
|
||||
disposeAll()
|
||||
stopForeground(true)
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Loading & Handling
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) {
|
||||
companion object {
|
||||
fun wrapList(subscriptionId: Long, info: ListInfo<StreamInfoItem>): List<Throwable> {
|
||||
val toReturn = ArrayList<Throwable>(info.errors.size)
|
||||
for (error in info.errors) {
|
||||
toReturn.add(RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, error))
|
||||
}
|
||||
return toReturn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) {
|
||||
feedResultsHolder = ResultsHolder()
|
||||
|
||||
val outdatedThreshold = Calendar.getInstance().apply {
|
||||
add(Calendar.SECOND, -thresholdOutdatedSeconds)
|
||||
}.time
|
||||
|
||||
val subscriptions = when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
|
||||
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
|
||||
}
|
||||
|
||||
subscriptions
|
||||
.limit(1)
|
||||
|
||||
.doOnNext {
|
||||
currentProgress.set(0)
|
||||
maxProgress.set(it.size)
|
||||
}
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext {
|
||||
startForeground(NOTIFICATION_ID, notificationBuilder.build())
|
||||
updateNotificationProgress(null)
|
||||
broadcastProgress()
|
||||
}
|
||||
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMap { Flowable.fromIterable(it) }
|
||||
.takeWhile { !cancelSignal.get() }
|
||||
|
||||
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
|
||||
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
|
||||
.filter { !cancelSignal.get() }
|
||||
|
||||
.map { subscriptionEntity ->
|
||||
try {
|
||||
val listInfo = if (useFeedExtractor) {
|
||||
ExtractorHelper
|
||||
.getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
|
||||
.blockingGet()
|
||||
} else {
|
||||
ExtractorHelper
|
||||
.getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
|
||||
.blockingGet()
|
||||
} as ListInfo<StreamInfoItem>
|
||||
|
||||
return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
|
||||
} catch (e: Throwable) {
|
||||
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||
val wrapper = RequestException(subscriptionEntity.uid, request, e)
|
||||
return@map Notification.createOnError<Pair<Long, ListInfo<StreamInfoItem>>>(wrapper)
|
||||
}
|
||||
}
|
||||
.sequential()
|
||||
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext(errorHandlingConsumer)
|
||||
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext(notificationsConsumer)
|
||||
|
||||
.observeOn(Schedulers.io())
|
||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.doOnNext(databaseConsumer)
|
||||
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(resultSubscriber)
|
||||
}
|
||||
|
||||
private fun broadcastProgress() {
|
||||
postEvent(ProgressEvent(currentProgress.get(), maxProgress.get()))
|
||||
}
|
||||
|
||||
private val resultSubscriber
|
||||
get() = object : Subscriber<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>> {
|
||||
|
||||
override fun onSubscribe(s: Subscription) {
|
||||
loadingSubscription = s
|
||||
s.request(java.lang.Long.MAX_VALUE)
|
||||
}
|
||||
|
||||
override fun onNext(notification: List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>) {
|
||||
if (DEBUG) Log.v(TAG, "onNext() → $notification")
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
handleError(error)
|
||||
}
|
||||
|
||||
override fun onComplete() {
|
||||
if (maxProgress.get() == 0) {
|
||||
postEvent(IdleEvent)
|
||||
stopService()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
currentProgress.set(-1)
|
||||
maxProgress.set(-1)
|
||||
|
||||
notificationUpdater.onNext(getString(R.string.feed_processing_message))
|
||||
postEvent(ProgressEvent(R.string.feed_processing_message))
|
||||
|
||||
disposables.add(Single
|
||||
.fromCallable {
|
||||
feedResultsHolder.ready()
|
||||
|
||||
postEvent(ProgressEvent(R.string.feed_processing_message))
|
||||
feedDatabaseManager.removeOrphansOrOlderStreams()
|
||||
|
||||
postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors))
|
||||
true
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { _, throwable ->
|
||||
if (throwable != null) {
|
||||
Log.e(TAG, "Error while storing result", throwable)
|
||||
handleError(throwable)
|
||||
return@subscribe
|
||||
}
|
||||
stopService()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private val databaseConsumer: Consumer<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>>
|
||||
get() = Consumer {
|
||||
feedDatabaseManager.database().runInTransaction {
|
||||
for (notification in it) {
|
||||
|
||||
if (notification.isOnNext) {
|
||||
val subscriptionId = notification.value!!.first
|
||||
val info = notification.value!!.second
|
||||
|
||||
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
||||
subscriptionManager.updateFromInfo(subscriptionId, info)
|
||||
|
||||
if (info.errors.isNotEmpty()) {
|
||||
feedResultsHolder.addErrors(RequestException.wrapList(subscriptionId, info))
|
||||
feedDatabaseManager.markAsOutdated(subscriptionId)
|
||||
}
|
||||
|
||||
} else if (notification.isOnError) {
|
||||
val error = notification.error!!
|
||||
feedResultsHolder.addError(error)
|
||||
|
||||
if (error is RequestException) {
|
||||
feedDatabaseManager.markAsOutdated(error.subscriptionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private val errorHandlingConsumer: Consumer<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>
|
||||
get() = Consumer {
|
||||
if (it.isOnError) {
|
||||
var error = it.error!!
|
||||
if (error is RequestException) error = error.cause!!
|
||||
val cause = error.cause
|
||||
|
||||
when {
|
||||
error is IOException -> throw error
|
||||
cause is IOException -> throw cause
|
||||
|
||||
error is ReCaptchaException -> throw error
|
||||
cause is ReCaptchaException -> throw cause
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val notificationsConsumer: Consumer<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>
|
||||
get() = Consumer { onItemCompleted(it.value?.second?.name) }
|
||||
|
||||
private fun onItemCompleted(updateDescription: String?) {
|
||||
currentProgress.incrementAndGet()
|
||||
notificationUpdater.onNext(updateDescription ?: "")
|
||||
|
||||
broadcastProgress()
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Notification
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
private lateinit var notificationBuilder: NotificationCompat.Builder
|
||||
|
||||
private var currentProgress = AtomicInteger(-1)
|
||||
private var maxProgress = AtomicInteger(-1)
|
||||
|
||||
private fun createNotification(): NotificationCompat.Builder {
|
||||
val cancelActionIntent = PendingIntent.getBroadcast(this,
|
||||
NOTIFICATION_ID, Intent(ACTION_CANCEL), 0)
|
||||
|
||||
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
|
||||
.setOngoing(true)
|
||||
.setProgress(-1, -1, true)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.addAction(0, getString(R.string.cancel), cancelActionIntent)
|
||||
.setContentTitle(getString(R.string.feed_notification_loading))
|
||||
}
|
||||
|
||||
private fun setupNotification() {
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
notificationBuilder = createNotification()
|
||||
|
||||
val throttleAfterFirstEmission = Function { flow: Flowable<String> ->
|
||||
flow.limit(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
|
||||
}
|
||||
|
||||
disposables.add(notificationUpdater
|
||||
.publish(throttleAfterFirstEmission)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(this::updateNotificationProgress))
|
||||
}
|
||||
|
||||
private fun updateNotificationProgress(updateDescription: String?) {
|
||||
notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1)
|
||||
|
||||
if (maxProgress.get() == -1) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null)
|
||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
||||
notificationBuilder.setContentText(updateDescription)
|
||||
} else {
|
||||
val progressText = this.currentProgress.toString() + "/" + maxProgress
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)")
|
||||
} else {
|
||||
notificationBuilder.setContentInfo(progressText)
|
||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
||||
}
|
||||
}
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build())
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Notification Actions
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private lateinit var broadcastReceiver: BroadcastReceiver
|
||||
private val cancelSignal = AtomicBoolean()
|
||||
|
||||
private fun setupBroadcastReceiver() {
|
||||
broadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == ACTION_CANCEL) {
|
||||
cancelSignal.set(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL))
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Error handling
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private fun handleError(error: Throwable) {
|
||||
postEvent(ErrorResultEvent(error))
|
||||
stopService()
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Results Holder
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
class ResultsHolder {
|
||||
/**
|
||||
* List of errors that may have happen during loading.
|
||||
*/
|
||||
internal lateinit var itemsErrors: List<Throwable>
|
||||
|
||||
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
|
||||
|
||||
fun addError(error: Throwable) {
|
||||
itemsErrorsHolder.add(error)
|
||||
}
|
||||
|
||||
fun addErrors(errors: List<Throwable>) {
|
||||
itemsErrorsHolder.addAll(errors)
|
||||
}
|
||||
|
||||
fun ready() {
|
||||
itemsErrors = itemsErrorsHolder.toList()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -269,11 +269,11 @@ public class HistoryRecordManager {
|
|||
for (LocalItem item : items) {
|
||||
long streamId;
|
||||
if (item instanceof StreamStatisticsEntry) {
|
||||
streamId = ((StreamStatisticsEntry) item).streamId;
|
||||
streamId = ((StreamStatisticsEntry) item).getStreamId();
|
||||
} else if (item instanceof PlaylistStreamEntity) {
|
||||
streamId = ((PlaylistStreamEntity) item).getStreamUid();
|
||||
} else if (item instanceof PlaylistStreamEntry) {
|
||||
streamId = ((PlaylistStreamEntry) item).streamId;
|
||||
streamId = ((PlaylistStreamEntry) item).getStreamId();
|
||||
} else {
|
||||
result.add(null);
|
||||
continue;
|
||||
|
|
|
@ -76,11 +76,11 @@ public class StatisticsPlaylistFragment
|
|||
switch (sortMode) {
|
||||
case LAST_PLAYED:
|
||||
Collections.sort(results, (left, right) ->
|
||||
right.latestAccessDate.compareTo(left.latestAccessDate));
|
||||
right.getLatestAccessDate().compareTo(left.getLatestAccessDate()));
|
||||
return results;
|
||||
case MOST_PLAYED:
|
||||
Collections.sort(results, (left, right) ->
|
||||
Long.compare(right.watchCount, left.watchCount));
|
||||
Long.compare(right.getWatchCount(), left.getWatchCount()));
|
||||
return results;
|
||||
default: return null;
|
||||
}
|
||||
|
@ -153,9 +153,9 @@ public class StatisticsPlaylistFragment
|
|||
if (selectedItem instanceof StreamStatisticsEntry) {
|
||||
final StreamStatisticsEntry item = (StreamStatisticsEntry) selectedItem;
|
||||
NavigationHelper.openVideoDetailFragment(getFM(),
|
||||
item.serviceId,
|
||||
item.url,
|
||||
item.title);
|
||||
item.getStreamEntity().getServiceId(),
|
||||
item.getStreamEntity().getUrl(),
|
||||
item.getStreamEntity().getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -402,7 +402,7 @@ public class StatisticsPlaylistFragment
|
|||
.get(index);
|
||||
if(infoItem instanceof StreamStatisticsEntry) {
|
||||
final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem;
|
||||
final Disposable onDelete = recordManager.deleteStreamHistory(entry.streamId)
|
||||
final Disposable onDelete = recordManager.deleteStreamHistory(entry.getStreamId())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
howManyDeleted -> {
|
||||
|
|
|
@ -52,12 +52,12 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
|||
if (!(localItem instanceof PlaylistStreamEntry)) return;
|
||||
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
|
||||
|
||||
itemVideoTitleView.setText(item.title);
|
||||
itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.uploader,
|
||||
NewPipe.getNameOfService(item.serviceId)));
|
||||
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
|
||||
itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.getStreamEntity().getUploader(),
|
||||
NewPipe.getNameOfService(item.getStreamEntity().getServiceId())));
|
||||
|
||||
if (item.duration > 0) {
|
||||
itemDurationView.setText(Localization.getDurationString(item.duration));
|
||||
if (item.getStreamEntity().getDuration() > 0) {
|
||||
itemDurationView.setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
|
||||
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
||||
R.color.duration_background_color));
|
||||
itemDurationView.setVisibility(View.VISIBLE);
|
||||
|
@ -65,7 +65,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
|||
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
||||
if (state != null) {
|
||||
itemProgressView.setVisibility(View.VISIBLE);
|
||||
itemProgressView.setMax((int) item.duration);
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
||||
} else {
|
||||
itemProgressView.setVisibility(View.GONE);
|
||||
|
@ -75,7 +75,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
|||
}
|
||||
|
||||
// Default thumbnail is shown on error, while loading and if the url is empty
|
||||
itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,
|
||||
itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView,
|
||||
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
|
@ -102,8 +102,8 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
|||
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
|
||||
|
||||
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
||||
if (state != null && item.duration > 0) {
|
||||
itemProgressView.setMax((int) item.duration);
|
||||
if (state != null && item.getStreamEntity().getDuration() > 0) {
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
||||
} else {
|
||||
|
|
|
@ -71,9 +71,9 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
|||
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
|
||||
final DateFormat dateFormat) {
|
||||
final String watchCount = Localization.shortViewCount(itemBuilder.getContext(),
|
||||
entry.watchCount);
|
||||
final String uploadDate = dateFormat.format(entry.latestAccessDate);
|
||||
final String serviceName = NewPipe.getNameOfService(entry.serviceId);
|
||||
entry.getWatchCount());
|
||||
final String uploadDate = dateFormat.format(entry.getLatestAccessDate());
|
||||
final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId());
|
||||
return Localization.concatenateStrings(watchCount, uploadDate, serviceName);
|
||||
}
|
||||
|
||||
|
@ -82,11 +82,11 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
|||
if (!(localItem instanceof StreamStatisticsEntry)) return;
|
||||
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
|
||||
|
||||
itemVideoTitleView.setText(item.title);
|
||||
itemUploaderView.setText(item.uploader);
|
||||
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
|
||||
itemUploaderView.setText(item.getStreamEntity().getUploader());
|
||||
|
||||
if (item.duration > 0) {
|
||||
itemDurationView.setText(Localization.getDurationString(item.duration));
|
||||
if (item.getStreamEntity().getDuration() > 0) {
|
||||
itemDurationView.setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
|
||||
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
||||
R.color.duration_background_color));
|
||||
itemDurationView.setVisibility(View.VISIBLE);
|
||||
|
@ -94,7 +94,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
|||
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
||||
if (state != null) {
|
||||
itemProgressView.setVisibility(View.VISIBLE);
|
||||
itemProgressView.setMax((int) item.duration);
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
||||
} else {
|
||||
itemProgressView.setVisibility(View.GONE);
|
||||
|
@ -109,7 +109,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
|||
}
|
||||
|
||||
// Default thumbnail is shown on error, while loading and if the url is empty
|
||||
itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,
|
||||
itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView,
|
||||
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
|
@ -133,8 +133,8 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
|||
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
|
||||
|
||||
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
||||
if (state != null && item.duration > 0) {
|
||||
itemProgressView.setMax((int) item.duration);
|
||||
if (state != null && item.getStreamEntity().getDuration() > 0) {
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
||||
} else {
|
||||
|
|
|
@ -168,7 +168,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
if (selectedItem instanceof PlaylistStreamEntry) {
|
||||
final PlaylistStreamEntry item = (PlaylistStreamEntry) selectedItem;
|
||||
NavigationHelper.openVideoDetailFragment(getFragmentManager(),
|
||||
item.serviceId, item.url, item.title);
|
||||
item.getStreamEntity().getServiceId(), item.getStreamEntity().getUrl(), item.getStreamEntity().getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -422,7 +422,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
String newThumbnailUrl;
|
||||
|
||||
if (!itemListAdapter.getItemsList().isEmpty()) {
|
||||
newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0)).thumbnailUrl;
|
||||
newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0)).getStreamEntity().getThumbnailUrl();
|
||||
} else {
|
||||
newThumbnailUrl = "drawable://" + R.drawable.dummy_thumbnail_playlist;
|
||||
}
|
||||
|
@ -434,7 +434,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
if (itemListAdapter == null) return;
|
||||
|
||||
itemListAdapter.removeItem(item);
|
||||
if (playlistManager.getPlaylistThumbnail(playlistId).equals(item.thumbnailUrl))
|
||||
if (playlistManager.getPlaylistThumbnail(playlistId).equals(item.getStreamEntity().getThumbnailUrl()))
|
||||
updateThumbnailUrl();
|
||||
|
||||
setVideoCount(itemListAdapter.getItemsList().size());
|
||||
|
@ -472,7 +472,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
List<Long> streamIds = new ArrayList<>(items.size());
|
||||
for (final LocalItem item : items) {
|
||||
if (item instanceof PlaylistStreamEntry) {
|
||||
streamIds.add(((PlaylistStreamEntry) item).streamId);
|
||||
streamIds.add(((PlaylistStreamEntry) item).getStreamId());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -579,7 +579,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
StreamDialogEntry.start_here_on_background.setCustomAction(
|
||||
(fragment, infoItemDuplicate) -> NavigationHelper.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
|
||||
StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction(
|
||||
(fragment, infoItemDuplicate) -> changeThumbnailUrl(item.thumbnailUrl));
|
||||
(fragment, infoItemDuplicate) -> changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()));
|
||||
StreamDialogEntry.delete.setCustomAction(
|
||||
(fragment, infoItemDuplicate) -> deleteItem(item));
|
||||
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
package org.schabi.newpipe.local.subscription
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
|
||||
enum class FeedGroupIcon(
|
||||
/**
|
||||
* The id that will be used to store and retrieve icons from some persistent storage (e.g. DB).
|
||||
*/
|
||||
val id: Int,
|
||||
|
||||
/**
|
||||
* The attribute that points to a drawable resource. "R.attr" is used here to support multiple themes.
|
||||
*/
|
||||
@AttrRes val drawableResourceAttr: Int
|
||||
) {
|
||||
ALL(0, R.attr.ic_asterisk),
|
||||
MUSIC(1, R.attr.ic_music_note),
|
||||
EDUCATION(2, R.attr.ic_school),
|
||||
FITNESS(3, R.attr.ic_fitness),
|
||||
SPACE(4, R.attr.ic_telescope),
|
||||
COMPUTER(5, R.attr.ic_computer),
|
||||
GAMING(6, R.attr.ic_videogame),
|
||||
SPORTS(7, R.attr.ic_sports),
|
||||
NEWS(8, R.attr.ic_megaphone),
|
||||
FAVORITES(9, R.attr.ic_heart),
|
||||
CAR(10, R.attr.ic_car),
|
||||
MOTORCYCLE(11, R.attr.ic_motorcycle),
|
||||
TREND(12, R.attr.ic_trending_up),
|
||||
MOVIE(13, R.attr.ic_movie),
|
||||
BACKUP(14, R.attr.ic_backup),
|
||||
ART(15, R.attr.palette),
|
||||
PERSON(16, R.attr.ic_person),
|
||||
PEOPLE(17, R.attr.ic_people),
|
||||
MONEY(18, R.attr.ic_money),
|
||||
KIDS(19, R.attr.ic_kids),
|
||||
FOOD(20, R.attr.ic_fastfood),
|
||||
SMILE(21, R.attr.ic_smile),
|
||||
EXPLORE(22, R.attr.ic_explore),
|
||||
RESTAURANT(23, R.attr.ic_restaurant),
|
||||
MIC(24, R.attr.ic_mic),
|
||||
HEADSET(25, R.attr.audio),
|
||||
RADIO(26, R.attr.ic_radio),
|
||||
SHOPPING_CART(27, R.attr.ic_shopping_cart),
|
||||
WATCH_LATER(28, R.attr.ic_watch_later),
|
||||
WORK(29, R.attr.ic_work),
|
||||
HOT(30, R.attr.ic_hot),
|
||||
CHANNEL(31, R.attr.ic_channel),
|
||||
BOOKMARK(32, R.attr.ic_bookmark),
|
||||
PETS(33, R.attr.ic_pets),
|
||||
WORLD(34, R.attr.ic_world),
|
||||
STAR(35, R.attr.ic_stars),
|
||||
SUN(36, R.attr.ic_sunny),
|
||||
RSS(37, R.attr.rss);
|
||||
|
||||
@DrawableRes
|
||||
fun getDrawableRes(context: Context): Int {
|
||||
return ThemeHelper.resolveResourceIdFromAttr(context, drawableResourceAttr)
|
||||
}
|
||||
}
|
|
@ -1,595 +0,0 @@
|
|||
package org.schabi.newpipe.local.subscription;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.res.Configuration;
|
||||
import android.content.res.Resources;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.Parcelable;
|
||||
import android.preference.PreferenceManager;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.nononsenseapps.filepicker.Utils;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
||||
import org.schabi.newpipe.info_list.InfoListAdapter;
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService;
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.ShareUtils;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.CollapsibleView;
|
||||
|
||||
import java.io.File;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import icepick.State;
|
||||
import io.reactivex.Observer;
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.disposables.CompositeDisposable;
|
||||
import io.reactivex.disposables.Disposable;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
|
||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
|
||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE;
|
||||
import static org.schabi.newpipe.util.AnimationUtils.animateRotation;
|
||||
import static org.schabi.newpipe.util.AnimationUtils.animateView;
|
||||
|
||||
public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEntity>> implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private static final int REQUEST_EXPORT_CODE = 666;
|
||||
private static final int REQUEST_IMPORT_CODE = 667;
|
||||
|
||||
private RecyclerView itemsList;
|
||||
@State
|
||||
protected Parcelable itemsListState;
|
||||
private InfoListAdapter infoListAdapter;
|
||||
private int updateFlags = 0;
|
||||
|
||||
private static final int LIST_MODE_UPDATE_FLAG = 0x32;
|
||||
|
||||
private View whatsNewItemListHeader;
|
||||
private View importExportListHeader;
|
||||
|
||||
@State
|
||||
protected Parcelable importExportOptionsState;
|
||||
private CollapsibleView importExportOptions;
|
||||
|
||||
private CompositeDisposable disposables = new CompositeDisposable();
|
||||
private SubscriptionService subscriptionService;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Fragment LifeCycle
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setHasOptionsMenu(true);
|
||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
.registerOnSharedPreferenceChangeListener(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUserVisibleHint(boolean isVisibleToUser) {
|
||||
super.setUserVisibleHint(isVisibleToUser);
|
||||
if (activity != null && isVisibleToUser) {
|
||||
setTitle(activity.getString(R.string.tab_subscriptions));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Context context) {
|
||||
super.onAttach(context);
|
||||
infoListAdapter = new InfoListAdapter(activity);
|
||||
subscriptionService = SubscriptionService.getInstance(activity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDetach() {
|
||||
super.onDetach();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_subscription, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
setupBroadcastReceiver();
|
||||
if (updateFlags != 0) {
|
||||
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
|
||||
final boolean useGrid = isGridLayout();
|
||||
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
|
||||
infoListAdapter.setGridItemVariants(useGrid);
|
||||
infoListAdapter.notifyDataSetChanged();
|
||||
}
|
||||
updateFlags = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
itemsListState = itemsList.getLayoutManager().onSaveInstanceState();
|
||||
importExportOptionsState = importExportOptions.onSaveInstanceState();
|
||||
|
||||
if (subscriptionBroadcastReceiver != null && activity != null) {
|
||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
if (disposables != null) disposables.clear();
|
||||
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
if (disposables != null) disposables.dispose();
|
||||
disposables = null;
|
||||
subscriptionService = null;
|
||||
|
||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
.unregisterOnSharedPreferenceChangeListener(this);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
protected RecyclerView.LayoutManager getListLayoutManager() {
|
||||
return new LinearLayoutManager(activity);
|
||||
}
|
||||
|
||||
protected RecyclerView.LayoutManager getGridLayoutManager() {
|
||||
final Resources resources = activity.getResources();
|
||||
int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width);
|
||||
width += (24 * resources.getDisplayMetrics().density);
|
||||
final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels / (double)width);
|
||||
final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
|
||||
lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount));
|
||||
return lm;
|
||||
}
|
||||
|
||||
/*/////////////////////////////////////////////////////////////////////////
|
||||
// Menu
|
||||
/////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
|
||||
ActionBar supportActionBar = activity.getSupportActionBar();
|
||||
if (supportActionBar != null) {
|
||||
supportActionBar.setDisplayShowTitleEnabled(true);
|
||||
setTitle(getString(R.string.tab_subscriptions));
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Subscriptions import/export
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private BroadcastReceiver subscriptionBroadcastReceiver;
|
||||
|
||||
private void setupBroadcastReceiver() {
|
||||
if (activity == null) return;
|
||||
|
||||
if (subscriptionBroadcastReceiver != null) {
|
||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver);
|
||||
}
|
||||
|
||||
final IntentFilter filters = new IntentFilter();
|
||||
filters.addAction(SubscriptionsExportService.EXPORT_COMPLETE_ACTION);
|
||||
filters.addAction(SubscriptionsImportService.IMPORT_COMPLETE_ACTION);
|
||||
subscriptionBroadcastReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (importExportOptions != null) importExportOptions.collapse();
|
||||
}
|
||||
};
|
||||
|
||||
LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver, filters);
|
||||
}
|
||||
|
||||
private View addItemView(final String title, @DrawableRes final int icon, ViewGroup container) {
|
||||
final View itemRoot = View.inflate(getContext(), R.layout.subscription_import_export_item, null);
|
||||
final TextView titleView = itemRoot.findViewById(android.R.id.text1);
|
||||
final ImageView iconView = itemRoot.findViewById(android.R.id.icon1);
|
||||
|
||||
titleView.setText(title);
|
||||
iconView.setImageResource(icon);
|
||||
|
||||
container.addView(itemRoot);
|
||||
return itemRoot;
|
||||
}
|
||||
|
||||
private void setupImportFromItems(final ViewGroup listHolder) {
|
||||
final View previousBackupItem = addItemView(getString(R.string.previous_export),
|
||||
ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_backup), listHolder);
|
||||
previousBackupItem.setOnClickListener(item -> onImportPreviousSelected());
|
||||
|
||||
final int iconColor = ThemeHelper.isLightThemeSelected(getContext()) ? Color.BLACK : Color.WHITE;
|
||||
final String[] services = getResources().getStringArray(R.array.service_list);
|
||||
for (String serviceName : services) {
|
||||
try {
|
||||
final StreamingService service = NewPipe.getService(serviceName);
|
||||
|
||||
final SubscriptionExtractor subscriptionExtractor = service.getSubscriptionExtractor();
|
||||
if (subscriptionExtractor == null) continue;
|
||||
|
||||
final List<SubscriptionExtractor.ContentSource> supportedSources = subscriptionExtractor.getSupportedSources();
|
||||
if (supportedSources.isEmpty()) continue;
|
||||
|
||||
final View itemView = addItemView(serviceName, ServiceHelper.getIcon(service.getServiceId()), listHolder);
|
||||
final ImageView iconView = itemView.findViewById(android.R.id.icon1);
|
||||
iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN);
|
||||
|
||||
itemView.setOnClickListener(selectedItem -> onImportFromServiceSelected(service.getServiceId()));
|
||||
} catch (ExtractionException e) {
|
||||
throw new RuntimeException("Services array contains an entry that it's not a valid service name (" + serviceName + ")", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setupExportToItems(final ViewGroup listHolder) {
|
||||
final View previousBackupItem = addItemView(getString(R.string.file), ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_save), listHolder);
|
||||
previousBackupItem.setOnClickListener(item -> onExportSelected());
|
||||
}
|
||||
|
||||
private void onImportFromServiceSelected(int serviceId) {
|
||||
FragmentManager fragmentManager = getFM();
|
||||
NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId);
|
||||
}
|
||||
|
||||
private void onImportPreviousSelected() {
|
||||
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE);
|
||||
}
|
||||
|
||||
private void onExportSelected() {
|
||||
final String date = new SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(new Date());
|
||||
final String exportName = "newpipe_subscriptions_" + date + ".json";
|
||||
final File exportFile = new File(Environment.getExternalStorageDirectory(), exportName);
|
||||
|
||||
startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.getAbsolutePath()), REQUEST_EXPORT_CODE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (data != null && data.getData() != null && resultCode == Activity.RESULT_OK) {
|
||||
if (requestCode == REQUEST_EXPORT_CODE) {
|
||||
final File exportFile = Utils.getFileForUri(data.getData());
|
||||
if (!exportFile.getParentFile().canWrite() || !exportFile.getParentFile().canRead()) {
|
||||
Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
activity.startService(new Intent(activity, SubscriptionsExportService.class)
|
||||
.putExtra(SubscriptionsExportService.KEY_FILE_PATH, exportFile.getAbsolutePath()));
|
||||
}
|
||||
} else if (requestCode == REQUEST_IMPORT_CODE) {
|
||||
final String path = Utils.getFileForUri(data.getData()).getAbsolutePath();
|
||||
ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
|
||||
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
|
||||
.putExtra(KEY_VALUE, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
/*/////////////////////////////////////////////////////////////////////////
|
||||
// Fragment Views
|
||||
/////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected void initViews(View rootView, Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
|
||||
final boolean useGrid = isGridLayout();
|
||||
infoListAdapter = new InfoListAdapter(getActivity());
|
||||
itemsList = rootView.findViewById(R.id.items_list);
|
||||
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
|
||||
|
||||
View headerRootLayout;
|
||||
infoListAdapter.setHeader(headerRootLayout = activity.getLayoutInflater().inflate(R.layout.subscription_header, itemsList, false));
|
||||
whatsNewItemListHeader = headerRootLayout.findViewById(R.id.whats_new);
|
||||
importExportListHeader = headerRootLayout.findViewById(R.id.import_export);
|
||||
importExportOptions = headerRootLayout.findViewById(R.id.import_export_options);
|
||||
|
||||
infoListAdapter.useMiniItemVariants(true);
|
||||
infoListAdapter.setGridItemVariants(useGrid);
|
||||
itemsList.setAdapter(infoListAdapter);
|
||||
|
||||
setupImportFromItems(headerRootLayout.findViewById(R.id.import_from_options));
|
||||
setupExportToItems(headerRootLayout.findViewById(R.id.export_to_options));
|
||||
|
||||
if (importExportOptionsState != null) {
|
||||
importExportOptions.onRestoreInstanceState(importExportOptionsState);
|
||||
importExportOptionsState = null;
|
||||
}
|
||||
|
||||
importExportOptions.addListener(getExpandIconSyncListener(headerRootLayout.findViewById(R.id.import_export_expand_icon)));
|
||||
importExportOptions.ready();
|
||||
}
|
||||
|
||||
private CollapsibleView.StateListener getExpandIconSyncListener(final ImageView iconView) {
|
||||
return newState -> animateRotation(iconView, 250, newState == CollapsibleView.COLLAPSED ? 0 : 180);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initListeners() {
|
||||
super.initListeners();
|
||||
|
||||
infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<ChannelInfoItem>() {
|
||||
|
||||
public void selected(ChannelInfoItem selectedItem) {
|
||||
final FragmentManager fragmentManager = getFM();
|
||||
NavigationHelper.openChannelFragment(fragmentManager,
|
||||
selectedItem.getServiceId(),
|
||||
selectedItem.getUrl(),
|
||||
selectedItem.getName());
|
||||
}
|
||||
|
||||
public void held(ChannelInfoItem selectedItem) {
|
||||
showLongTapDialog(selectedItem);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
whatsNewItemListHeader.setOnClickListener(v -> {
|
||||
FragmentManager fragmentManager = getFM();
|
||||
NavigationHelper.openWhatsNewFragment(fragmentManager);
|
||||
});
|
||||
importExportListHeader.setOnClickListener(v -> importExportOptions.switchState());
|
||||
}
|
||||
|
||||
private void showLongTapDialog(ChannelInfoItem selectedItem) {
|
||||
final Context context = getContext();
|
||||
final Activity activity = getActivity();
|
||||
if (context == null || context.getResources() == null || getActivity() == null) return;
|
||||
|
||||
final String[] commands = new String[]{
|
||||
context.getResources().getString(R.string.unsubscribe),
|
||||
context.getResources().getString(R.string.share)
|
||||
};
|
||||
|
||||
final DialogInterface.OnClickListener actions = (dialogInterface, i) -> {
|
||||
switch (i) {
|
||||
case 0:
|
||||
deleteChannel(selectedItem);
|
||||
break;
|
||||
case 1:
|
||||
shareChannel(selectedItem);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
|
||||
bannerView.setSelected(true);
|
||||
|
||||
TextView titleView = bannerView.findViewById(R.id.itemTitleView);
|
||||
titleView.setText(selectedItem.getName());
|
||||
|
||||
TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
|
||||
detailsView.setVisibility(View.GONE);
|
||||
|
||||
new AlertDialog.Builder(activity)
|
||||
.setCustomTitle(bannerView)
|
||||
.setItems(commands, actions)
|
||||
.create()
|
||||
.show();
|
||||
|
||||
}
|
||||
|
||||
private void shareChannel(ChannelInfoItem selectedItem) {
|
||||
ShareUtils.shareUrl(getContext(), selectedItem.getName(), selectedItem.getUrl());
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private void deleteChannel(ChannelInfoItem selectedItem) {
|
||||
subscriptionService.subscriptionTable()
|
||||
.getSubscription(selectedItem.getServiceId(), selectedItem.getUrl())
|
||||
.toObservable()
|
||||
.observeOn(Schedulers.io())
|
||||
.subscribe(getDeleteObserver());
|
||||
|
||||
Toast.makeText(activity, getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
|
||||
|
||||
private Observer<List<SubscriptionEntity>> getDeleteObserver() {
|
||||
return new Observer<List<SubscriptionEntity>>() {
|
||||
@Override
|
||||
public void onSubscribe(Disposable d) {
|
||||
disposables.add(d);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(List<SubscriptionEntity> subscriptionEntities) {
|
||||
subscriptionService.subscriptionTable().delete(subscriptionEntities);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable exception) {
|
||||
SubscriptionFragment.this.onError(exception);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() { }
|
||||
};
|
||||
}
|
||||
|
||||
private void resetFragment() {
|
||||
if (disposables != null) disposables.clear();
|
||||
if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Subscriptions Loader
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Override
|
||||
public void startLoading(boolean forceLoad) {
|
||||
super.startLoading(forceLoad);
|
||||
resetFragment();
|
||||
|
||||
subscriptionService.getSubscription().toObservable()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getSubscriptionObserver());
|
||||
}
|
||||
|
||||
private Observer<List<SubscriptionEntity>> getSubscriptionObserver() {
|
||||
return new Observer<List<SubscriptionEntity>>() {
|
||||
@Override
|
||||
public void onSubscribe(Disposable d) {
|
||||
showLoading();
|
||||
disposables.add(d);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(List<SubscriptionEntity> subscriptions) {
|
||||
handleResult(subscriptions);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable exception) {
|
||||
SubscriptionFragment.this.onError(exception);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull List<SubscriptionEntity> result) {
|
||||
super.handleResult(result);
|
||||
|
||||
infoListAdapter.clearStreamItemList();
|
||||
|
||||
if (result.isEmpty()) {
|
||||
whatsNewItemListHeader.setVisibility(View.GONE);
|
||||
showEmptyState();
|
||||
} else {
|
||||
infoListAdapter.addInfoItemList(getSubscriptionItems(result));
|
||||
if (itemsListState != null) {
|
||||
itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
|
||||
itemsListState = null;
|
||||
}
|
||||
whatsNewItemListHeader.setVisibility(View.VISIBLE);
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private List<InfoItem> getSubscriptionItems(List<SubscriptionEntity> subscriptions) {
|
||||
List<InfoItem> items = new ArrayList<>();
|
||||
for (final SubscriptionEntity subscription : subscriptions) {
|
||||
items.add(subscription.toChannelInfoItem());
|
||||
}
|
||||
|
||||
Collections.sort(items,
|
||||
(InfoItem o1, InfoItem o2) ->
|
||||
o1.getName().compareToIgnoreCase(o2.getName()));
|
||||
return items;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Contract
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void showLoading() {
|
||||
super.showLoading();
|
||||
animateView(itemsList, false, 100);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hideLoading() {
|
||||
super.hideLoading();
|
||||
animateView(itemsList, true, 200);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Fragment Error Handling
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Override
|
||||
protected boolean onError(Throwable exception) {
|
||||
resetFragment();
|
||||
if (super.onError(exception)) return true;
|
||||
|
||||
onUnrecoverableError(exception,
|
||||
UserAction.SOMETHING_ELSE,
|
||||
"none",
|
||||
"Subscriptions",
|
||||
R.string.general_error);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
|
||||
if (key.equals(getString(R.string.list_view_mode_key))) {
|
||||
updateFlags |= LIST_MODE_UPDATE_FLAG;
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean isGridLayout() {
|
||||
final String list_mode = PreferenceManager.getDefaultSharedPreferences(activity).getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value));
|
||||
if ("auto".equals(list_mode)) {
|
||||
final Configuration configuration = getResources().getConfiguration();
|
||||
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
|
||||
} else {
|
||||
return "grid".equals(list_mode);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,421 @@
|
|||
package org.schabi.newpipe.local.subscription
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.*
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.Parcelable
|
||||
import android.preference.PreferenceManager
|
||||
import android.view.*
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.nononsenseapps.filepicker.Utils
|
||||
import com.xwray.groupie.Group
|
||||
import com.xwray.groupie.GroupAdapter
|
||||
import com.xwray.groupie.Item
|
||||
import com.xwray.groupie.Section
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import icepick.State
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import kotlinx.android.synthetic.main.dialog_title.view.*
|
||||
import kotlinx.android.synthetic.main.fragment_subscription.*
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionState
|
||||
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog
|
||||
import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog
|
||||
import org.schabi.newpipe.local.subscription.item.*
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.KEY_FILE_PATH
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.*
|
||||
import org.schabi.newpipe.report.UserAction
|
||||
import org.schabi.newpipe.util.*
|
||||
import org.schabi.newpipe.util.AnimationUtils.animateView
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
|
||||
class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||
private lateinit var viewModel: SubscriptionViewModel
|
||||
private lateinit var subscriptionManager: SubscriptionManager
|
||||
private val disposables: CompositeDisposable = CompositeDisposable()
|
||||
|
||||
private var subscriptionBroadcastReceiver: BroadcastReceiver? = null
|
||||
|
||||
private val groupAdapter = GroupAdapter<GroupieViewHolder>()
|
||||
private val feedGroupsSection = Section()
|
||||
private var feedGroupsCarousel: FeedGroupCarouselItem? = null
|
||||
private lateinit var importExportItem: FeedImportExportItem
|
||||
private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem
|
||||
private val subscriptionsSection = Section()
|
||||
|
||||
@State @JvmField var itemsListState: Parcelable? = null
|
||||
@State @JvmField var feedGroupsListState: Parcelable? = null
|
||||
@State @JvmField var importExportItemExpandedState: Boolean? = null
|
||||
|
||||
init {
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Fragment LifeCycle
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setupInitialLayout()
|
||||
}
|
||||
|
||||
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
|
||||
super.setUserVisibleHint(isVisibleToUser)
|
||||
if (activity != null && isVisibleToUser) {
|
||||
setTitle(activity.getString(R.string.tab_subscriptions))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
subscriptionManager = SubscriptionManager(requireContext())
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_subscription, container, false)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setupBroadcastReceiver()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
itemsListState = items_list.layoutManager?.onSaveInstanceState()
|
||||
feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState()
|
||||
importExportItemExpandedState = importExportItem.isExpanded
|
||||
|
||||
if (subscriptionBroadcastReceiver != null && activity != null) {
|
||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Menu
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
|
||||
val supportActionBar = activity.supportActionBar
|
||||
if (supportActionBar != null) {
|
||||
supportActionBar.setDisplayShowTitleEnabled(true)
|
||||
setTitle(getString(R.string.tab_subscriptions))
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupBroadcastReceiver() {
|
||||
if (activity == null) return
|
||||
|
||||
if (subscriptionBroadcastReceiver != null) {
|
||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
|
||||
}
|
||||
|
||||
val filters = IntentFilter()
|
||||
filters.addAction(EXPORT_COMPLETE_ACTION)
|
||||
filters.addAction(IMPORT_COMPLETE_ACTION)
|
||||
subscriptionBroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
items_list?.post {
|
||||
importExportItem.isExpanded = false
|
||||
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters)
|
||||
}
|
||||
|
||||
private fun onImportFromServiceSelected(serviceId: Int) {
|
||||
val fragmentManager = fm
|
||||
NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId)
|
||||
}
|
||||
|
||||
private fun onImportPreviousSelected() {
|
||||
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE)
|
||||
}
|
||||
|
||||
private fun onExportSelected() {
|
||||
val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date())
|
||||
val exportName = "newpipe_subscriptions_$date.json"
|
||||
val exportFile = File(Environment.getExternalStorageDirectory(), exportName)
|
||||
|
||||
startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.absolutePath), REQUEST_EXPORT_CODE)
|
||||
}
|
||||
|
||||
private fun openReorderDialog() {
|
||||
FeedGroupReorderDialog().show(requireFragmentManager(), null)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (data != null && data.data != null && resultCode == Activity.RESULT_OK) {
|
||||
if (requestCode == REQUEST_EXPORT_CODE) {
|
||||
val exportFile = Utils.getFileForUri(data.data!!)
|
||||
if (!exportFile.parentFile.canWrite() || !exportFile.parentFile.canRead()) {
|
||||
Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
activity.startService(Intent(activity, SubscriptionsExportService::class.java)
|
||||
.putExtra(KEY_FILE_PATH, exportFile.absolutePath))
|
||||
}
|
||||
} else if (requestCode == REQUEST_IMPORT_CODE) {
|
||||
val path = Utils.getFileForUri(data.data!!).absolutePath
|
||||
ImportConfirmationDialog.show(this, Intent(activity, SubscriptionsImportService::class.java)
|
||||
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
|
||||
.putExtra(KEY_VALUE, path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Fragment Views
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private fun setupInitialLayout() {
|
||||
Section().apply {
|
||||
val carouselAdapter = GroupAdapter<GroupieViewHolder>()
|
||||
|
||||
carouselAdapter.add(FeedGroupCardItem(-1, getString(R.string.all), FeedGroupIcon.RSS))
|
||||
carouselAdapter.add(feedGroupsSection)
|
||||
carouselAdapter.add(FeedGroupAddItem())
|
||||
|
||||
carouselAdapter.setOnItemClickListener { item, _ ->
|
||||
listenerFeedGroups.selected(item)
|
||||
}
|
||||
carouselAdapter.setOnItemLongClickListener { item, _ ->
|
||||
if (item is FeedGroupCardItem) {
|
||||
if (item.groupId == FeedGroupEntity.GROUP_ALL_ID) {
|
||||
return@setOnItemLongClickListener false
|
||||
}
|
||||
}
|
||||
listenerFeedGroups.held(item)
|
||||
return@setOnItemLongClickListener true
|
||||
}
|
||||
|
||||
feedGroupsCarousel = FeedGroupCarouselItem(requireContext(), carouselAdapter)
|
||||
feedGroupsSortMenuItem = HeaderWithMenuItem(
|
||||
getString(R.string.feed_groups_header_title),
|
||||
ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_sort),
|
||||
menuItemOnClickListener = ::openReorderDialog
|
||||
)
|
||||
add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel)))
|
||||
|
||||
groupAdapter.add(this)
|
||||
}
|
||||
|
||||
subscriptionsSection.setPlaceholder(EmptyPlaceholderItem())
|
||||
subscriptionsSection.setHideWhenEmpty(true)
|
||||
|
||||
importExportItem = FeedImportExportItem(
|
||||
{ onImportPreviousSelected() },
|
||||
{ onImportFromServiceSelected(it) },
|
||||
{ onExportSelected() },
|
||||
importExportItemExpandedState ?: false)
|
||||
groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection)))
|
||||
|
||||
}
|
||||
|
||||
override fun initViews(rootView: View, savedInstanceState: Bundle?) {
|
||||
super.initViews(rootView, savedInstanceState)
|
||||
|
||||
val shouldUseGridLayout = shouldUseGridLayout()
|
||||
groupAdapter.spanCount = if (shouldUseGridLayout) getGridSpanCount() else 1
|
||||
items_list.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply {
|
||||
spanSizeLookup = groupAdapter.spanSizeLookup
|
||||
}
|
||||
items_list.adapter = groupAdapter
|
||||
|
||||
viewModel = ViewModelProviders.of(this).get(SubscriptionViewModel::class.java)
|
||||
viewModel.stateLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleResult) })
|
||||
viewModel.feedGroupsLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleFeedGroups) })
|
||||
}
|
||||
|
||||
private fun showLongTapDialog(selectedItem: ChannelInfoItem) {
|
||||
val commands = arrayOf(
|
||||
getString(R.string.share),
|
||||
getString(R.string.unsubscribe)
|
||||
)
|
||||
|
||||
val actions = DialogInterface.OnClickListener { _, i ->
|
||||
when (i) {
|
||||
0 -> ShareUtils.shareUrl(requireContext(), selectedItem.name, selectedItem.url)
|
||||
1 -> deleteChannel(selectedItem)
|
||||
}
|
||||
}
|
||||
|
||||
val bannerView = View.inflate(requireContext(), R.layout.dialog_title, null)
|
||||
bannerView.isSelected = true
|
||||
bannerView.itemTitleView.text = selectedItem.name
|
||||
bannerView.itemAdditionalDetails.visibility = View.GONE
|
||||
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setCustomTitle(bannerView)
|
||||
.setItems(commands, actions)
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteChannel(selectedItem: ChannelInfoItem) {
|
||||
disposables.add(subscriptionManager.deleteSubscription(selectedItem.serviceId, selectedItem.url).subscribe {
|
||||
Toast.makeText(requireContext(), getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show()
|
||||
})
|
||||
}
|
||||
|
||||
override fun doInitialLoadLogic() = Unit
|
||||
override fun startLoading(forceLoad: Boolean) = Unit
|
||||
|
||||
private val listenerFeedGroups = object : OnClickGesture<Item<*>>() {
|
||||
override fun selected(selectedItem: Item<*>?) {
|
||||
when (selectedItem) {
|
||||
is FeedGroupCardItem -> NavigationHelper.openFeedFragment(fm, selectedItem.groupId, selectedItem.name)
|
||||
is FeedGroupAddItem -> FeedGroupDialog.newInstance().show(fm, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun held(selectedItem: Item<*>?) {
|
||||
when (selectedItem) {
|
||||
is FeedGroupCardItem -> FeedGroupDialog.newInstance(selectedItem.groupId).show(fm, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val listenerChannelItem = object : OnClickGesture<ChannelInfoItem>() {
|
||||
override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment(fm,
|
||||
selectedItem.serviceId, selectedItem.url, selectedItem.name)
|
||||
|
||||
override fun held(selectedItem: ChannelInfoItem) = showLongTapDialog(selectedItem)
|
||||
}
|
||||
|
||||
override fun handleResult(result: SubscriptionState) {
|
||||
super.handleResult(result)
|
||||
|
||||
val shouldUseGridLayout = shouldUseGridLayout()
|
||||
when (result) {
|
||||
is SubscriptionState.LoadedState -> {
|
||||
result.subscriptions.forEach {
|
||||
if (it is ChannelItem) {
|
||||
it.gesturesListener = listenerChannelItem
|
||||
it.itemVersion = when {
|
||||
shouldUseGridLayout -> ChannelItem.ItemVersion.GRID
|
||||
else -> ChannelItem.ItemVersion.MINI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscriptionsSection.update(result.subscriptions)
|
||||
subscriptionsSection.setHideWhenEmpty(false)
|
||||
|
||||
if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) {
|
||||
items_list.post {
|
||||
importExportItem.isExpanded = true
|
||||
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsListState != null) {
|
||||
items_list.layoutManager?.onRestoreInstanceState(itemsListState)
|
||||
itemsListState = null
|
||||
}
|
||||
}
|
||||
is SubscriptionState.ErrorState -> {
|
||||
result.error?.let { onError(result.error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFeedGroups(groups: List<Group>) {
|
||||
feedGroupsSection.update(groups)
|
||||
|
||||
if (feedGroupsListState != null) {
|
||||
feedGroupsCarousel?.onRestoreInstanceState(feedGroupsListState)
|
||||
feedGroupsListState = null
|
||||
}
|
||||
|
||||
if (groups.size < 2) {
|
||||
items_list.post { feedGroupsSortMenuItem.notifyChanged(HeaderWithMenuItem.PAYLOAD_HIDE_MENU_ITEM) }
|
||||
} else {
|
||||
items_list.post { feedGroupsSortMenuItem.notifyChanged(HeaderWithMenuItem.PAYLOAD_SHOW_MENU_ITEM) }
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Contract
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
override fun showLoading() {
|
||||
super.showLoading()
|
||||
animateView(items_list, false, 100)
|
||||
}
|
||||
|
||||
override fun hideLoading() {
|
||||
super.hideLoading()
|
||||
animateView(items_list, true, 200)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Fragment Error Handling
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
override fun onError(exception: Throwable): Boolean {
|
||||
if (super.onError(exception)) return true
|
||||
|
||||
onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, "none", "Subscriptions", R.string.general_error)
|
||||
return true
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Grid Mode
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// TODO: Move these out of this class, as it can be reused
|
||||
|
||||
private fun shouldUseGridLayout(): Boolean {
|
||||
val listMode = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
.getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value))
|
||||
|
||||
return when (listMode) {
|
||||
getString(R.string.list_view_mode_auto_key) -> {
|
||||
val configuration = resources.configuration
|
||||
|
||||
(configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE))
|
||||
}
|
||||
getString(R.string.list_view_mode_grid_key) -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGridSpanCount(): Int {
|
||||
val minWidth = resources.getDimensionPixelSize(R.dimen.channel_item_grid_min_width)
|
||||
return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_EXPORT_CODE = 666
|
||||
private const val REQUEST_IMPORT_CODE = 667
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
package org.schabi.newpipe.local.subscription
|
||||
|
||||
import android.content.Context
|
||||
import io.reactivex.Completable
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||
import org.schabi.newpipe.extractor.feed.FeedInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
|
||||
class SubscriptionManager(context: Context) {
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
private val subscriptionTable = database.subscriptionDAO()
|
||||
private val feedDatabaseManager = FeedDatabaseManager(context)
|
||||
|
||||
fun subscriptionTable(): SubscriptionDAO = subscriptionTable
|
||||
fun subscriptions() = subscriptionTable.all
|
||||
|
||||
fun upsertAll(infoList: List<ChannelInfo>): List<SubscriptionEntity> {
|
||||
val listEntities = subscriptionTable.upsertAll(
|
||||
infoList.map { SubscriptionEntity.from(it) })
|
||||
|
||||
database.runInTransaction {
|
||||
infoList.forEachIndexed { index, info ->
|
||||
feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems)
|
||||
}
|
||||
}
|
||||
|
||||
return listEntities
|
||||
}
|
||||
|
||||
fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url)
|
||||
.flatMapCompletable {
|
||||
Completable.fromRunnable {
|
||||
it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
|
||||
subscriptionTable.update(it)
|
||||
feedDatabaseManager.upsertAll(it.uid, info.relatedItems)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) {
|
||||
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
|
||||
|
||||
if (info is FeedInfo) {
|
||||
subscriptionEntity.name = info.name
|
||||
} else if (info is ChannelInfo) {
|
||||
subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
|
||||
}
|
||||
|
||||
subscriptionTable.update(subscriptionEntity)
|
||||
}
|
||||
|
||||
fun deleteSubscription(serviceId: Int, url: String): Completable {
|
||||
return Completable.fromCallable { subscriptionTable.deleteSubscription(serviceId, url) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) {
|
||||
database.runInTransaction {
|
||||
val subscriptionId = subscriptionTable.insert(subscriptionEntity)
|
||||
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
|
||||
subscriptionTable.delete(subscriptionEntity)
|
||||
}
|
||||
}
|
|
@ -1,162 +0,0 @@
|
|||
package org.schabi.newpipe.local.subscription;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.util.Log;
|
||||
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.database.AppDatabase;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.Completable;
|
||||
import io.reactivex.CompletableSource;
|
||||
import io.reactivex.Flowable;
|
||||
import io.reactivex.Maybe;
|
||||
import io.reactivex.Scheduler;
|
||||
import io.reactivex.functions.Function;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* Subscription Service singleton:
|
||||
* Provides a basis for channel Subscriptions.
|
||||
* Provides access to subscription table in database as well as
|
||||
* up-to-date observations on the subscribed channels
|
||||
*/
|
||||
public class SubscriptionService {
|
||||
|
||||
private static volatile SubscriptionService instance;
|
||||
|
||||
public static SubscriptionService getInstance(@NonNull Context context) {
|
||||
SubscriptionService result = instance;
|
||||
if (result == null) {
|
||||
synchronized (SubscriptionService.class) {
|
||||
result = instance;
|
||||
if (result == null) {
|
||||
instance = (result = new SubscriptionService(context));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected final String TAG = "SubscriptionService@" + Integer.toHexString(hashCode());
|
||||
protected static final boolean DEBUG = MainActivity.DEBUG;
|
||||
private static final int SUBSCRIPTION_DEBOUNCE_INTERVAL = 500;
|
||||
private static final int SUBSCRIPTION_THREAD_POOL_SIZE = 4;
|
||||
|
||||
private final AppDatabase db;
|
||||
private final Flowable<List<SubscriptionEntity>> subscription;
|
||||
|
||||
private final Scheduler subscriptionScheduler;
|
||||
|
||||
private SubscriptionService(Context context) {
|
||||
db = NewPipeDatabase.getInstance(context.getApplicationContext());
|
||||
subscription = getSubscriptionInfos();
|
||||
|
||||
final Executor subscriptionExecutor = Executors.newFixedThreadPool(SUBSCRIPTION_THREAD_POOL_SIZE);
|
||||
subscriptionScheduler = Schedulers.from(subscriptionExecutor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Part of subscription observation pipeline
|
||||
*
|
||||
* @see SubscriptionService#getSubscription()
|
||||
*/
|
||||
private Flowable<List<SubscriptionEntity>> getSubscriptionInfos() {
|
||||
return subscriptionTable().getAll()
|
||||
// Wait for a period of infrequent updates and return the latest update
|
||||
.debounce(SUBSCRIPTION_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS)
|
||||
.share() // Share allows multiple subscribers on the same observable
|
||||
.replay(1) // Replay synchronizes subscribers to the last emitted result
|
||||
.autoConnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides an observer to the latest update to the subscription table.
|
||||
* <p>
|
||||
* This observer may be subscribed multiple times, where each subscriber obtains
|
||||
* the latest synchronized changes available, effectively share the same data
|
||||
* across all subscribers.
|
||||
* <p>
|
||||
* This observer has a debounce cooldown, meaning if multiple updates are observed
|
||||
* in the cooldown interval, only the latest changes are emitted to the subscribers.
|
||||
* This reduces the amount of observations caused by frequent updates to the database.
|
||||
*/
|
||||
@androidx.annotation.NonNull
|
||||
public Flowable<List<SubscriptionEntity>> getSubscription() {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public Maybe<ChannelInfo> getChannelInfo(final SubscriptionEntity subscriptionEntity) {
|
||||
if (DEBUG) Log.d(TAG, "getChannelInfo() called with: subscriptionEntity = [" + subscriptionEntity + "]");
|
||||
|
||||
return Maybe.fromSingle(ExtractorHelper
|
||||
.getChannelInfo(subscriptionEntity.getServiceId(), subscriptionEntity.getUrl(), false))
|
||||
.subscribeOn(subscriptionScheduler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the database access interface for subscription table.
|
||||
*/
|
||||
public SubscriptionDAO subscriptionTable() {
|
||||
return db.subscriptionDAO();
|
||||
}
|
||||
|
||||
public Completable updateChannelInfo(final ChannelInfo info) {
|
||||
final Function<List<SubscriptionEntity>, CompletableSource> update = new Function<List<SubscriptionEntity>, CompletableSource>() {
|
||||
@Override
|
||||
public CompletableSource apply(@NonNull List<SubscriptionEntity> subscriptionEntities) {
|
||||
if (DEBUG) Log.d(TAG, "updateChannelInfo() called with: subscriptionEntities = [" + subscriptionEntities + "]");
|
||||
if (subscriptionEntities.size() == 1) {
|
||||
SubscriptionEntity subscription = subscriptionEntities.get(0);
|
||||
|
||||
// Subscriber count changes very often, making this check almost unnecessary.
|
||||
// Consider removing it later.
|
||||
if (!isSubscriptionUpToDate(info, subscription)) {
|
||||
subscription.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount());
|
||||
|
||||
return Completable.fromRunnable(() -> subscriptionTable().update(subscription));
|
||||
}
|
||||
}
|
||||
|
||||
return Completable.complete();
|
||||
}
|
||||
};
|
||||
|
||||
return subscriptionTable().getSubscription(info.getServiceId(), info.getUrl())
|
||||
.firstOrError()
|
||||
.flatMapCompletable(update);
|
||||
}
|
||||
|
||||
public List<SubscriptionEntity> upsertAll(final List<ChannelInfo> infoList) {
|
||||
final List<SubscriptionEntity> entityList = new ArrayList<>();
|
||||
for (ChannelInfo info : infoList) entityList.add(SubscriptionEntity.from(info));
|
||||
|
||||
return subscriptionTable().upsertAll(entityList);
|
||||
}
|
||||
|
||||
private boolean isSubscriptionUpToDate(final ChannelInfo info, final SubscriptionEntity entity) {
|
||||
return equalsAndNotNull(info.getUrl(), entity.getUrl()) &&
|
||||
info.getServiceId() == entity.getServiceId() &&
|
||||
info.getName().equals(entity.getName()) &&
|
||||
equalsAndNotNull(info.getAvatarUrl(), entity.getAvatarUrl()) &&
|
||||
equalsAndNotNull(info.getDescription(), entity.getDescription()) &&
|
||||
info.getSubscriberCount() == entity.getSubscriberCount();
|
||||
}
|
||||
|
||||
private boolean equalsAndNotNull(final Object o1, final Object o2) {
|
||||
return (o1 != null && o2 != null)
|
||||
&& o1.equals(o2);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package org.schabi.newpipe.local.subscription
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.xwray.groupie.Group
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.local.subscription.item.ChannelItem
|
||||
import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
|
||||
import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SubscriptionViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)
|
||||
private var subscriptionManager = SubscriptionManager(application)
|
||||
|
||||
private val mutableStateLiveData = MutableLiveData<SubscriptionState>()
|
||||
private val mutableFeedGroupsLiveData = MutableLiveData<List<Group>>()
|
||||
val stateLiveData: LiveData<SubscriptionState> = mutableStateLiveData
|
||||
val feedGroupsLiveData: LiveData<List<Group>> = mutableFeedGroupsLiveData
|
||||
|
||||
private var feedGroupItemsDisposable = feedDatabaseManager.groups()
|
||||
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||
.map { it.map(::FeedGroupCardItem) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(
|
||||
{ mutableFeedGroupsLiveData.postValue(it) },
|
||||
{ mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) }
|
||||
)
|
||||
|
||||
private var stateItemsDisposable = subscriptionManager.subscriptions()
|
||||
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||
.map { it.map { entity -> ChannelItem(entity.toChannelInfoItem(), entity.uid, ChannelItem.ItemVersion.MINI) } }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(
|
||||
{ mutableStateLiveData.postValue(SubscriptionState.LoadedState(it)) },
|
||||
{ mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) }
|
||||
)
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stateItemsDisposable.dispose()
|
||||
feedGroupItemsDisposable.dispose()
|
||||
}
|
||||
|
||||
sealed class SubscriptionState {
|
||||
data class LoadedState(val subscriptions: List<Group>) : SubscriptionState()
|
||||
data class ErrorState(val error: Throwable? = null) : SubscriptionState()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package org.schabi.newpipe.local.subscription.decoration
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
class FeedGroupCarouselDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val marginStartEnd: Int
|
||||
private val marginTopBottom: Int
|
||||
private val marginBetweenItems: Int
|
||||
|
||||
init {
|
||||
with(context.resources) {
|
||||
marginStartEnd = getDimensionPixelOffset(R.dimen.feed_group_carousel_start_end_margin)
|
||||
marginTopBottom = getDimensionPixelOffset(R.dimen.feed_group_carousel_top_bottom_margin)
|
||||
marginBetweenItems = getDimensionPixelOffset(R.dimen.feed_group_carousel_between_items_margin)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemOffsets(outRect: Rect, child: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
val childAdapterPosition = parent.getChildAdapterPosition(child)
|
||||
val childAdapterCount = parent.adapter?.itemCount ?: 0
|
||||
|
||||
outRect.set(marginBetweenItems, marginTopBottom, 0, marginTopBottom)
|
||||
|
||||
if (childAdapterPosition == 0) {
|
||||
outRect.left = marginStartEnd
|
||||
} else if (childAdapterPosition == childAdapterCount - 1) {
|
||||
outRect.right = marginStartEnd
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,354 @@
|
|||
package org.schabi.newpipe.local.subscription.dialog
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.xwray.groupie.GroupAdapter
|
||||
import com.xwray.groupie.Section
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import icepick.Icepick
|
||||
import icepick.State
|
||||
import kotlinx.android.synthetic.main.dialog_feed_group_create.*
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.*
|
||||
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent
|
||||
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent
|
||||
import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem
|
||||
import org.schabi.newpipe.local.subscription.item.PickerIconItem
|
||||
import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import java.io.Serializable
|
||||
|
||||
class FeedGroupDialog : DialogFragment() {
|
||||
private lateinit var viewModel: FeedGroupDialogViewModel
|
||||
private var groupId: Long = NO_GROUP_SELECTED
|
||||
private var groupIcon: FeedGroupIcon? = null
|
||||
private var groupSortOrder: Long = -1
|
||||
|
||||
sealed class ScreenState : Serializable {
|
||||
object InitialScreen : ScreenState()
|
||||
object IconPickerScreen : ScreenState()
|
||||
object SubscriptionsPickerScreen : ScreenState()
|
||||
object DeleteScreen : ScreenState()
|
||||
}
|
||||
|
||||
@State @JvmField var selectedIcon: FeedGroupIcon? = null
|
||||
@State @JvmField var selectedSubscriptions: HashSet<Long> = HashSet()
|
||||
@State @JvmField var currentScreen: ScreenState = InitialScreen
|
||||
|
||||
@State @JvmField var subscriptionsListState: Parcelable? = null
|
||||
@State @JvmField var iconsListState: Parcelable? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Icepick.restoreInstanceState(this, savedInstanceState)
|
||||
|
||||
setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()))
|
||||
groupId = arguments?.getLong(KEY_GROUP_ID, NO_GROUP_SELECTED) ?: NO_GROUP_SELECTED
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.dialog_feed_group_create, container)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return object : Dialog(requireActivity(), theme) {
|
||||
override fun onBackPressed() {
|
||||
if (currentScreen !is InitialScreen) {
|
||||
showScreen(InitialScreen)
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
iconsListState = icon_selector.layoutManager?.onSaveInstanceState()
|
||||
subscriptionsListState = subscriptions_selector_list.layoutManager?.onSaveInstanceState()
|
||||
|
||||
Icepick.saveInstanceState(this, outState)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewModel = ViewModelProviders.of(this, FeedGroupDialogViewModel.Factory(requireContext(), groupId))
|
||||
.get(FeedGroupDialogViewModel::class.java)
|
||||
|
||||
viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup))
|
||||
viewModel.subscriptionsLiveData.observe(viewLifecycleOwner, Observer { setupSubscriptionPicker(it.first, it.second) })
|
||||
viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer {
|
||||
when (it) {
|
||||
ProcessingEvent -> disableInput()
|
||||
SuccessEvent -> dismiss()
|
||||
}
|
||||
})
|
||||
|
||||
setupIconPicker()
|
||||
setupListeners()
|
||||
|
||||
showScreen(currentScreen)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Setup
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private fun setupListeners() {
|
||||
delete_button.setOnClickListener { showScreen(DeleteScreen) }
|
||||
|
||||
cancel_button.setOnClickListener {
|
||||
when (currentScreen) {
|
||||
InitialScreen -> dismiss()
|
||||
else -> showScreen(InitialScreen)
|
||||
}
|
||||
}
|
||||
|
||||
group_name_input_container.error = null
|
||||
group_name_input.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
if (group_name_input_container.isErrorEnabled && !s.isNullOrBlank()) {
|
||||
group_name_input_container.error = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
confirm_button.setOnClickListener {
|
||||
when (currentScreen) {
|
||||
InitialScreen -> handlePositiveButtonInitialScreen()
|
||||
DeleteScreen -> viewModel.deleteGroup()
|
||||
else -> showScreen(InitialScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePositiveButtonInitialScreen() {
|
||||
val name = group_name_input.text.toString().trim()
|
||||
val icon = selectedIcon ?: groupIcon ?: FeedGroupIcon.ALL
|
||||
|
||||
if (name.isBlank()) {
|
||||
group_name_input_container.error = getString(R.string.feed_group_dialog_empty_name)
|
||||
group_name_input.text = null
|
||||
group_name_input.requestFocus()
|
||||
return
|
||||
} else {
|
||||
group_name_input_container.error = null
|
||||
}
|
||||
|
||||
if (selectedSubscriptions.isEmpty()) {
|
||||
Toast.makeText(requireContext(), getString(R.string.feed_group_dialog_empty_selection), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
when (groupId) {
|
||||
NO_GROUP_SELECTED -> viewModel.createGroup(name, icon, selectedSubscriptions)
|
||||
else -> viewModel.updateGroup(name, icon, selectedSubscriptions, groupSortOrder)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGroup(feedGroupEntity: FeedGroupEntity? = null) {
|
||||
val icon = feedGroupEntity?.icon ?: FeedGroupIcon.ALL
|
||||
val name = feedGroupEntity?.name ?: ""
|
||||
groupIcon = feedGroupEntity?.icon
|
||||
groupSortOrder = feedGroupEntity?.sortOrder ?: -1
|
||||
|
||||
icon_preview.setImageResource((if (selectedIcon == null) icon else selectedIcon!!).getDrawableRes(requireContext()))
|
||||
|
||||
if (group_name_input.text.isNullOrBlank()) {
|
||||
group_name_input.setText(name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSubscriptionPicker(subscriptions: List<SubscriptionEntity>, selectedSubscriptions: Set<Long>) {
|
||||
this.selectedSubscriptions.addAll(selectedSubscriptions)
|
||||
val useGridLayout = subscriptions.isNotEmpty()
|
||||
|
||||
val groupAdapter = GroupAdapter<GroupieViewHolder>()
|
||||
groupAdapter.spanCount = if (useGridLayout) 4 else 1
|
||||
|
||||
val selectedCountText = getString(R.string.feed_group_dialog_selection_count, this.selectedSubscriptions.size)
|
||||
selected_subscription_count_view.text = selectedCountText
|
||||
subscriptions_selector_header_info.text = selectedCountText
|
||||
|
||||
Section().apply {
|
||||
addAll(subscriptions.map {
|
||||
val isSelected = this@FeedGroupDialog.selectedSubscriptions.contains(it.uid)
|
||||
PickerSubscriptionItem(it, isSelected)
|
||||
})
|
||||
setPlaceholder(EmptyPlaceholderItem())
|
||||
|
||||
groupAdapter.add(this)
|
||||
}
|
||||
|
||||
subscriptions_selector_list.apply {
|
||||
layoutManager = if (useGridLayout) {
|
||||
GridLayoutManager(requireContext(), groupAdapter.spanCount, RecyclerView.VERTICAL, false)
|
||||
} else {
|
||||
LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false)
|
||||
}
|
||||
|
||||
adapter = groupAdapter
|
||||
|
||||
if (subscriptionsListState != null) {
|
||||
layoutManager?.onRestoreInstanceState(subscriptionsListState)
|
||||
subscriptionsListState = null
|
||||
}
|
||||
}
|
||||
|
||||
groupAdapter.setOnItemClickListener { item, _ ->
|
||||
when (item) {
|
||||
is PickerSubscriptionItem -> {
|
||||
val subscriptionId = item.subscriptionEntity.uid
|
||||
|
||||
val isSelected = if (this.selectedSubscriptions.contains(subscriptionId)) {
|
||||
this.selectedSubscriptions.remove(subscriptionId)
|
||||
false
|
||||
} else {
|
||||
this.selectedSubscriptions.add(subscriptionId)
|
||||
true
|
||||
}
|
||||
|
||||
item.isSelected = isSelected
|
||||
item.notifyChanged(PickerSubscriptionItem.UPDATE_SELECTED)
|
||||
|
||||
val updateSelectedCountText = getString(R.string.feed_group_dialog_selection_count, this.selectedSubscriptions.size)
|
||||
selected_subscription_count_view.text = updateSelectedCountText
|
||||
subscriptions_selector_header_info.text = updateSelectedCountText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select_channel_button.setOnClickListener {
|
||||
subscriptions_selector_list.scrollToPosition(0)
|
||||
showScreen(SubscriptionsPickerScreen)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupIconPicker() {
|
||||
val groupAdapter = GroupAdapter<GroupieViewHolder>()
|
||||
groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(requireContext(), it) })
|
||||
|
||||
icon_selector.apply {
|
||||
layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false)
|
||||
adapter = groupAdapter
|
||||
|
||||
if (iconsListState != null) {
|
||||
layoutManager?.onRestoreInstanceState(iconsListState)
|
||||
iconsListState = null
|
||||
}
|
||||
}
|
||||
|
||||
groupAdapter.setOnItemClickListener { item, _ ->
|
||||
when (item) {
|
||||
is PickerIconItem -> {
|
||||
selectedIcon = item.icon
|
||||
icon_preview.setImageResource(item.iconRes)
|
||||
|
||||
showScreen(InitialScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
icon_preview.setOnClickListener {
|
||||
icon_selector.scrollToPosition(0)
|
||||
showScreen(IconPickerScreen)
|
||||
}
|
||||
|
||||
if (groupId == NO_GROUP_SELECTED) {
|
||||
val icon = selectedIcon ?: FeedGroupIcon.ALL
|
||||
icon_preview.setImageResource(icon.getDrawableRes(requireContext()))
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Screen Selector
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private fun showScreen(screen: ScreenState) {
|
||||
currentScreen = screen
|
||||
|
||||
options_root.onlyVisibleIn(InitialScreen)
|
||||
icon_selector.onlyVisibleIn(IconPickerScreen)
|
||||
subscriptions_selector.onlyVisibleIn(SubscriptionsPickerScreen)
|
||||
delete_screen_message.onlyVisibleIn(DeleteScreen)
|
||||
|
||||
separator.onlyVisibleIn(SubscriptionsPickerScreen, IconPickerScreen)
|
||||
cancel_button.onlyVisibleIn(InitialScreen, DeleteScreen)
|
||||
|
||||
confirm_button.setText(when {
|
||||
currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED -> R.string.create
|
||||
else -> android.R.string.ok
|
||||
})
|
||||
|
||||
delete_button.visibility = when {
|
||||
currentScreen != InitialScreen -> View.GONE
|
||||
groupId == NO_GROUP_SELECTED -> View.GONE
|
||||
else -> View.VISIBLE
|
||||
}
|
||||
|
||||
if (currentScreen != InitialScreen) hideKeyboard()
|
||||
}
|
||||
|
||||
private fun View.onlyVisibleIn(vararg screens: ScreenState) {
|
||||
visibility = when (currentScreen) {
|
||||
in screens -> View.VISIBLE
|
||||
else -> View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private fun hideKeyboard() {
|
||||
val inputMethodManager = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
inputMethodManager.hideSoftInputFromWindow(group_name_input.windowToken, InputMethodManager.RESULT_UNCHANGED_SHOWN)
|
||||
group_name_input.clearFocus()
|
||||
}
|
||||
|
||||
private fun disableInput() {
|
||||
delete_button?.isEnabled = false
|
||||
confirm_button?.isEnabled = false
|
||||
cancel_button?.isEnabled = false
|
||||
isCancelable = false
|
||||
|
||||
hideKeyboard()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_GROUP_ID = "KEY_GROUP_ID"
|
||||
private const val NO_GROUP_SELECTED = -1L
|
||||
|
||||
fun newInstance(groupId: Long = NO_GROUP_SELECTED): FeedGroupDialog {
|
||||
val dialog = FeedGroupDialog()
|
||||
|
||||
dialog.arguments = Bundle().apply {
|
||||
putLong(KEY_GROUP_ID, groupId)
|
||||
}
|
||||
|
||||
return dialog
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
package org.schabi.newpipe.local.subscription.dialog
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.Completable
|
||||
import io.reactivex.Flowable
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.functions.BiFunction
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
|
||||
|
||||
class FeedGroupDialogViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() {
|
||||
class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return FeedGroupDialogViewModel(context.applicationContext, groupId) as T
|
||||
}
|
||||
}
|
||||
|
||||
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
|
||||
private var subscriptionManager = SubscriptionManager(applicationContext)
|
||||
|
||||
private val mutableGroupLiveData = MutableLiveData<FeedGroupEntity>()
|
||||
private val mutableSubscriptionsLiveData = MutableLiveData<Pair<List<SubscriptionEntity>, Set<Long>>>()
|
||||
private val mutableDialogEventLiveData = MutableLiveData<DialogEvent>()
|
||||
val groupLiveData: LiveData<FeedGroupEntity> = mutableGroupLiveData
|
||||
val subscriptionsLiveData: LiveData<Pair<List<SubscriptionEntity>, Set<Long>>> = mutableSubscriptionsLiveData
|
||||
val dialogEventLiveData: LiveData<DialogEvent> = mutableDialogEventLiveData
|
||||
|
||||
private var actionProcessingDisposable: Disposable? = null
|
||||
|
||||
private var feedGroupDisposable = feedDatabaseManager.getGroup(groupId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(mutableGroupLiveData::postValue)
|
||||
|
||||
private var subscriptionsDisposable = Flowable
|
||||
.combineLatest(subscriptionManager.subscriptions(), feedDatabaseManager.subscriptionIdsForGroup(groupId),
|
||||
BiFunction { t1: List<SubscriptionEntity>, t2: List<Long> -> t1 to t2.toSet() })
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(mutableSubscriptionsLiveData::postValue)
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
actionProcessingDisposable?.dispose()
|
||||
subscriptionsDisposable.dispose()
|
||||
feedGroupDisposable.dispose()
|
||||
}
|
||||
|
||||
fun createGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set<Long>) {
|
||||
doAction(feedDatabaseManager.createGroup(name, selectedIcon)
|
||||
.flatMapCompletable {
|
||||
feedDatabaseManager.updateSubscriptionsForGroup(it, selectedSubscriptions.toList())
|
||||
})
|
||||
}
|
||||
|
||||
fun updateGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set<Long>, sortOrder: Long) {
|
||||
doAction(feedDatabaseManager.updateSubscriptionsForGroup(groupId, selectedSubscriptions.toList())
|
||||
.andThen(feedDatabaseManager.updateGroup(FeedGroupEntity(groupId, name, selectedIcon, sortOrder))))
|
||||
}
|
||||
|
||||
fun deleteGroup() {
|
||||
doAction(feedDatabaseManager.deleteGroup(groupId))
|
||||
}
|
||||
|
||||
private fun doAction(completable: Completable) {
|
||||
if (actionProcessingDisposable == null) {
|
||||
mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent
|
||||
|
||||
actionProcessingDisposable = completable
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) }
|
||||
}
|
||||
}
|
||||
|
||||
sealed class DialogEvent {
|
||||
object ProcessingEvent : DialogEvent()
|
||||
object SuccessEvent : DialogEvent()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
package org.schabi.newpipe.local.subscription.dialog
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.xwray.groupie.GroupAdapter
|
||||
import com.xwray.groupie.TouchCallback
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import icepick.Icepick
|
||||
import icepick.State
|
||||
import kotlinx.android.synthetic.main.dialog_feed_group_reorder.*
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.*
|
||||
import org.schabi.newpipe.local.subscription.item.FeedGroupReorderItem
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class FeedGroupReorderDialog : DialogFragment() {
|
||||
private lateinit var viewModel: FeedGroupReorderDialogViewModel
|
||||
|
||||
@State @JvmField var groupOrderedIdList = ArrayList<Long>()
|
||||
private val groupAdapter = GroupAdapter<GroupieViewHolder>()
|
||||
private val itemTouchHelper = ItemTouchHelper(getItemTouchCallback())
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Icepick.restoreInstanceState(this, savedInstanceState)
|
||||
|
||||
setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()))
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.dialog_feed_group_reorder, container)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewModel = ViewModelProviders.of(this).get(FeedGroupReorderDialogViewModel::class.java)
|
||||
viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups))
|
||||
viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer {
|
||||
when (it) {
|
||||
ProcessingEvent -> disableInput()
|
||||
SuccessEvent -> dismiss()
|
||||
}
|
||||
})
|
||||
|
||||
feed_groups_list.layoutManager = LinearLayoutManager(requireContext())
|
||||
feed_groups_list.adapter = groupAdapter
|
||||
itemTouchHelper.attachToRecyclerView(feed_groups_list)
|
||||
|
||||
confirm_button.setOnClickListener {
|
||||
viewModel.updateOrder(groupOrderedIdList)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
Icepick.saveInstanceState(this, outState)
|
||||
}
|
||||
|
||||
private fun handleGroups(list: List<FeedGroupEntity>) {
|
||||
val groupList: List<FeedGroupEntity>
|
||||
|
||||
if (groupOrderedIdList.isEmpty()) {
|
||||
groupList = list
|
||||
groupOrderedIdList.addAll(groupList.map { it.uid })
|
||||
} else {
|
||||
groupList = list.sortedBy { groupOrderedIdList.indexOf(it.uid) }
|
||||
}
|
||||
|
||||
groupAdapter.update(groupList.map { FeedGroupReorderItem(it, itemTouchHelper) })
|
||||
}
|
||||
|
||||
private fun disableInput() {
|
||||
confirm_button?.isEnabled = false
|
||||
isCancelable = false
|
||||
}
|
||||
|
||||
private fun getItemTouchCallback(): SimpleCallback {
|
||||
return object : TouchCallback() {
|
||||
|
||||
override fun onMove(recyclerView: RecyclerView, source: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder): Boolean {
|
||||
val sourceIndex = source.adapterPosition
|
||||
val targetIndex = target.adapterPosition
|
||||
|
||||
groupAdapter.notifyItemMoved(sourceIndex, targetIndex)
|
||||
Collections.swap(groupOrderedIdList, sourceIndex, targetIndex)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun isLongPressDragEnabled(): Boolean = false
|
||||
override fun isItemViewSwipeEnabled(): Boolean = false
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) {}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package org.schabi.newpipe.local.subscription.dialog
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import io.reactivex.Completable
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
|
||||
class FeedGroupReorderDialogViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)
|
||||
|
||||
private val mutableGroupsLiveData = MutableLiveData<List<FeedGroupEntity>>()
|
||||
private val mutableDialogEventLiveData = MutableLiveData<DialogEvent>()
|
||||
val groupsLiveData: LiveData<List<FeedGroupEntity>> = mutableGroupsLiveData
|
||||
val dialogEventLiveData: LiveData<DialogEvent> = mutableDialogEventLiveData
|
||||
|
||||
private var actionProcessingDisposable: Disposable? = null
|
||||
|
||||
private var groupsDisposable = feedDatabaseManager.groups()
|
||||
.limit(1)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(mutableGroupsLiveData::postValue)
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
actionProcessingDisposable?.dispose()
|
||||
groupsDisposable.dispose()
|
||||
}
|
||||
|
||||
fun updateOrder(groupIdList: List<Long>) {
|
||||
doAction(feedDatabaseManager.updateGroupsOrder(groupIdList))
|
||||
}
|
||||
|
||||
private fun doAction(completable: Completable) {
|
||||
if (actionProcessingDisposable == null) {
|
||||
mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent
|
||||
|
||||
actionProcessingDisposable = completable
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) }
|
||||
}
|
||||
}
|
||||
|
||||
sealed class DialogEvent {
|
||||
object ProcessingEvent : DialogEvent()
|
||||
object SuccessEvent : DialogEvent()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
package org.schabi.newpipe.local.subscription.item
|
||||
|
||||
import android.content.Context
|
||||
import com.nostra13.universalimageloader.core.ImageLoader
|
||||
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import kotlinx.android.synthetic.main.list_channel_item.*
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||
import org.schabi.newpipe.util.ImageDisplayConstants
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.OnClickGesture
|
||||
|
||||
|
||||
class ChannelItem(
|
||||
private val infoItem: ChannelInfoItem,
|
||||
private val subscriptionId: Long = -1L,
|
||||
var itemVersion: ItemVersion = ItemVersion.NORMAL,
|
||||
var gesturesListener: OnClickGesture<ChannelInfoItem>? = null
|
||||
) : Item() {
|
||||
|
||||
override fun getId(): Long = if (subscriptionId == -1L) super.getId() else subscriptionId
|
||||
|
||||
enum class ItemVersion { NORMAL, MINI, GRID }
|
||||
|
||||
override fun getLayout(): Int = when (itemVersion) {
|
||||
ItemVersion.NORMAL -> R.layout.list_channel_item
|
||||
ItemVersion.MINI -> R.layout.list_channel_mini_item
|
||||
ItemVersion.GRID -> R.layout.list_channel_grid_item
|
||||
}
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||
viewHolder.itemTitleView.text = infoItem.name
|
||||
viewHolder.itemAdditionalDetails.text = getDetailLine(viewHolder.root.context)
|
||||
if (itemVersion == ItemVersion.NORMAL) viewHolder.itemChannelDescriptionView.text = infoItem.description
|
||||
|
||||
ImageLoader.getInstance().displayImage(infoItem.thumbnailUrl, viewHolder.itemThumbnailView,
|
||||
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS)
|
||||
|
||||
gesturesListener?.run {
|
||||
viewHolder.containerView.setOnClickListener { selected(infoItem) }
|
||||
viewHolder.containerView.setOnLongClickListener { held(infoItem); true }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDetailLine(context: Context): String {
|
||||
var details = if (infoItem.subscriberCount >= 0) {
|
||||
Localization.shortSubscriberCount(context, infoItem.subscriberCount)
|
||||
} else {
|
||||
context.getString(R.string.subscribers_count_not_available)
|
||||
}
|
||||
|
||||
if (itemVersion == ItemVersion.NORMAL) {
|
||||
if (infoItem.streamCount >= 0) {
|
||||
val formattedVideoAmount = Localization.localizeStreamCount(context, infoItem.streamCount)
|
||||
details = Localization.concatenateStrings(details, formattedVideoAmount)
|
||||
}
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
override fun getSpanSize(spanCount: Int, position: Int): Int {
|
||||
return if (itemVersion == ItemVersion.GRID) 1 else spanCount
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package org.schabi.newpipe.local.subscription.item
|
||||
|
||||
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
class EmptyPlaceholderItem : Item() {
|
||||
override fun getLayout(): Int = R.layout.list_empty_view
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int) {}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package org.schabi.newpipe.local.subscription.item
|
||||
|
||||
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
class FeedGroupAddItem : Item() {
|
||||
override fun getLayout(): Int = R.layout.feed_group_add_new_item
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int) {}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package org.schabi.newpipe.local.subscription.item
|
||||
|
||||
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import kotlinx.android.synthetic.main.feed_group_card_item.*
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||
|
||||
data class FeedGroupCardItem(
|
||||
val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||
val name: String,
|
||||
val icon: FeedGroupIcon
|
||||
) : Item() {
|
||||
constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon)
|
||||
|
||||
override fun getId(): Long {
|
||||
return when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> super.getId()
|
||||
else -> groupId
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLayout(): Int = R.layout.feed_group_card_item
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||
viewHolder.title.text = name
|
||||
viewHolder.icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package org.schabi.newpipe.local.subscription.item
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.xwray.groupie.GroupAdapter
|
||||
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import kotlinx.android.synthetic.main.feed_item_carousel.*
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.local.subscription.decoration.FeedGroupCarouselDecoration
|
||||
|
||||
class FeedGroupCarouselItem(context: Context, private val carouselAdapter: GroupAdapter<GroupieViewHolder>) : Item() {
|
||||
private val feedGroupCarouselDecoration = FeedGroupCarouselDecoration(context)
|
||||
|
||||
private var linearLayoutManager: LinearLayoutManager? = null
|
||||
private var listState: Parcelable? = null
|
||||
|
||||
override fun getLayout() = R.layout.feed_item_carousel
|
||||
|
||||
fun onSaveInstanceState(): Parcelable? {
|
||||
listState = linearLayoutManager?.onSaveInstanceState()
|
||||
return listState
|
||||
}
|
||||
|
||||
fun onRestoreInstanceState(state: Parcelable?) {
|
||||
linearLayoutManager?.onRestoreInstanceState(state)
|
||||
listState = state
|
||||
}
|
||||
|
||||
override fun createViewHolder(itemView: View): GroupieViewHolder {
|
||||
val viewHolder = super.createViewHolder(itemView)
|
||||
|
||||
linearLayoutManager = LinearLayoutManager(itemView.context, RecyclerView.HORIZONTAL, false)
|
||||
|
||||
viewHolder.recycler_view.apply {
|
||||
layoutManager = linearLayoutManager
|
||||
adapter = carouselAdapter
|
||||
addItemDecoration(feedGroupCarouselDecoration)
|
||||
}
|
||||
|
||||
return viewHolder
|
||||
}
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||
viewHolder.recycler_view.apply { adapter = carouselAdapter }
|
||||
linearLayoutManager?.onRestoreInstanceState(listState)
|
||||
}
|
||||
|
||||
override fun unbind(viewHolder: GroupieViewHolder) {
|
||||
super.unbind(viewHolder)
|
||||
|
||||
listState = linearLayoutManager?.onSaveInstanceState()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package org.schabi.newpipe.local.subscription.item
|
||||
|
||||
import android.view.MotionEvent
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.ItemTouchHelper.DOWN
|
||||
import androidx.recyclerview.widget.ItemTouchHelper.UP
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||
import kotlinx.android.synthetic.main.feed_group_reorder_item.*
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||
|
||||
data class FeedGroupReorderItem(
|
||||
val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||
val name: String,
|
||||
val icon: FeedGroupIcon,
|
||||
val dragCallback: ItemTouchHelper
|
||||
) : Item() {
|
||||
constructor (feedGroupEntity: FeedGroupEntity, dragCallback: ItemTouchHelper)
|
||||
: this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon, dragCallback)
|
||||
|
||||
override fun getId(): Long {
|
||||
return when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> super.getId()
|
||||
else -> groupId
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLayout(): Int = R.layout.feed_group_reorder_item
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||
viewHolder.group_name.text = name
|
||||
viewHolder.group_icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context))
|
||||
viewHolder.handle.setOnTouchListener { _, event ->
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
dragCallback.startDrag(viewHolder)
|
||||
return@setOnTouchListener true
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDragDirs(): Int {
|
||||
return UP or DOWN
|
||||
}
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
package org.schabi.newpipe.local.subscription.item
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.PorterDuff
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||
import kotlinx.android.synthetic.main.feed_import_export_group.*
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
||||
import org.schabi.newpipe.util.AnimationUtils
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import org.schabi.newpipe.views.CollapsibleView
|
||||
|
||||
class FeedImportExportItem(
|
||||
val onImportPreviousSelected: () -> Unit,
|
||||
val onImportFromServiceSelected: (Int) -> Unit,
|
||||
val onExportSelected: () -> Unit,
|
||||
var isExpanded: Boolean = false
|
||||
) : Item() {
|
||||
companion object {
|
||||
const val REFRESH_EXPANDED_STATUS = 123
|
||||
}
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList<Any>) {
|
||||
if (payloads.contains(REFRESH_EXPANDED_STATUS)) {
|
||||
viewHolder.import_export_options.apply { if (isExpanded) expand() else collapse() }
|
||||
return
|
||||
}
|
||||
|
||||
super.bind(viewHolder, position, payloads)
|
||||
}
|
||||
|
||||
override fun getLayout(): Int = R.layout.feed_import_export_group
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||
if (viewHolder.import_from_options.childCount == 0) setupImportFromItems(viewHolder.import_from_options)
|
||||
if (viewHolder.export_to_options.childCount == 0) setupExportToItems(viewHolder.export_to_options)
|
||||
|
||||
expandIconListener?.let { viewHolder.import_export_options.removeListener(it) }
|
||||
expandIconListener = CollapsibleView.StateListener { newState ->
|
||||
AnimationUtils.animateRotation(viewHolder.import_export_expand_icon,
|
||||
250, if (newState == CollapsibleView.COLLAPSED) 0 else 180)
|
||||
}
|
||||
|
||||
viewHolder.import_export_options.currentState = if (isExpanded) CollapsibleView.EXPANDED else CollapsibleView.COLLAPSED
|
||||
viewHolder.import_export_expand_icon.rotation = if (isExpanded) 180F else 0F
|
||||
viewHolder.import_export_options.ready()
|
||||
|
||||
viewHolder.import_export_options.addListener(expandIconListener)
|
||||
viewHolder.import_export.setOnClickListener {
|
||||
viewHolder.import_export_options.switchState()
|
||||
isExpanded = viewHolder.import_export_options.currentState == CollapsibleView.EXPANDED
|
||||
}
|
||||
}
|
||||
|
||||
override fun unbind(viewHolder: GroupieViewHolder) {
|
||||
super.unbind(viewHolder)
|
||||
expandIconListener?.let { viewHolder.import_export_options.removeListener(it) }
|
||||
expandIconListener = null
|
||||
}
|
||||
|
||||
private var expandIconListener: CollapsibleView.StateListener? = null
|
||||
|
||||
private fun addItemView(title: String, @DrawableRes icon: Int, container: ViewGroup): View {
|
||||
val itemRoot = View.inflate(container.context, R.layout.subscription_import_export_item, null)
|
||||
val titleView = itemRoot.findViewById<TextView>(android.R.id.text1)
|
||||
val iconView = itemRoot.findViewById<ImageView>(android.R.id.icon1)
|
||||
|
||||
titleView.text = title
|
||||
iconView.setImageResource(icon)
|
||||
|
||||
container.addView(itemRoot)
|
||||
return itemRoot
|
||||
}
|
||||
|
||||
private fun setupImportFromItems(listHolder: ViewGroup) {
|
||||
val previousBackupItem = addItemView(listHolder.context.getString(R.string.previous_export),
|
||||
ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_backup), listHolder)
|
||||
previousBackupItem.setOnClickListener { onImportPreviousSelected() }
|
||||
|
||||
val iconColor = if (ThemeHelper.isLightThemeSelected(listHolder.context)) Color.BLACK else Color.WHITE
|
||||
val services = listHolder.context.resources.getStringArray(R.array.service_list)
|
||||
for (serviceName in services) {
|
||||
try {
|
||||
val service = NewPipe.getService(serviceName)
|
||||
|
||||
val subscriptionExtractor = service.subscriptionExtractor ?: continue
|
||||
|
||||
val supportedSources = subscriptionExtractor.supportedSources
|
||||
if (supportedSources.isEmpty()) continue
|
||||
|
||||
val itemView = addItemView(serviceName, ServiceHelper.getIcon(service.serviceId), listHolder)
|
||||
val iconView = itemView.findViewById<ImageView>(android.R.id.icon1)
|
||||
iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN)
|
||||
|
||||
itemView.setOnClickListener { onImportFromServiceSelected(service.serviceId) }
|
||||
} catch (e: ExtractionException) {
|
||||
throw RuntimeException("Services array contains an entry that it's not a valid service name ($serviceName)", e)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupExportToItems(listHolder: ViewGroup) {
|
||||
val previousBackupItem = addItemView(listHolder.context.getString(R.string.file),
|
||||
ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_save), listHolder)
|
||||
previousBackupItem.setOnClickListener { onExportSelected() }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package org.schabi.newpipe.local.subscription.item
|
||||
|
||||
import android.view.View.OnClickListener
|
||||
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import kotlinx.android.synthetic.main.header_item.*
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
class HeaderItem(val title: String, private val onClickListener: (() -> Unit)? = null) : Item() {
|
||||
|
||||
override fun getLayout(): Int = R.layout.header_item
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||
viewHolder.header_title.text = title
|
||||
|
||||
val listener: OnClickListener? = if (onClickListener != null) OnClickListener { onClickListener.invoke() } else null
|
||||
viewHolder.root.setOnClickListener(listener)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package org.schabi.newpipe.local.subscription.item
|
||||
|
||||
import android.view.View.*
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||
import kotlinx.android.synthetic.main.header_with_menu_item.*
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
class HeaderWithMenuItem(
|
||||
val title: String,
|
||||
@DrawableRes val itemIcon: Int = 0,
|
||||
private val onClickListener: (() -> Unit)? = null,
|
||||
private val menuItemOnClickListener: (() -> Unit)? = null
|
||||
) : Item() {
|
||||
companion object {
|
||||
const val PAYLOAD_SHOW_MENU_ITEM = 1
|
||||
const val PAYLOAD_HIDE_MENU_ITEM = 2
|
||||
}
|
||||
|
||||
override fun getLayout(): Int = R.layout.header_with_menu_item
|
||||
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList<Any>) {
|
||||
if (payloads.contains(PAYLOAD_SHOW_MENU_ITEM)) {
|
||||
viewHolder.header_menu_item.visibility = VISIBLE
|
||||
return
|
||||
} else if (payloads.contains(PAYLOAD_HIDE_MENU_ITEM)) {
|
||||
viewHolder.header_menu_item.visibility = GONE
|
||||
return
|
||||
}
|
||||
|
||||
super.bind(viewHolder, position, payloads)
|
||||
}
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||
viewHolder.header_title.text = title
|
||||
viewHolder.header_menu_item.setImageResource(itemIcon)
|
||||
|
||||
val listener: OnClickListener? =
|
||||
onClickListener?.let { OnClickListener { onClickListener.invoke() } }
|
||||
viewHolder.root.setOnClickListener(listener)
|
||||
|
||||
val menuItemListener: OnClickListener? =
|
||||
menuItemOnClickListener?.let { OnClickListener { menuItemOnClickListener.invoke() } }
|
||||
viewHolder.header_menu_item.setOnClickListener(menuItemListener)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package org.schabi.newpipe.local.subscription.item
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import kotlinx.android.synthetic.main.picker_icon_item.*
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||
|
||||
class PickerIconItem(context: Context, val icon: FeedGroupIcon) : Item() {
|
||||
@DrawableRes val iconRes: Int = icon.getDrawableRes(context)
|
||||
|
||||
override fun getLayout(): Int = R.layout.picker_icon_item
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||
viewHolder.icon_view.setImageResource(iconRes)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package org.schabi.newpipe.local.subscription.item
|
||||
|
||||
import android.view.View
|
||||
import com.nostra13.universalimageloader.core.DisplayImageOptions
|
||||
import com.nostra13.universalimageloader.core.ImageLoader
|
||||
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||
import kotlinx.android.synthetic.main.picker_subscription_item.*
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.util.AnimationUtils
|
||||
import org.schabi.newpipe.util.AnimationUtils.animateView
|
||||
import org.schabi.newpipe.util.ImageDisplayConstants
|
||||
|
||||
data class PickerSubscriptionItem(val subscriptionEntity: SubscriptionEntity, var isSelected: Boolean = false) : Item() {
|
||||
companion object {
|
||||
const val UPDATE_SELECTED = 123
|
||||
|
||||
val IMAGE_LOADING_OPTIONS: DisplayImageOptions = ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS
|
||||
}
|
||||
|
||||
override fun getLayout(): Int = R.layout.picker_subscription_item
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList<Any>) {
|
||||
if (payloads.contains(UPDATE_SELECTED)) {
|
||||
animateView(viewHolder.selected_highlight, AnimationUtils.Type.LIGHT_SCALE_AND_ALPHA, isSelected, 150)
|
||||
return
|
||||
}
|
||||
|
||||
super.bind(viewHolder, position, payloads)
|
||||
}
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||
ImageLoader.getInstance().displayImage(subscriptionEntity.avatarUrl, viewHolder.thumbnail_view, IMAGE_LOADING_OPTIONS)
|
||||
|
||||
viewHolder.title_view.text = subscriptionEntity.name
|
||||
viewHolder.selected_highlight.visibility = if (isSelected) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
override fun unbind(viewHolder: GroupieViewHolder) {
|
||||
super.unbind(viewHolder)
|
||||
|
||||
viewHolder.selected_highlight.animate().setListener(null).cancel()
|
||||
viewHolder.selected_highlight.visibility = View.GONE
|
||||
viewHolder.selected_highlight.alpha = 1F
|
||||
}
|
||||
|
||||
override fun getId(): Long {
|
||||
return subscriptionEntity.uid
|
||||
}
|
||||
}
|
|
@ -34,10 +34,9 @@ import android.widget.Toast;
|
|||
import org.reactivestreams.Publisher;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
import org.schabi.newpipe.report.ErrorActivity;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
import org.schabi.newpipe.local.subscription.ImportExportEventListener;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
|
@ -57,7 +56,7 @@ public abstract class BaseImportExportService extends Service {
|
|||
protected NotificationManagerCompat notificationManager;
|
||||
protected NotificationCompat.Builder notificationBuilder;
|
||||
|
||||
protected SubscriptionService subscriptionService;
|
||||
protected SubscriptionManager subscriptionManager;
|
||||
protected final CompositeDisposable disposables = new CompositeDisposable();
|
||||
protected final PublishProcessor<String> notificationUpdater = PublishProcessor.create();
|
||||
|
||||
|
@ -70,7 +69,7 @@ public abstract class BaseImportExportService extends Service {
|
|||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
subscriptionService = SubscriptionService.getInstance(this);
|
||||
subscriptionManager = new SubscriptionManager(this);
|
||||
setupNotification();
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package org.schabi.newpipe.local.subscription;
|
||||
package org.schabi.newpipe.local.subscription.services;
|
||||
|
||||
public interface ImportExportEventListener {
|
||||
/**
|
|
@ -17,7 +17,7 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.subscription;
|
||||
package org.schabi.newpipe.local.subscription.services;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
|
@ -29,7 +29,6 @@ import org.reactivestreams.Subscription;
|
|||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
import org.schabi.newpipe.local.subscription.ImportExportJsonHelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
|
@ -96,7 +95,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
|
|||
private void startExport() {
|
||||
showToast(R.string.export_ongoing);
|
||||
|
||||
subscriptionService.subscriptionTable()
|
||||
subscriptionManager.subscriptionTable()
|
||||
.getAll()
|
||||
.take(1)
|
||||
.map(subscriptionEntities -> {
|
||||
|
|
|
@ -33,7 +33,6 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
|||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
import org.schabi.newpipe.local.subscription.ImportExportJsonHelper;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
||||
|
@ -180,6 +179,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
|||
|
||||
.observeOn(Schedulers.io())
|
||||
.doOnNext(getNotificationsConsumer())
|
||||
|
||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.map(upsertBatch())
|
||||
|
||||
|
@ -204,6 +204,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
|||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
Log.e(TAG, "Got an error!", error);
|
||||
handleError(error);
|
||||
}
|
||||
|
||||
|
@ -242,7 +243,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
|||
if (n.isOnNext()) infoList.add(n.getValue());
|
||||
}
|
||||
|
||||
return subscriptionService.upsertAll(infoList);
|
||||
return subscriptionManager.upsertAll(infoList);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ public enum UserAction {
|
|||
REQUESTED_PLAYLIST("requested playlist"),
|
||||
REQUESTED_KIOSK("requested kiosk"),
|
||||
REQUESTED_COMMENTS("requested comments"),
|
||||
REQUESTED_FEED("requested feed"),
|
||||
DELETE_FROM_HISTORY("delete from history"),
|
||||
PLAY_STREAM("Play stream"),
|
||||
DOWNLOAD_POSTPROCESSING("download post-processing"),
|
||||
|
|
|
@ -23,7 +23,7 @@ package org.schabi.newpipe.settings;
|
|||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Environment;
|
||||
import android.preference.PreferenceManager;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
|
|
@ -18,9 +18,9 @@ import com.nostra13.universalimageloader.core.ImageLoader;
|
|||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
import org.schabi.newpipe.report.ErrorActivity;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Vector;
|
||||
|
@ -99,8 +99,8 @@ public class SelectChannelFragment extends DialogFragment {
|
|||
emptyView.setVisibility(View.GONE);
|
||||
|
||||
|
||||
SubscriptionService subscriptionService = SubscriptionService.getInstance(getContext());
|
||||
subscriptionService.getSubscription().toObservable()
|
||||
SubscriptionManager subscriptionManager = new SubscriptionManager(getContext());
|
||||
subscriptionManager.subscriptions().toObservable()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getSubscriptionObserver());
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
package org.schabi.newpipe.settings.custom
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.preference.ListPreference
|
||||
import org.schabi.newpipe.util.Localization
|
||||
|
||||
/**
|
||||
* An extension of a common ListPreference where it sets the duration values to human readable strings.
|
||||
*
|
||||
* The values in the entry values array will be interpreted as seconds. If the value of a specific position
|
||||
* is less than or equals to zero, its original entry title will be used.
|
||||
*
|
||||
* If the entry values array have anything other than numbers in it, an exception will be raised.
|
||||
*/
|
||||
class DurationListPreference : ListPreference {
|
||||
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
|
||||
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
|
||||
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
|
||||
constructor(context: Context?) : super(context)
|
||||
|
||||
override fun onAttached() {
|
||||
super.onAttached()
|
||||
|
||||
val originalEntryTitles = entries
|
||||
val originalEntryValues = entryValues
|
||||
val newEntryTitles = arrayOfNulls<CharSequence>(originalEntryValues.size)
|
||||
|
||||
for (i in originalEntryValues.indices) {
|
||||
val currentDurationValue: Int
|
||||
try {
|
||||
currentDurationValue = (originalEntryValues[i] as String).toInt()
|
||||
} catch (e: NumberFormatException) {
|
||||
throw RuntimeException("Invalid number was set in the preference entry values array", e)
|
||||
}
|
||||
|
||||
if (currentDurationValue <= 0) {
|
||||
newEntryTitles[i] = originalEntryTitles[i]
|
||||
} else {
|
||||
newEntryTitles[i] = Localization.localizeDuration(context, currentDurationValue)
|
||||
}
|
||||
}
|
||||
|
||||
entries = newEntryTitles
|
||||
}
|
||||
}
|
|
@ -218,7 +218,7 @@ public abstract class Tab {
|
|||
|
||||
@Override
|
||||
public String getTabName(Context context) {
|
||||
return context.getString(R.string.fragment_whats_new);
|
||||
return context.getString(R.string.fragment_feed_title);
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
package org.schabi.newpipe.util
|
||||
|
||||
/**
|
||||
* Default duration when using throttle functions across the app, in milliseconds.
|
||||
*/
|
||||
const val DEFAULT_THROTTLE_TIMEOUT = 120L
|
|
@ -31,18 +31,23 @@ import org.schabi.newpipe.ReCaptchaActivity;
|
|||
import org.schabi.newpipe.extractor.Info;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage;
|
||||
import org.schabi.newpipe.extractor.ListInfo;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfo;
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||
import org.schabi.newpipe.extractor.feed.FeedExtractor;
|
||||
import org.schabi.newpipe.extractor.feed.FeedInfo;
|
||||
import org.schabi.newpipe.extractor.kiosk.KioskInfo;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||
import org.schabi.newpipe.extractor.search.SearchInfo;
|
||||
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
|
||||
import org.schabi.newpipe.report.ErrorActivity;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
|
||||
|
@ -131,6 +136,22 @@ public final class ExtractorHelper {
|
|||
ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextStreamsUrl));
|
||||
}
|
||||
|
||||
public static Single<ListInfo<StreamInfoItem>> getFeedInfoFallbackToChannelInfo(final int serviceId,
|
||||
final String url) {
|
||||
final Maybe<ListInfo<StreamInfoItem>> maybeFeedInfo = Maybe.fromCallable(() -> {
|
||||
final StreamingService service = NewPipe.getService(serviceId);
|
||||
final FeedExtractor feedExtractor = service.getFeedExtractor(url);
|
||||
|
||||
if (feedExtractor == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FeedInfo.getInfo(feedExtractor);
|
||||
});
|
||||
|
||||
return maybeFeedInfo.switchIfEmpty(getChannelInfo(serviceId, url, true));
|
||||
}
|
||||
|
||||
public static Single<CommentsInfo> getCommentsInfo(final int serviceId,
|
||||
final String url,
|
||||
boolean forceLoad) {
|
||||
|
|
|
@ -213,6 +213,42 @@ public class Localization {
|
|||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Localize an amount of seconds into a human readable string.
|
||||
*
|
||||
* <p>The seconds will be converted to the closest whole time unit.
|
||||
* <p>For example, 60 seconds would give "1 minute", 119 would also give "1 minute".
|
||||
*
|
||||
* @param context used to get plurals resources.
|
||||
* @param durationInSecs an amount of seconds.
|
||||
* @return duration in a human readable string.
|
||||
*/
|
||||
@NonNull
|
||||
public static String localizeDuration(Context context, int durationInSecs) {
|
||||
if (durationInSecs < 0) {
|
||||
throw new IllegalArgumentException("duration can not be negative");
|
||||
}
|
||||
|
||||
final int days = (int) (durationInSecs / (24 * 60 * 60L)); /* greater than a day */
|
||||
durationInSecs %= (24 * 60 * 60L);
|
||||
final int hours = (int) (durationInSecs / (60 * 60L)); /* greater than an hour */
|
||||
durationInSecs %= (60 * 60L);
|
||||
final int minutes = (int) (durationInSecs / 60L);
|
||||
final int seconds = (int) (durationInSecs % 60L);
|
||||
|
||||
final Resources resources = context.getResources();
|
||||
|
||||
if (days > 0) {
|
||||
return resources.getQuantityString(R.plurals.days, days, days);
|
||||
} else if (hours > 0) {
|
||||
return resources.getQuantityString(R.plurals.hours, hours, hours);
|
||||
} else if (minutes > 0) {
|
||||
return resources.getQuantityString(R.plurals.minutes, minutes, minutes);
|
||||
} else {
|
||||
return resources.getQuantityString(R.plurals.seconds, seconds, seconds);
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Pretty Time
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
|
|
@ -23,6 +23,7 @@ import org.schabi.newpipe.MainActivity;
|
|||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.RouterActivity;
|
||||
import org.schabi.newpipe.about.AboutActivity;
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
|
||||
import org.schabi.newpipe.download.DownloadActivity;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
|
@ -343,9 +344,13 @@ public class NavigationHelper {
|
|||
.commit();
|
||||
}
|
||||
|
||||
public static void openWhatsNewFragment(FragmentManager fragmentManager) {
|
||||
public static void openFeedFragment(FragmentManager fragmentManager) {
|
||||
openFeedFragment(fragmentManager, FeedGroupEntity.GROUP_ALL_ID, null);
|
||||
}
|
||||
|
||||
public static void openFeedFragment(FragmentManager fragmentManager, long groupId, @Nullable String groupName) {
|
||||
defaultTransaction(fragmentManager)
|
||||
.replace(R.id.fragment_holder, new FeedFragment())
|
||||
.replace(R.id.fragment_holder, FeedFragment.newInstance(groupId, groupName))
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
|
|
|
@ -99,6 +99,17 @@ public class ThemeHelper {
|
|||
return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a min-width dialog theme styled according to the (default) selected theme.
|
||||
*
|
||||
* @param context context to get the selected theme
|
||||
* @return the dialog style (the default one)
|
||||
*/
|
||||
@StyleRes
|
||||
public static int getMinWidthDialogTheme(Context context) {
|
||||
return isLightThemeSelected(context) ? R.style.LightDialogMinWidthTheme : R.style.DarkDialogMinWidthTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the selected theme styled according to the serviceId.
|
||||
*
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true" android:drawable="@color/selected_background_color"/>
|
||||
<item android:drawable="@color/transparent_background_color"/>
|
||||
</selector>
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/black_border_color"
|
||||
android:dashGap="4dp"
|
||||
android:dashWidth="4dp"/>
|
||||
</shape>
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/dark_border_color"
|
||||
android:dashGap="4dp"
|
||||
android:dashWidth="4dp"/>
|
||||
</shape>
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/light_border_color"
|
||||
android:dashGap="4dp"
|
||||
android:dashWidth="4dp"/>
|
||||
</shape>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M10,2H14L13.21,9.91L19.66,5.27L21.66,8.73L14.42,12L21.66,15.27L19.66,18.73L13.21,14.09L14,22H10L10.79,14.09L4.34,18.73L2.34,15.27L9.58,12L2.34,8.73L4.34,5.27L10.79,9.91L10,2Z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M10,2H14L13.21,9.91L19.66,5.27L21.66,8.73L14.42,12L21.66,15.27L19.66,18.73L13.21,14.09L14,22H10L10.79,14.09L4.34,18.73L2.34,15.27L9.58,12L2.34,8.73L4.34,5.27L10.79,9.91L10,2Z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99zM6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16zM17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM5,11l1.5,-4.5h11L19,11L5,11z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99zM6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16zM17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM5,11l1.5,-4.5h11L19,11L5,11z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M21,2L3,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h7v2L8,20v2h8v-2h-2v-2h7c1.1,0 2,-0.9 2,-2L23,4c0,-1.1 -0.9,-2 -2,-2zM21,16L3,16L3,4h18v12z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M21,2L3,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h7v2L8,20v2h8v-2h-2v-2h7c1.1,0 2,-0.9 2,-2L23,4c0,-1.1 -0.9,-2 -2,-2zM21,16L3,16L3,4h18v12z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM15.5,11c0.83,0 1.5,-0.67 1.5,-1.5S16.33,8 15.5,8 14,8.67 14,9.5s0.67,1.5 1.5,1.5zM8.5,11c0.83,0 1.5,-0.67 1.5,-1.5S9.33,8 8.5,8 7,8.67 7,9.5 7.67,11 8.5,11zM12,17.5c2.33,0 4.31,-1.46 5.11,-3.5L6.89,14c0.8,2.04 2.78,3.5 5.11,3.5z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM15.5,11c0.83,0 1.5,-0.67 1.5,-1.5S16.33,8 15.5,8 14,8.67 14,9.5s0.67,1.5 1.5,1.5zM8.5,11c0.83,0 1.5,-0.67 1.5,-1.5S9.33,8 8.5,8 7,8.67 7,9.5 7.67,11 8.5,11zM12,17.5c2.33,0 4.31,-1.46 5.11,-3.5L6.89,14c0.8,2.04 2.78,3.5 5.11,3.5z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,10.9c-0.61,0 -1.1,0.49 -1.1,1.1s0.49,1.1 1.1,1.1c0.61,0 1.1,-0.49 1.1,-1.1s-0.49,-1.1 -1.1,-1.1zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM14.19,14.19L6,18l3.81,-8.19L18,6l-3.81,8.19z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,10.9c-0.61,0 -1.1,0.49 -1.1,1.1s0.49,1.1 1.1,1.1c0.61,0 1.1,-0.49 1.1,-1.1s-0.49,-1.1 -1.1,-1.1zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM14.19,14.19L6,18l3.81,-8.19L18,6l-3.81,8.19z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M18.06,22.99h1.66c0.84,0 1.53,-0.64 1.63,-1.46L23,5.05h-5L18,1h-1.97v4.05h-4.97l0.3,2.34c1.71,0.47 3.31,1.32 4.27,2.26 1.44,1.42 2.43,2.89 2.43,5.29v8.05zM1,21.99L1,21h15.03v0.99c0,0.55 -0.45,1 -1.01,1L2.01,22.99c-0.56,0 -1.01,-0.45 -1.01,-1zM16.03,14.99c0,-8 -15.03,-8 -15.03,0h15.03zM1.02,17h15v2h-15z"/>
|
||||
</vector>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue