mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2024-12-22 16:10:31 +00:00
Merge pull request #2335 from nv95/feature/notifications
New streams notifications
This commit is contained in:
commit
2623f0e360
@ -102,6 +102,7 @@ ext {
|
||||
|
||||
androidxLifecycleVersion = '2.3.1'
|
||||
androidxRoomVersion = '2.4.2'
|
||||
androidxWorkVersion = '2.7.1'
|
||||
|
||||
icepickVersion = '3.2.0'
|
||||
exoPlayerVersion = '2.14.2'
|
||||
@ -220,8 +221,10 @@ dependencies {
|
||||
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||
implementation 'androidx.webkit:webkit:1.4.0'
|
||||
implementation 'androidx.work:work-runtime:2.7.1'
|
||||
implementation 'com.google.android.material:material:1.5.0'
|
||||
implementation "androidx.work:work-runtime:${androidxWorkVersion}"
|
||||
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
|
||||
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
|
||||
|
||||
/** Third-party libraries **/
|
||||
// Instance state boilerplate elimination
|
||||
|
719
app/schemas/org.schabi.newpipe.database.AppDatabase/5.json
Normal file
719
app/schemas/org.schabi.newpipe.database.AppDatabase/5.json
Normal file
@ -0,0 +1,719 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 5,
|
||||
"identityHash": "096731b513bb71dd44517639f4a2c1e3",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "subscriptions",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "avatarUrl",
|
||||
"columnName": "avatar_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriberCount",
|
||||
"columnName": "subscriber_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationMode",
|
||||
"columnName": "notification_mode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_subscriptions_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "search_history",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "streams",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "streamType",
|
||||
"columnName": "stream_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploader",
|
||||
"columnName": "uploader",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploaderUrl",
|
||||
"columnName": "uploader_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "viewCount",
|
||||
"columnName": "view_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "textualUploadDate",
|
||||
"columnName": "textual_upload_date",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploadDate",
|
||||
"columnName": "upload_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isUploadDateApproximation",
|
||||
"columnName": "is_upload_date_approximation",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_streams_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "stream_history",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "streamUid",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accessDate",
|
||||
"columnName": "access_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "repeatCount",
|
||||
"columnName": "repeat_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"access_date"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_stream_history_stream_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "stream_state",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "streamUid",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "progressMillis",
|
||||
"columnName": "progress_time",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"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 IF NOT EXISTS `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 IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)"
|
||||
},
|
||||
{
|
||||
"name": "index_playlist_stream_join_stream_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "playlists",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"playlist_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "remote_playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `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 IF NOT EXISTS `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 IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "feed",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "streamId",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscription_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_subscription_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "feed_group",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon",
|
||||
"columnName": "icon_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_group_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "feed_group_subscription_join",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "feedGroupId",
|
||||
"columnName": "group_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscription_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"group_id",
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_group_subscription_join_subscription_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "feed_group",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"group_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "feed_last_updated",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscription_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUpdated",
|
||||
"columnName": "last_updated",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"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, '096731b513bb71dd44517639f4a2c1e3')"
|
||||
]
|
||||
}
|
||||
}
|
@ -0,0 +1,130 @@
|
||||
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 DatabaseMigrationTest {
|
||||
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"
|
||||
|
||||
private const val DEFAULT_SECOND_SERVICE_ID = 0
|
||||
private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc"
|
||||
}
|
||||
|
||||
@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("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)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"streams", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SECOND_SERVICE_ID)
|
||||
put("url", DEFAULT_SECOND_URL)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"streams", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SERVICE_ID)
|
||||
}
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME, Migrations.DB_VER_3,
|
||||
true, Migrations.MIGRATION_2_3
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME, Migrations.DB_VER_4,
|
||||
true, Migrations.MIGRATION_3_4
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME, Migrations.DB_VER_5,
|
||||
true, Migrations.MIGRATION_4_5
|
||||
)
|
||||
|
||||
val migratedDatabaseV3 = getMigratedDatabase()
|
||||
val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
|
||||
|
||||
// Only expect 2, the one with the null url will be ignored
|
||||
assertEquals(2, listFromDB.size)
|
||||
|
||||
val streamFromMigratedDatabase = listFromDB[0]
|
||||
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)
|
||||
|
||||
val secondStreamFromMigratedDatabase = listFromDB[1]
|
||||
assertEquals(DEFAULT_SECOND_SERVICE_ID, secondStreamFromMigratedDatabase.serviceId)
|
||||
assertEquals(DEFAULT_SECOND_URL, secondStreamFromMigratedDatabase.url)
|
||||
assertEquals("", secondStreamFromMigratedDatabase.title)
|
||||
// Should fallback to VIDEO_STREAM
|
||||
assertEquals(StreamType.VIDEO_STREAM, secondStreamFromMigratedDatabase.streamType)
|
||||
assertEquals(0, secondStreamFromMigratedDatabase.duration)
|
||||
assertEquals("", secondStreamFromMigratedDatabase.uploader)
|
||||
assertEquals("", secondStreamFromMigratedDatabase.thumbnailUrl)
|
||||
assertNull(secondStreamFromMigratedDatabase.viewCount)
|
||||
assertNull(secondStreamFromMigratedDatabase.textualUploadDate)
|
||||
assertNull(secondStreamFromMigratedDatabase.uploadDate)
|
||||
assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation)
|
||||
}
|
||||
|
||||
private fun getMigratedDatabase(): AppDatabase {
|
||||
val database: AppDatabase = Room.databaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
AppDatabase::class.java, AppDatabase.DATABASE_NAME
|
||||
)
|
||||
.build()
|
||||
testHelper.closeWhenFinished(database)
|
||||
return database
|
||||
}
|
||||
}
|
@ -27,7 +27,7 @@ import org.schabi.newpipe.util.StateSaver;
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.net.SocketException;
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@ -213,37 +213,44 @@ public class App extends MultiDexApplication {
|
||||
private void initNotificationChannels() {
|
||||
// Keep the importance below DEFAULT to avoid making noise on every notification update for
|
||||
// the main and update channels
|
||||
final NotificationChannelCompat mainChannel = new NotificationChannelCompat
|
||||
final List<NotificationChannelCompat> notificationChannelCompats = new ArrayList<>();
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.notification_channel_name))
|
||||
.setDescription(getString(R.string.notification_channel_description))
|
||||
.build();
|
||||
.build());
|
||||
|
||||
final NotificationChannelCompat appUpdateChannel = new NotificationChannelCompat
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.app_update_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.app_update_notification_channel_name))
|
||||
.setDescription(getString(R.string.app_update_notification_channel_description))
|
||||
.build();
|
||||
.build());
|
||||
|
||||
final NotificationChannelCompat hashChannel = new NotificationChannelCompat
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.hash_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(getString(R.string.hash_channel_name))
|
||||
.setDescription(getString(R.string.hash_channel_description))
|
||||
.build();
|
||||
.build());
|
||||
|
||||
final NotificationChannelCompat errorReportChannel = new NotificationChannelCompat
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.error_report_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.error_report_channel_name))
|
||||
.setDescription(getString(R.string.error_report_channel_description))
|
||||
.build();
|
||||
.build());
|
||||
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.streams_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||
.setName(getString(R.string.streams_notification_channel_name))
|
||||
.setDescription(getString(R.string.streams_notification_channel_description))
|
||||
.build());
|
||||
|
||||
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
|
||||
notificationManager.createNotificationChannelsCompat(Arrays.asList(mainChannel,
|
||||
appUpdateChannel, hashChannel, errorReportChannel));
|
||||
notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
|
||||
}
|
||||
|
||||
protected boolean isDisposedRxExceptionsReported() {
|
||||
|
@ -71,6 +71,7 @@ import org.schabi.newpipe.fragments.BackPressable;
|
||||
import org.schabi.newpipe.fragments.MainFragment;
|
||||
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
|
||||
import org.schabi.newpipe.fragments.list.search.SearchFragment;
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.event.OnKeyDownListener;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
@ -158,11 +159,14 @@ public class MainActivity extends AppCompatActivity {
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e);
|
||||
}
|
||||
|
||||
if (DeviceUtils.isTv(this)) {
|
||||
FocusOverlayView.setupFocusObserver(this);
|
||||
}
|
||||
openMiniPlayerUponPlayerStarted();
|
||||
|
||||
// Schedule worker for checking for new streams and creating corresponding notifications
|
||||
// if this is enabled by the user.
|
||||
NotificationWorker.initialize(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -1,5 +1,11 @@
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
@ -8,11 +14,6 @@ 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_1_2;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
|
||||
|
||||
public final class NewPipeDatabase {
|
||||
private static volatile AppDatabase databaseInstance;
|
||||
|
||||
@ -23,7 +24,7 @@ public final class NewPipeDatabase {
|
||||
private static AppDatabase getDatabase(final Context context) {
|
||||
return Room
|
||||
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
package org.schabi.newpipe.database;
|
||||
|
||||
import static org.schabi.newpipe.database.Migrations.DB_VER_5;
|
||||
|
||||
import androidx.room.Database;
|
||||
import androidx.room.RoomDatabase;
|
||||
import androidx.room.TypeConverters;
|
||||
@ -27,8 +29,6 @@ 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_4;
|
||||
|
||||
@TypeConverters({Converters.class})
|
||||
@Database(
|
||||
entities = {
|
||||
@ -38,7 +38,7 @@ import static org.schabi.newpipe.database.Migrations.DB_VER_4;
|
||||
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
|
||||
FeedLastUpdatedEntity.class
|
||||
},
|
||||
version = DB_VER_4
|
||||
version = DB_VER_5
|
||||
)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
public static final String DATABASE_NAME = "newpipe.db";
|
||||
|
@ -22,6 +22,7 @@ public final class Migrations {
|
||||
public static final int DB_VER_2 = 2;
|
||||
public static final int DB_VER_3 = 3;
|
||||
public static final int DB_VER_4 = 4;
|
||||
public static final int DB_VER_5 = 5;
|
||||
|
||||
private static final String TAG = Migrations.class.getName();
|
||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||
@ -179,5 +180,14 @@ public final class Migrations {
|
||||
}
|
||||
};
|
||||
|
||||
private Migrations() { }
|
||||
public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
|
||||
+ "INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
};
|
||||
|
||||
private Migrations() {
|
||||
}
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@ -252,4 +253,21 @@ abstract class FeedDAO {
|
||||
"""
|
||||
)
|
||||
abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@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)
|
||||
AND s.notification_mode = :notificationMode
|
||||
"""
|
||||
)
|
||||
abstract fun getOutdatedWithNotificationMode(
|
||||
outdatedThreshold: OffsetDateTime,
|
||||
@NotificationMode notificationMode: Int
|
||||
): Flowable<List<SubscriptionEntity>>
|
||||
}
|
||||
|
@ -39,6 +39,9 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long>
|
||||
|
||||
@Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId")
|
||||
internal abstract fun exists(serviceId: Int, url: String): Boolean
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration
|
||||
|
@ -0,0 +1,14 @@
|
||||
package org.schabi.newpipe.database.subscription;
|
||||
|
||||
import androidx.annotation.IntDef;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED})
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
public @interface NotificationMode {
|
||||
|
||||
int DISABLED = 0;
|
||||
int ENABLED = 1;
|
||||
//other values reserved for the future
|
||||
}
|
@ -26,6 +26,7 @@ public class SubscriptionEntity {
|
||||
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";
|
||||
public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode";
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
private long uid = 0;
|
||||
@ -48,6 +49,9 @@ public class SubscriptionEntity {
|
||||
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
|
||||
private String description;
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
|
||||
private int notificationMode;
|
||||
|
||||
@Ignore
|
||||
public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
|
||||
final SubscriptionEntity result = new SubscriptionEntity();
|
||||
@ -114,6 +118,15 @@ public class SubscriptionEntity {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
@NotificationMode
|
||||
public int getNotificationMode() {
|
||||
return notificationMode;
|
||||
}
|
||||
|
||||
public void setNotificationMode(@NotificationMode final int notificationMode) {
|
||||
this.notificationMode = notificationMode;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public void setData(final String n, final String au, final String d, final Long sc) {
|
||||
this.setName(n);
|
||||
|
@ -26,6 +26,7 @@ public enum UserAction {
|
||||
DOWNLOAD_OPEN_DIALOG("download open dialog"),
|
||||
DOWNLOAD_POSTPROCESSING("download post-processing"),
|
||||
DOWNLOAD_FAILED("download failed"),
|
||||
NEW_STREAMS_NOTIFICATIONS("new streams notifications"),
|
||||
PREFERENCES_MIGRATION("migration of preferences"),
|
||||
SHARE_TO_NEWPIPE("share to newpipe"),
|
||||
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
|
||||
|
@ -5,6 +5,7 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
@ -22,9 +23,11 @@ import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
import com.jakewharton.rxbinding4.view.RxView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.databinding.ChannelHeaderBinding;
|
||||
import org.schabi.newpipe.databinding.FragmentChannelBinding;
|
||||
@ -39,6 +42,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.ktx.AnimationType;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
|
||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
||||
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
@ -84,6 +88,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
private PlaylistControlBinding playlistControlBinding;
|
||||
|
||||
private MenuItem menuRssButton;
|
||||
private MenuItem menuNotifyButton;
|
||||
|
||||
public static ChannelFragment getInstance(final int serviceId, final String url,
|
||||
final String name) {
|
||||
@ -179,6 +184,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
}
|
||||
menuRssButton = menu.findItem(R.id.menu_item_rss);
|
||||
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,6 +194,11 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
case R.id.action_settings:
|
||||
NavigationHelper.openSettings(requireContext());
|
||||
break;
|
||||
case R.id.menu_item_notify:
|
||||
final boolean value = !item.isChecked();
|
||||
item.setEnabled(false);
|
||||
setNotify(value);
|
||||
break;
|
||||
case R.id.menu_item_rss:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInBrowser(
|
||||
@ -232,15 +243,22 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
.subscribe(getSubscribeUpdateMonitor(info), onError));
|
||||
|
||||
disposables.add(observable
|
||||
// Some updates are very rapid
|
||||
// (for example when calling the updateSubscription(info))
|
||||
// so only update the UI for the latest emission
|
||||
// ("sync" the subscribe button's state)
|
||||
.debounce(100, TimeUnit.MILLISECONDS)
|
||||
.map(List::isEmpty)
|
||||
.distinctUntilChanged()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe((List<SubscriptionEntity> subscriptionEntities) ->
|
||||
updateSubscribeButton(!subscriptionEntities.isEmpty()), onError));
|
||||
.subscribe(isEmpty -> updateSubscribeButton(!isEmpty), onError));
|
||||
|
||||
disposables.add(observable
|
||||
.map(List::isEmpty)
|
||||
.distinctUntilChanged()
|
||||
.skip(1) // channel has just been opened
|
||||
.filter(x -> NotificationHelper.areNewStreamsNotificationsEnabled(requireContext()))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(isEmpty -> {
|
||||
if (!isEmpty) {
|
||||
showNotifySnackbar();
|
||||
}
|
||||
}, onError));
|
||||
}
|
||||
|
||||
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription,
|
||||
@ -320,6 +338,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
info.getAvatarUrl(),
|
||||
info.getDescription(),
|
||||
info.getSubscriberCount());
|
||||
updateNotifyButton(null);
|
||||
subscribeButtonMonitor = monitorSubscribeButton(
|
||||
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
|
||||
} else {
|
||||
@ -327,6 +346,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
Log.d(TAG, "Found subscription to this channel!");
|
||||
}
|
||||
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
||||
updateNotifyButton(subscription);
|
||||
subscribeButtonMonitor = monitorSubscribeButton(
|
||||
headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription));
|
||||
}
|
||||
@ -369,6 +389,45 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
AnimationType.LIGHT_SCALE_AND_ALPHA);
|
||||
}
|
||||
|
||||
private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) {
|
||||
if (menuNotifyButton == null) {
|
||||
return;
|
||||
}
|
||||
if (subscription != null) {
|
||||
menuNotifyButton.setEnabled(
|
||||
NotificationHelper.areNewStreamsNotificationsEnabled(requireContext())
|
||||
);
|
||||
menuNotifyButton.setChecked(
|
||||
subscription.getNotificationMode() == NotificationMode.ENABLED
|
||||
);
|
||||
}
|
||||
|
||||
menuNotifyButton.setVisible(subscription != null);
|
||||
}
|
||||
|
||||
private void setNotify(final boolean isEnabled) {
|
||||
disposables.add(
|
||||
subscriptionManager
|
||||
.updateNotificationMode(
|
||||
currentInfo.getServiceId(),
|
||||
currentInfo.getUrl(),
|
||||
isEnabled ? NotificationMode.ENABLED : NotificationMode.DISABLED)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a snackbar with the option to enable notifications on new streams for this channel.
|
||||
*/
|
||||
private void showNotifySnackbar() {
|
||||
Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.get_notified, v -> setNotify(true))
|
||||
.setActionTextColor(Color.YELLOW)
|
||||
.show();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Load and handle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
@ -14,6 +14,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||
@ -57,6 +58,11 @@ class FeedDatabaseManager(context: Context) {
|
||||
|
||||
fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold)
|
||||
|
||||
fun outdatedSubscriptionsWithNotificationMode(
|
||||
outdatedThreshold: OffsetDateTime,
|
||||
@NotificationMode notificationMode: Int
|
||||
) = feedTable.getOutdatedWithNotificationMode(outdatedThreshold, notificationMode)
|
||||
|
||||
fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<Long> {
|
||||
return when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount()
|
||||
@ -72,6 +78,10 @@ class FeedDatabaseManager(context: Context) {
|
||||
fun markAsOutdated(subscriptionId: Long) = feedTable
|
||||
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
|
||||
|
||||
fun doesStreamExist(stream: StreamInfoItem): Boolean {
|
||||
return streamTable.exists(stream.serviceId, stream.url)
|
||||
}
|
||||
|
||||
fun upsertAll(
|
||||
subscriptionId: Long,
|
||||
items: List<StreamInfoItem>,
|
||||
|
@ -0,0 +1,145 @@
|
||||
package org.schabi.newpipe.local.feed.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.PicassoHelper
|
||||
|
||||
/**
|
||||
* Helper for everything related to show notifications about new streams to the user.
|
||||
*/
|
||||
class NotificationHelper(val context: Context) {
|
||||
|
||||
private val manager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
|
||||
/**
|
||||
* Show a notification about new streams from a single channel.
|
||||
* Opening the notification will open the corresponding channel page.
|
||||
*/
|
||||
fun displayNewStreamsNotification(data: FeedUpdateInfo) {
|
||||
val newStreams: List<StreamInfoItem> = data.newStreams
|
||||
val summary = context.resources.getQuantityString(
|
||||
R.plurals.new_streams, newStreams.size, newStreams.size
|
||||
)
|
||||
val builder = NotificationCompat.Builder(
|
||||
context,
|
||||
context.getString(R.string.streams_notification_channel_id)
|
||||
)
|
||||
.setContentTitle(Localization.concatenateStrings(data.name, summary))
|
||||
.setContentText(
|
||||
data.listInfo.relatedItems.joinToString(
|
||||
context.getString(R.string.enumeration_comma)
|
||||
) { x -> x.name }
|
||||
)
|
||||
.setNumber(newStreams.size)
|
||||
.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
|
||||
.setColorized(true)
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
|
||||
// Build style
|
||||
val style = NotificationCompat.InboxStyle()
|
||||
newStreams.forEach { style.addLine(it.name) }
|
||||
style.setSummaryText(summary)
|
||||
style.setBigContentTitle(data.name)
|
||||
builder.setStyle(style)
|
||||
|
||||
// open the channel page when clicking on the notification
|
||||
builder.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
data.pseudoId,
|
||||
NavigationHelper
|
||||
.getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
else
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
PicassoHelper.loadNotificationIcon(data.avatarUrl) { bitmap ->
|
||||
bitmap?.let { builder.setLargeIcon(it) } // set only if != null
|
||||
manager.notify(data.pseudoId, builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Check whether notifications are enabled on the device.
|
||||
* Users can disable them via the system settings for a single app.
|
||||
* If this is the case, the app cannot create any notifications
|
||||
* and display them to the user.
|
||||
* <br>
|
||||
* On Android 26 and above, notification channels are used by NewPipe.
|
||||
* These can be configured by the user, too.
|
||||
* The notification channel for new streams is also checked by this method.
|
||||
*
|
||||
* @param context Context
|
||||
* @return <code>true</code> if notifications are allowed and can be displayed;
|
||||
* <code>false</code> otherwise
|
||||
*/
|
||||
fun areNotificationsEnabledOnDevice(context: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channelId = context.getString(R.string.streams_notification_channel_id)
|
||||
val manager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
val enabled = manager.areNotificationsEnabled()
|
||||
val channel = manager.getNotificationChannel(channelId)
|
||||
val importance = channel?.importance
|
||||
enabled && channel != null && importance != NotificationManager.IMPORTANCE_NONE
|
||||
} else {
|
||||
NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user enabled the notifications for new streams in the app settings.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun areNewStreamsNotificationsEnabled(context: Context): Boolean {
|
||||
return (
|
||||
PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getBoolean(context.getString(R.string.enable_streams_notifications), false) &&
|
||||
areNotificationsEnabledOnDevice(context)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the system's notification settings for NewPipe on Android Oreo (API 26) and later.
|
||||
* Open the system's app settings for NewPipe on previous Android versions.
|
||||
*/
|
||||
fun openNewPipeSystemNotificationSettings(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = Uri.parse("package:" + context.packageName)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,170 @@
|
||||
package org.schabi.newpipe.local.feed.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.rxjava3.RxWorker
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.schabi.newpipe.App
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.ErrorUtil
|
||||
import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.local.feed.service.FeedLoadManager
|
||||
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/*
|
||||
* Worker which checks for new streams of subscribed channels
|
||||
* in intervals which can be set by the user in the settings.
|
||||
*/
|
||||
class NotificationWorker(
|
||||
appContext: Context,
|
||||
workerParams: WorkerParameters,
|
||||
) : RxWorker(appContext, workerParams) {
|
||||
|
||||
private val notificationHelper by lazy {
|
||||
NotificationHelper(appContext)
|
||||
}
|
||||
private val feedLoadManager = FeedLoadManager(appContext)
|
||||
|
||||
override fun createWork(): Single<Result> = if (areNotificationsEnabled(applicationContext)) {
|
||||
feedLoadManager.startLoading(
|
||||
ignoreOutdatedThreshold = true,
|
||||
groupId = FeedLoadManager.GROUP_NOTIFICATION_ENABLED
|
||||
)
|
||||
.doOnSubscribe { showLoadingFeedForegroundNotification() }
|
||||
.map { feed ->
|
||||
// filter out feedUpdateInfo items (i.e. channels) with nothing new
|
||||
feed.mapNotNull {
|
||||
it.value?.takeIf { feedUpdateInfo ->
|
||||
feedUpdateInfo.newStreams.isNotEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread()) // Picasso requires calls from main thread
|
||||
.map { feedUpdateInfoList ->
|
||||
// display notifications for each feedUpdateInfo (i.e. channel)
|
||||
feedUpdateInfoList.forEach { feedUpdateInfo ->
|
||||
notificationHelper.displayNewStreamsNotification(feedUpdateInfo)
|
||||
}
|
||||
return@map Result.success()
|
||||
}
|
||||
.doOnError { throwable ->
|
||||
Log.e(TAG, "Error while displaying streams notifications", throwable)
|
||||
ErrorUtil.createNotification(
|
||||
applicationContext,
|
||||
ErrorInfo(throwable, UserAction.NEW_STREAMS_NOTIFICATIONS, "main worker")
|
||||
)
|
||||
}
|
||||
.onErrorReturnItem(Result.failure())
|
||||
} else {
|
||||
// the user can disable streams notifications in the device's app settings
|
||||
Single.just(Result.success())
|
||||
}
|
||||
|
||||
private fun showLoadingFeedForegroundNotification() {
|
||||
val notification = NotificationCompat.Builder(
|
||||
applicationContext,
|
||||
applicationContext.getString(R.string.notification_channel_id)
|
||||
).setOngoing(true)
|
||||
.setProgress(-1, -1, true)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setContentTitle(applicationContext.getString(R.string.feed_notification_loading))
|
||||
.build()
|
||||
setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification))
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = NotificationWorker::class.java.simpleName
|
||||
private const val WORK_TAG = App.PACKAGE_NAME + "_streams_notifications"
|
||||
|
||||
private fun areNotificationsEnabled(context: Context) =
|
||||
NotificationHelper.areNewStreamsNotificationsEnabled(context) &&
|
||||
NotificationHelper.areNotificationsEnabledOnDevice(context)
|
||||
|
||||
/**
|
||||
* Schedules a task for the [NotificationWorker]
|
||||
* if the (device and in-app) notifications are enabled,
|
||||
* otherwise [cancel]s all scheduled tasks.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun initialize(context: Context) {
|
||||
if (areNotificationsEnabled(context)) {
|
||||
schedule(context)
|
||||
} else {
|
||||
cancel(context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param context the context to use
|
||||
* @param options configuration options for the scheduler
|
||||
* @param force Force the scheduler to use the new options
|
||||
* by replacing the previously used worker.
|
||||
*/
|
||||
fun schedule(context: Context, options: ScheduleOptions, force: Boolean = false) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(
|
||||
if (options.isRequireNonMeteredNetwork) {
|
||||
NetworkType.UNMETERED
|
||||
} else {
|
||||
NetworkType.CONNECTED
|
||||
}
|
||||
).build()
|
||||
|
||||
val request = PeriodicWorkRequest.Builder(
|
||||
NotificationWorker::class.java,
|
||||
options.interval,
|
||||
TimeUnit.MILLISECONDS
|
||||
).setConstraints(constraints)
|
||||
.addTag(WORK_TAG)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(
|
||||
WORK_TAG,
|
||||
if (force) {
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
} else {
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
},
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun schedule(context: Context) = schedule(context, ScheduleOptions.from(context))
|
||||
|
||||
/**
|
||||
* Check for new streams immediately
|
||||
*/
|
||||
@JvmStatic
|
||||
fun runNow(context: Context) {
|
||||
val request = OneTimeWorkRequestBuilder<NotificationWorker>()
|
||||
.addTag(WORK_TAG)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueue(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all current work related to the [NotificationWorker].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package org.schabi.newpipe.local.feed.notifications
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.schabi.newpipe.R
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Information for the Scheduler which checks for new streams.
|
||||
* See [NotificationWorker]
|
||||
*/
|
||||
data class ScheduleOptions(
|
||||
val interval: Long,
|
||||
val isRequireNonMeteredNetwork: Boolean
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(context: Context): ScheduleOptions {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return ScheduleOptions(
|
||||
interval = TimeUnit.SECONDS.toMillis(
|
||||
preferences.getString(
|
||||
context.getString(R.string.streams_notifications_interval_key),
|
||||
null
|
||||
)?.toLongOrNull() ?: context.getString(
|
||||
R.string.streams_notifications_interval_default
|
||||
).toLong()
|
||||
),
|
||||
isRequireNonMeteredNetwork = preferences.getString(
|
||||
context.getString(R.string.streams_notifications_network_key),
|
||||
context.getString(R.string.streams_notifications_network_default)
|
||||
) == context.getString(R.string.streams_notifications_network_wifi)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,270 @@
|
||||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Notification
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.functions.Consumer
|
||||
import io.reactivex.rxjava3.processors.PublishProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class FeedLoadManager(private val context: Context) {
|
||||
|
||||
private val subscriptionManager = SubscriptionManager(context)
|
||||
private val feedDatabaseManager = FeedDatabaseManager(context)
|
||||
|
||||
private val notificationUpdater = PublishProcessor.create<String>()
|
||||
private val currentProgress = AtomicInteger(-1)
|
||||
private val maxProgress = AtomicInteger(-1)
|
||||
private val cancelSignal = AtomicBoolean()
|
||||
private val feedResultsHolder = FeedResultsHolder()
|
||||
|
||||
val notification: Flowable<FeedLoadState> = notificationUpdater.map { description ->
|
||||
FeedLoadState(description, maxProgress.get(), currentProgress.get())
|
||||
}
|
||||
|
||||
/**
|
||||
* Start checking for new streams of a subscription group.
|
||||
* @param groupId The ID of the subscription group to load. When using
|
||||
* [FeedGroupEntity.GROUP_ALL_ID], all subscriptions are loaded. When using
|
||||
* [GROUP_NOTIFICATION_ENABLED], only subscriptions with enabled notifications for new streams
|
||||
* are loaded. Using an id of a group created by the user results in that specific group to be
|
||||
* loaded.
|
||||
* @param ignoreOutdatedThreshold When `false`, only subscriptions which have not been updated
|
||||
* within the `feed_update_threshold` are checked for updates. This threshold can be set by
|
||||
* the user in the app settings. When `true`, all subscriptions are checked for new streams.
|
||||
*/
|
||||
fun startLoading(
|
||||
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||
ignoreOutdatedThreshold: Boolean = false,
|
||||
): Single<List<Notification<FeedUpdateInfo>>> {
|
||||
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val useFeedExtractor = defaultSharedPreferences.getBoolean(
|
||||
context.getString(R.string.feed_use_dedicated_fetch_method_key),
|
||||
false
|
||||
)
|
||||
|
||||
val outdatedThreshold = if (ignoreOutdatedThreshold) {
|
||||
OffsetDateTime.now(ZoneOffset.UTC)
|
||||
} else {
|
||||
val thresholdOutdatedSeconds = (
|
||||
defaultSharedPreferences.getString(
|
||||
context.getString(R.string.feed_update_threshold_key),
|
||||
context.getString(R.string.feed_update_threshold_default_value)
|
||||
) ?: context.getString(R.string.feed_update_threshold_default_value)
|
||||
).toInt()
|
||||
OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
|
||||
}
|
||||
|
||||
/**
|
||||
* subscriptions which have not been updated within the feed updated threshold
|
||||
*/
|
||||
val outdatedSubscriptions = when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
|
||||
GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode(
|
||||
outdatedThreshold, NotificationMode.ENABLED
|
||||
)
|
||||
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
|
||||
}
|
||||
|
||||
return outdatedSubscriptions
|
||||
.take(1)
|
||||
.doOnNext {
|
||||
currentProgress.set(0)
|
||||
maxProgress.set(it.size)
|
||||
}
|
||||
.filter { it.isNotEmpty() }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext {
|
||||
notificationUpdater.onNext("")
|
||||
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 ->
|
||||
var error: Throwable? = null
|
||||
try {
|
||||
// check for and load new streams
|
||||
// either by using the dedicated feed method or by getting the channel info
|
||||
val listInfo = if (useFeedExtractor) {
|
||||
ExtractorHelper
|
||||
.getFeedInfoFallbackToChannelInfo(
|
||||
subscriptionEntity.serviceId,
|
||||
subscriptionEntity.url
|
||||
)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} else {
|
||||
ExtractorHelper
|
||||
.getChannelInfo(
|
||||
subscriptionEntity.serviceId,
|
||||
subscriptionEntity.url,
|
||||
true
|
||||
)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} as ListInfo<StreamInfoItem>
|
||||
|
||||
return@map Notification.createOnNext(
|
||||
FeedUpdateInfo(
|
||||
subscriptionEntity,
|
||||
listInfo
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
if (error == null) {
|
||||
// do this to prevent blockingGet() from wrapping into RuntimeException
|
||||
error = e
|
||||
}
|
||||
|
||||
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||
val wrapper =
|
||||
FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!)
|
||||
return@map Notification.createOnError<FeedUpdateInfo>(wrapper)
|
||||
}
|
||||
}
|
||||
.sequential()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext(NotificationConsumer())
|
||||
.observeOn(Schedulers.io())
|
||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.doOnNext(DatabaseConsumer())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.toList()
|
||||
.flatMap { x -> postProcessFeed().toSingleDefault(x.flatten()) }
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
cancelSignal.set(true)
|
||||
}
|
||||
|
||||
private fun broadcastProgress() {
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the feed and the stream tables small
|
||||
* to reduce loading times when trying to display the feed.
|
||||
* <br>
|
||||
* Remove streams from the feed which are older than [FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE].
|
||||
* Remove streams from the database which are not linked / used by any table.
|
||||
*/
|
||||
private fun postProcessFeed() = Completable.fromRunnable {
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
|
||||
feedDatabaseManager.removeOrphansOrOlderStreams()
|
||||
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.SuccessResultEvent(feedResultsHolder.itemsErrors))
|
||||
}.doOnSubscribe {
|
||||
currentProgress.set(-1)
|
||||
maxProgress.set(-1)
|
||||
|
||||
notificationUpdater.onNext(context.getString(R.string.feed_processing_message))
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
|
||||
}.subscribeOn(Schedulers.io())
|
||||
|
||||
private inner class NotificationConsumer : Consumer<Notification<FeedUpdateInfo>> {
|
||||
override fun accept(item: Notification<FeedUpdateInfo>) {
|
||||
currentProgress.incrementAndGet()
|
||||
notificationUpdater.onNext(item.value?.name.orEmpty())
|
||||
|
||||
broadcastProgress()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DatabaseConsumer : Consumer<List<Notification<FeedUpdateInfo>>> {
|
||||
|
||||
override fun accept(list: List<Notification<FeedUpdateInfo>>) {
|
||||
feedDatabaseManager.database().runInTransaction {
|
||||
for (notification in list) {
|
||||
when {
|
||||
notification.isOnNext -> {
|
||||
val subscriptionId = notification.value!!.uid
|
||||
val info = notification.value!!.listInfo
|
||||
|
||||
notification.value!!.newStreams = filterNewStreams(
|
||||
notification.value!!.listInfo.relatedItems
|
||||
)
|
||||
|
||||
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
||||
subscriptionManager.updateFromInfo(subscriptionId, info)
|
||||
|
||||
if (info.errors.isNotEmpty()) {
|
||||
feedResultsHolder.addErrors(
|
||||
FeedLoadService.RequestException.wrapList(
|
||||
subscriptionId,
|
||||
info
|
||||
)
|
||||
)
|
||||
feedDatabaseManager.markAsOutdated(subscriptionId)
|
||||
}
|
||||
}
|
||||
notification.isOnError -> {
|
||||
val error = notification.error
|
||||
feedResultsHolder.addError(error!!)
|
||||
|
||||
if (error is FeedLoadService.RequestException) {
|
||||
feedDatabaseManager.markAsOutdated(error.subscriptionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterNewStreams(list: List<StreamInfoItem>): List<StreamInfoItem> {
|
||||
return list.filter {
|
||||
!feedDatabaseManager.doesStreamExist(it) &&
|
||||
it.uploadDate != null &&
|
||||
// Streams older than this date are automatically removed from the feed.
|
||||
// Therefore, streams which are not in the database,
|
||||
// but older than this date, are considered old.
|
||||
it.uploadDate!!.offsetDateTime().isAfter(
|
||||
FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Constant used to check for updates of subscriptions with [NotificationMode.ENABLED].
|
||||
*/
|
||||
const val GROUP_NOTIFICATION_ENABLED = -2L
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
@ -31,41 +31,24 @@ import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Notification
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.functions.Consumer
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.functions.Function
|
||||
import io.reactivex.rxjava3.processors.PublishProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.reactivestreams.Subscriber
|
||||
import org.reactivestreams.Subscription
|
||||
import org.schabi.newpipe.App
|
||||
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.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResultEvent
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class FeedLoadService : Service() {
|
||||
companion object {
|
||||
private val TAG = FeedLoadService::class.java.simpleName
|
||||
private const val NOTIFICATION_ID = 7293450
|
||||
const val NOTIFICATION_ID = 7293450
|
||||
private const val ACTION_CANCEL = App.PACKAGE_NAME + ".local.feed.service.FeedLoadService.CANCEL"
|
||||
|
||||
/**
|
||||
@ -73,27 +56,13 @@ class FeedLoadService : Service() {
|
||||
*/
|
||||
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 var loadingDisposable: Disposable? = null
|
||||
private var notificationDisposable: Disposable? = null
|
||||
|
||||
private lateinit var feedDatabaseManager: FeedDatabaseManager
|
||||
private lateinit var feedResultsHolder: ResultsHolder
|
||||
|
||||
private var disposables = CompositeDisposable()
|
||||
private var notificationUpdater = PublishProcessor.create<String>()
|
||||
private lateinit var feedLoadManager: FeedLoadManager
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
// Lifecycle
|
||||
@ -101,8 +70,7 @@ class FeedLoadService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
subscriptionManager = SubscriptionManager(this)
|
||||
feedDatabaseManager = FeedDatabaseManager(this)
|
||||
feedLoadManager = FeedLoadManager(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@ -114,40 +82,45 @@ class FeedLoadService : Service() {
|
||||
)
|
||||
}
|
||||
|
||||
if (intent == null || loadingSubscription != null) {
|
||||
if (intent == null || loadingDisposable != 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)
|
||||
|
||||
loadingDisposable = feedLoadManager.startLoading(groupId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnSubscribe {
|
||||
startForeground(NOTIFICATION_ID, notificationBuilder.build())
|
||||
}
|
||||
.subscribe { _, error ->
|
||||
// There seems to be a bug in the kotlin plugin as it tells you when
|
||||
// building that this can't be null:
|
||||
// "Condition 'error != null' is always 'true'"
|
||||
// However it can indeed be null
|
||||
// The suppression may be removed in further versions
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
if (error != null) {
|
||||
Log.e(TAG, "Error while storing result", error)
|
||||
handleError(error)
|
||||
return@subscribe
|
||||
}
|
||||
stopService()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun disposeAll() {
|
||||
unregisterReceiver(broadcastReceiver)
|
||||
|
||||
loadingSubscription?.cancel()
|
||||
loadingSubscription = null
|
||||
|
||||
disposables.dispose()
|
||||
loadingDisposable?.dispose()
|
||||
notificationDisposable?.dispose()
|
||||
}
|
||||
|
||||
private fun stopService() {
|
||||
disposeAll()
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
@ -171,182 +144,6 @@ class FeedLoadService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) {
|
||||
feedResultsHolder = ResultsHolder()
|
||||
|
||||
val outdatedThreshold = OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
|
||||
|
||||
val subscriptions = when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
|
||||
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
|
||||
}
|
||||
|
||||
subscriptions
|
||||
.take(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 ->
|
||||
var error: Throwable? = null
|
||||
try {
|
||||
val listInfo = if (useFeedExtractor) {
|
||||
ExtractorHelper
|
||||
.getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} else {
|
||||
ExtractorHelper
|
||||
.getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} as ListInfo<StreamInfoItem>
|
||||
|
||||
return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
|
||||
} catch (e: Throwable) {
|
||||
if (error == null) {
|
||||
// do this to prevent blockingGet() from wrapping into RuntimeException
|
||||
error = e
|
||||
}
|
||||
|
||||
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||
val wrapper = RequestException(subscriptionEntity.uid, request, error!!)
|
||||
return@map Notification.createOnError<Pair<Long, ListInfo<StreamInfoItem>>>(wrapper)
|
||||
}
|
||||
}
|
||||
.sequential()
|
||||
.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(FeedEventManager.Event.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 ->
|
||||
// There seems to be a bug in the kotlin plugin as it tells you when
|
||||
// building that this can't be null:
|
||||
// "Condition 'throwable != null' is always 'true'"
|
||||
// However it can indeed be null
|
||||
// The suppression may be removed in further versions
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
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 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
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
@ -354,13 +151,12 @@ class FeedLoadService : Service() {
|
||||
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
|
||||
NOTIFICATION_ID,
|
||||
Intent(ACTION_CANCEL),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
|
||||
@ -376,33 +172,36 @@ class FeedLoadService : Service() {
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
notificationBuilder = createNotification()
|
||||
|
||||
val throttleAfterFirstEmission = Function { flow: Flowable<String> ->
|
||||
val throttleAfterFirstEmission = Function { flow: Flowable<FeedLoadState> ->
|
||||
flow.take(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
|
||||
}
|
||||
|
||||
disposables.add(
|
||||
notificationUpdater
|
||||
.publish(throttleAfterFirstEmission)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(this::updateNotificationProgress)
|
||||
)
|
||||
notificationDisposable = feedLoadManager.notification
|
||||
.publish(throttleAfterFirstEmission)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnTerminate { notificationManager.cancel(NOTIFICATION_ID) }
|
||||
.subscribe(this::updateNotificationProgress)
|
||||
}
|
||||
|
||||
private fun updateNotificationProgress(updateDescription: String?) {
|
||||
notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1)
|
||||
private fun updateNotificationProgress(state: FeedLoadState) {
|
||||
notificationBuilder.setProgress(state.maxProgress, state.currentProgress, state.maxProgress == -1)
|
||||
|
||||
if (maxProgress.get() == -1) {
|
||||
if (state.maxProgress == -1) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null)
|
||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
||||
notificationBuilder.setContentText(updateDescription)
|
||||
if (state.updateDescription.isNotEmpty()) notificationBuilder.setContentText(state.updateDescription)
|
||||
notificationBuilder.setContentText(state.updateDescription)
|
||||
} else {
|
||||
val progressText = this.currentProgress.toString() + "/" + maxProgress
|
||||
val progressText = state.currentProgress.toString() + "/" + state.maxProgress
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)")
|
||||
if (state.updateDescription.isNotEmpty()) {
|
||||
notificationBuilder.setContentText("${state.updateDescription} ($progressText)")
|
||||
}
|
||||
} else {
|
||||
notificationBuilder.setContentInfo(progressText)
|
||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
||||
if (state.updateDescription.isNotEmpty()) {
|
||||
notificationBuilder.setContentText(state.updateDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -414,13 +213,12 @@ class FeedLoadService : Service() {
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
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)
|
||||
feedLoadManager.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -435,29 +233,4 @@ class FeedLoadService : Service() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
data class FeedLoadState(
|
||||
val updateDescription: String,
|
||||
val maxProgress: Int,
|
||||
val currentProgress: Int,
|
||||
)
|
@ -0,0 +1,19 @@
|
||||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
class FeedResultsHolder {
|
||||
/**
|
||||
* List of errors that may have happen during loading.
|
||||
*/
|
||||
val itemsErrors: List<Throwable>
|
||||
get() = itemsErrorsHolder
|
||||
|
||||
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
|
||||
|
||||
fun addError(error: Throwable) {
|
||||
itemsErrorsHolder.add(error)
|
||||
}
|
||||
|
||||
fun addErrors(errors: List<Throwable>) {
|
||||
itemsErrorsHolder.addAll(errors)
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
|
||||
data class FeedUpdateInfo(
|
||||
val uid: Long,
|
||||
@NotificationMode
|
||||
val notificationMode: Int,
|
||||
val name: String,
|
||||
val avatarUrl: String,
|
||||
val listInfo: ListInfo<StreamInfoItem>,
|
||||
) {
|
||||
constructor(
|
||||
subscription: SubscriptionEntity,
|
||||
listInfo: ListInfo<StreamInfoItem>,
|
||||
) : this(
|
||||
uid = subscription.uid,
|
||||
notificationMode = subscription.notificationMode,
|
||||
name = subscription.name,
|
||||
avatarUrl = subscription.avatarUrl,
|
||||
listInfo = listInfo,
|
||||
)
|
||||
|
||||
/**
|
||||
* Integer id, can be used as notification id, etc.
|
||||
*/
|
||||
val pseudoId: Int
|
||||
get() = listInfo.url.hashCode()
|
||||
|
||||
lateinit var newStreams: List<StreamInfoItem>
|
||||
}
|
@ -7,6 +7,8 @@ import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
@ -14,6 +16,7 @@ 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
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
|
||||
class SubscriptionManager(context: Context) {
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
@ -66,13 +69,33 @@ class SubscriptionManager(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable {
|
||||
return subscriptionTable().getSubscription(serviceId, url)
|
||||
.flatMapCompletable { entity: SubscriptionEntity ->
|
||||
Completable.fromAction {
|
||||
entity.notificationMode = mode
|
||||
subscriptionTable().update(entity)
|
||||
}.apply {
|
||||
if (mode != NotificationMode.DISABLED) {
|
||||
// notifications have just been enabled, mark all streams as "old"
|
||||
andThen(rememberAllStreams(entity))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
subscriptionEntity.setData(
|
||||
info.name,
|
||||
info.avatarUrl,
|
||||
info.description,
|
||||
info.subscriberCount
|
||||
)
|
||||
}
|
||||
|
||||
subscriptionTable.update(subscriptionEntity)
|
||||
@ -94,4 +117,19 @@ class SubscriptionManager(context: Context) {
|
||||
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
|
||||
subscriptionTable.delete(subscriptionEntity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the list of videos for the provided channel and saves them in the database, so that
|
||||
* they will be considered as "old"/"already seen" streams and the user will never be notified
|
||||
* about any one of them.
|
||||
*/
|
||||
private fun rememberAllStreams(subscription: SubscriptionEntity): Completable {
|
||||
return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false)
|
||||
.map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } }
|
||||
.flatMapCompletable { entities ->
|
||||
Completable.fromAction {
|
||||
database.streamDAO().upsertAll(entities)
|
||||
}
|
||||
}.onErrorComplete()
|
||||
}
|
||||
}
|
||||
|
@ -50,7 +50,7 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment {
|
||||
|
||||
@Override
|
||||
public boolean onPreferenceTreeClick(final Preference preference) {
|
||||
if (preference.getKey().equals(getString(R.string.caption_settings_key))) {
|
||||
if (getString(R.string.caption_settings_key).equals(preference.getKey())) {
|
||||
try {
|
||||
startActivity(new Intent(Settings.ACTION_CAPTIONING_SETTINGS));
|
||||
} catch (final ActivityNotFoundException e) {
|
||||
|
@ -10,6 +10,7 @@ import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@ -26,6 +27,8 @@ public class DebugSettingsFragment extends BasePreferenceFragment {
|
||||
= findPreference(getString(R.string.show_memory_leaks_key));
|
||||
final Preference showImageIndicatorsPreference
|
||||
= findPreference(getString(R.string.show_image_indicators_key));
|
||||
final Preference checkNewStreamsPreference
|
||||
= findPreference(getString(R.string.check_new_streams_key));
|
||||
final Preference crashTheAppPreference
|
||||
= findPreference(getString(R.string.crash_the_app_key));
|
||||
final Preference showErrorSnackbarPreference
|
||||
@ -36,6 +39,7 @@ public class DebugSettingsFragment extends BasePreferenceFragment {
|
||||
assert allowHeapDumpingPreference != null;
|
||||
assert showMemoryLeaksPreference != null;
|
||||
assert showImageIndicatorsPreference != null;
|
||||
assert checkNewStreamsPreference != null;
|
||||
assert crashTheAppPreference != null;
|
||||
assert showErrorSnackbarPreference != null;
|
||||
assert createErrorNotificationPreference != null;
|
||||
@ -62,6 +66,11 @@ public class DebugSettingsFragment extends BasePreferenceFragment {
|
||||
return true;
|
||||
});
|
||||
|
||||
checkNewStreamsPreference.setOnPreferenceClickListener(preference -> {
|
||||
NotificationWorker.runNow(preference.getContext());
|
||||
return true;
|
||||
});
|
||||
|
||||
crashTheAppPreference.setOnPreferenceClickListener(preference -> {
|
||||
throw new RuntimeException(DUMMY);
|
||||
});
|
||||
|
@ -70,7 +70,7 @@ public final class NewPipeSettings {
|
||||
PreferenceManager.setDefaultValues(context, R.xml.appearance_settings, true);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.history_settings, true);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.content_settings, true);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.notification_settings, true);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.player_notification_settings, true);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.update_settings, true);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true);
|
||||
|
||||
|
@ -0,0 +1,120 @@
|
||||
package org.schabi.newpipe.settings
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.ErrorUtil
|
||||
import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationHelper
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationWorker
|
||||
import org.schabi.newpipe.local.feed.notifications.ScheduleOptions
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
|
||||
class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferenceChangeListener {
|
||||
|
||||
private var notificationWarningSnackbar: Snackbar? = null
|
||||
private var loader: Disposable? = null
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.notifications_settings)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
defaultPreferences.registerOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
defaultPreferences.unregisterOnSharedPreferenceChangeListener(this)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
val context = context ?: return
|
||||
if (key == getString(R.string.streams_notifications_interval_key) ||
|
||||
key == getString(R.string.streams_notifications_network_key)
|
||||
) {
|
||||
// apply new configuration
|
||||
NotificationWorker.schedule(context, ScheduleOptions.from(context), true)
|
||||
} else if (key == getString(R.string.enable_streams_notifications)) {
|
||||
if (NotificationHelper.areNewStreamsNotificationsEnabled(context)) {
|
||||
// Start the worker, because notifications were disabled previously.
|
||||
NotificationWorker.schedule(context)
|
||||
} else {
|
||||
// The user disabled the notifications. Cancel the worker to save energy.
|
||||
// A new one will be created once the notifications are enabled again.
|
||||
NotificationWorker.cancel(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
// Check whether the notifications are disabled in the device's app settings.
|
||||
// If they are disabled, show a snackbar informing the user about that
|
||||
// while allowing them to open the device's app settings.
|
||||
val enabled = NotificationHelper.areNotificationsEnabledOnDevice(requireContext())
|
||||
preferenceScreen.isEnabled = enabled
|
||||
if (!enabled) {
|
||||
if (notificationWarningSnackbar == null) {
|
||||
notificationWarningSnackbar = Snackbar.make(
|
||||
listView,
|
||||
R.string.notifications_disabled,
|
||||
Snackbar.LENGTH_INDEFINITE
|
||||
).apply {
|
||||
setAction(R.string.settings) {
|
||||
NotificationHelper.openNewPipeSystemNotificationSettings(it.context)
|
||||
}
|
||||
setActionTextColor(Color.YELLOW)
|
||||
addCallback(object : Snackbar.Callback() {
|
||||
override fun onDismissed(transientBottomBar: Snackbar, event: Int) {
|
||||
super.onDismissed(transientBottomBar, event)
|
||||
notificationWarningSnackbar = null
|
||||
}
|
||||
})
|
||||
show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
notificationWarningSnackbar?.dismiss()
|
||||
notificationWarningSnackbar = null
|
||||
}
|
||||
|
||||
// (Re-)Create loader
|
||||
loader?.dispose()
|
||||
loader = SubscriptionManager(requireContext())
|
||||
.subscriptions()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(this::updateSubscriptions, this::onError)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
loader?.dispose()
|
||||
loader = null
|
||||
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
private fun updateSubscriptions(subscriptions: List<SubscriptionEntity>) {
|
||||
val notified = subscriptions.count { it.notificationMode != NotificationMode.DISABLED }
|
||||
val preference = findPreference<Preference>(getString(R.string.streams_notifications_channels_key))
|
||||
preference?.apply { summary = "$notified/${subscriptions.size}" }
|
||||
}
|
||||
|
||||
private fun onError(e: Throwable) {
|
||||
ErrorUtil.showSnackbar(
|
||||
this,
|
||||
ErrorInfo(e, UserAction.SUBSCRIPTION_GET, "Get subscriptions list")
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package org.schabi.newpipe.settings
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.preference.Preference
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
class PlayerNotificationSettingsFragment : BasePreferenceFragment() {
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResourceRegistry()
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
val colorizePref: Preference? = findPreference(getString(R.string.notification_colorize_key))
|
||||
colorizePref?.let {
|
||||
preferenceScreen.removePreference(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -36,7 +36,8 @@ public final class SettingsResourceRegistry {
|
||||
add(DebugSettingsFragment.class, R.xml.debug_settings).setSearchable(false);
|
||||
add(DownloadSettingsFragment.class, R.xml.download_settings);
|
||||
add(HistorySettingsFragment.class, R.xml.history_settings);
|
||||
add(NotificationSettingsFragment.class, R.xml.notification_settings);
|
||||
add(NotificationSettingsFragment.class, R.xml.notifications_settings);
|
||||
add(PlayerNotificationSettingsFragment.class, R.xml.player_notification_settings);
|
||||
add(UpdateSettingsFragment.class, R.xml.update_settings);
|
||||
add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings);
|
||||
}
|
||||
|
@ -0,0 +1,124 @@
|
||||
package org.schabi.newpipe.settings.notifications
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckedTextView
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.SubscriptionHolder
|
||||
|
||||
/**
|
||||
* This [RecyclerView.Adapter] is used in the [NotificationModeConfigFragment].
|
||||
* The adapter holds all subscribed channels and their [NotificationMode]s
|
||||
* and provides the needed data structures and methods for this task.
|
||||
*/
|
||||
class NotificationModeConfigAdapter(
|
||||
private val listener: ModeToggleListener
|
||||
) : RecyclerView.Adapter<SubscriptionHolder>() {
|
||||
|
||||
private val differ = AsyncListDiffer(this, DiffCallback())
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): SubscriptionHolder {
|
||||
val view = LayoutInflater.from(viewGroup.context)
|
||||
.inflate(R.layout.item_notification_config, viewGroup, false)
|
||||
return SubscriptionHolder(view, listener)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(subscriptionHolder: SubscriptionHolder, i: Int) {
|
||||
subscriptionHolder.bind(differ.currentList[i])
|
||||
}
|
||||
|
||||
fun getItem(position: Int): SubscriptionItem = differ.currentList[position]
|
||||
|
||||
override fun getItemCount() = differ.currentList.size
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return differ.currentList[position].id
|
||||
}
|
||||
|
||||
fun getCurrentList(): List<SubscriptionItem> = differ.currentList
|
||||
|
||||
fun update(newData: List<SubscriptionEntity>) {
|
||||
differ.submitList(
|
||||
newData.map {
|
||||
SubscriptionItem(
|
||||
id = it.uid,
|
||||
title = it.name,
|
||||
notificationMode = it.notificationMode,
|
||||
serviceId = it.serviceId,
|
||||
url = it.url
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
data class SubscriptionItem(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
@NotificationMode
|
||||
val notificationMode: Int,
|
||||
val serviceId: Int,
|
||||
val url: String
|
||||
)
|
||||
|
||||
class SubscriptionHolder(
|
||||
itemView: View,
|
||||
private val listener: ModeToggleListener
|
||||
) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
|
||||
|
||||
private val checkedTextView = itemView as CheckedTextView
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener(this)
|
||||
}
|
||||
|
||||
fun bind(data: SubscriptionItem) {
|
||||
checkedTextView.text = data.title
|
||||
checkedTextView.isChecked = data.notificationMode != NotificationMode.DISABLED
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
val mode = if (checkedTextView.isChecked) {
|
||||
NotificationMode.DISABLED
|
||||
} else {
|
||||
NotificationMode.ENABLED
|
||||
}
|
||||
listener.onModeChange(bindingAdapterPosition, mode)
|
||||
}
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<SubscriptionItem>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: SubscriptionItem, newItem: SubscriptionItem): Any? {
|
||||
if (oldItem.notificationMode != newItem.notificationMode) {
|
||||
return newItem.notificationMode
|
||||
} else {
|
||||
return super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ModeToggleListener {
|
||||
/**
|
||||
* Triggered when the UI representation of a notification mode is changed.
|
||||
*/
|
||||
fun onModeChange(position: Int, @NotificationMode mode: Int)
|
||||
}
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
package org.schabi.newpipe.settings.notifications
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.ModeToggleListener
|
||||
|
||||
/**
|
||||
* [NotificationModeConfigFragment] is a settings fragment
|
||||
* which allows changing the [NotificationMode] of all subscribed channels.
|
||||
* The [NotificationMode] can either be changed one by one or toggled for all channels.
|
||||
*/
|
||||
class NotificationModeConfigFragment : Fragment(), ModeToggleListener {
|
||||
|
||||
private lateinit var updaters: CompositeDisposable
|
||||
private var loader: Disposable? = null
|
||||
private var adapter: NotificationModeConfigAdapter? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
updaters = CompositeDisposable()
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View = inflater.inflate(R.layout.fragment_channels_notifications, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val recyclerView: RecyclerView = view.findViewById(R.id.recycler_view)
|
||||
adapter = NotificationModeConfigAdapter(this)
|
||||
recyclerView.adapter = adapter
|
||||
loader?.dispose()
|
||||
loader = SubscriptionManager(requireContext())
|
||||
.subscriptions()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { newData -> adapter?.update(newData) }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
loader?.dispose()
|
||||
loader = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
updaters.dispose()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
inflater.inflate(R.menu.menu_notifications_channels, menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_toggle_all -> {
|
||||
toggleAll()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onModeChange(position: Int, @NotificationMode mode: Int) {
|
||||
// Notification mode has been changed via the UI.
|
||||
// Now change it in the database.
|
||||
val subscription = adapter?.getItem(position) ?: return
|
||||
updaters.add(
|
||||
SubscriptionManager(requireContext())
|
||||
.updateNotificationMode(
|
||||
subscription.serviceId,
|
||||
subscription.url,
|
||||
mode
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
)
|
||||
}
|
||||
|
||||
private fun toggleAll() {
|
||||
val subscriptions = adapter?.getCurrentList() ?: return
|
||||
val mode = subscriptions.firstOrNull()?.notificationMode ?: return
|
||||
val newMode = when (mode) {
|
||||
NotificationMode.DISABLED -> NotificationMode.ENABLED
|
||||
else -> NotificationMode.DISABLED
|
||||
}
|
||||
val subscriptionManager = SubscriptionManager(requireContext())
|
||||
updaters.add(
|
||||
CompositeDisposable(
|
||||
subscriptions.map { item ->
|
||||
subscriptionManager.updateNotificationMode(
|
||||
serviceId = item.serviceId,
|
||||
url = item.url,
|
||||
mode = newMode
|
||||
).subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
@ -18,6 +20,8 @@ import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix;
|
||||
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
@ -58,10 +62,6 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp;
|
||||
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix;
|
||||
|
||||
public final class NavigationHelper {
|
||||
public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag";
|
||||
public static final String SEARCH_FRAGMENT_TAG = "search_fragment_tag";
|
||||
@ -611,6 +611,12 @@ public final class NavigationHelper {
|
||||
return getOpenIntent(context, url, service.getServiceId(), linkType);
|
||||
}
|
||||
|
||||
public static Intent getChannelIntent(final Context context,
|
||||
final int serviceId,
|
||||
final String url) {
|
||||
return getOpenIntent(context, url, serviceId, StreamingService.LinkType.CHANNEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an activity to install Kore.
|
||||
*
|
||||
|
@ -1,14 +1,18 @@
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
||||
import com.squareup.picasso.Cache;
|
||||
import com.squareup.picasso.LruCache;
|
||||
import com.squareup.picasso.OkHttp3Downloader;
|
||||
import com.squareup.picasso.Picasso;
|
||||
import com.squareup.picasso.RequestCreator;
|
||||
import com.squareup.picasso.Target;
|
||||
import com.squareup.picasso.Transformation;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
@ -16,11 +20,10 @@ import org.schabi.newpipe.R;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
|
||||
public final class PicassoHelper {
|
||||
public static final String PLAYER_THUMBNAIL_TAG = "PICASSO_PLAYER_THUMBNAIL_TAG";
|
||||
private static final String PLAYER_THUMBNAIL_TRANSFORMATION_KEY
|
||||
@ -156,6 +159,28 @@ public final class PicassoHelper {
|
||||
}
|
||||
|
||||
|
||||
public static void loadNotificationIcon(final String url,
|
||||
final Consumer<Bitmap> bitmapConsumer) {
|
||||
loadImageDefault(url, R.drawable.ic_newpipe_triangle_white)
|
||||
.into(new Target() {
|
||||
@Override
|
||||
public void onBitmapLoaded(final Bitmap bitmap, final Picasso.LoadedFrom from) {
|
||||
bitmapConsumer.accept(bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBitmapFailed(final Exception e, final Drawable errorDrawable) {
|
||||
bitmapConsumer.accept(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareLoad(final Drawable placeHolderDrawable) {
|
||||
// Nothing to do
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private static RequestCreator loadImageDefault(final String url, final int placeholderResId) {
|
||||
if (!shouldLoadImages || isBlank(url)) {
|
||||
return picassoInstance
|
||||
|
9
app/src/main/res/drawable-night/ic_notifications.xml
Normal file
9
app/src/main/res/drawable-night/ic_notifications.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
|
||||
</vector>
|
10
app/src/main/res/drawable/ic_list_check.xml
Normal file
10
app/src/main/res/drawable/ic_list_check.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M3,5H9V11H3V5M5,7V9H7V7H5M11,7H21V9H11V7M11,15H21V17H11V15M5,20L1.5,16.5L2.91,15.09L5,17.17L9.59,12.59L11,14L5,20Z" />
|
||||
</vector>
|
9
app/src/main/res/drawable/ic_notifications.xml
Normal file
9
app/src/main/res/drawable/ic_notifications.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
|
||||
</vector>
|
14
app/src/main/res/layout/fragment_channels_notifications.xml
Normal file
14
app/src/main/res/layout/fragment_channels_notifications.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
|
||||
|
||||
</FrameLayout>
|
16
app/src/main/res/layout/item_notification_config.xml
Normal file
16
app/src/main/res/layout/item_notification_config.xml
Normal file
@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<CheckedTextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?listPreferredItemHeightSmall"
|
||||
android:background="?selectableItemBackground"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:maxLines="2"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="?listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
|
||||
android:drawableEnd="?android:listChoiceIndicatorMultiple"
|
||||
tools:text="@tools:sample/lorem[4]" />
|
@ -18,14 +18,23 @@
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_settings"
|
||||
android:id="@+id/menu_item_notify"
|
||||
android:checkable="true"
|
||||
android:orderInCategory="1"
|
||||
android:title="@string/get_notified"
|
||||
android:visible="false"
|
||||
app:showAsAction="never"
|
||||
tools:visible="true" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_settings"
|
||||
android:orderInCategory="2"
|
||||
android:title="@string/settings"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_item_openInBrowser"
|
||||
android:orderInCategory="2"
|
||||
android:orderInCategory="3"
|
||||
android:title="@string/open_in_browser"
|
||||
app:showAsAction="never" />
|
||||
</menu>
|
||||
|
10
app/src/main/res/menu/menu_notifications_channels.xml
Normal file
10
app/src/main/res/menu/menu_notifications_channels.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_toggle_all"
|
||||
android:icon="@drawable/ic_list_check"
|
||||
android:title="@string/toggle_all"
|
||||
app:showAsAction="ifRoom" />
|
||||
</menu>
|
@ -584,7 +584,6 @@
|
||||
<string name="wifi_only">فقط على شبكة Wi-Fi</string>
|
||||
<string name="autoplay_summary">بدء التشغيل تلقائياً — %s</string>
|
||||
<string name="title_activity_play_queue">تشغيل قائمة الانتظار</string>
|
||||
<string name="settings_category_notification_title">الإشعار</string>
|
||||
<string name="unsupported_url_dialog_message">تعذر التعرف على الرابط. فتح باستخدام تطبيق آخر؟</string>
|
||||
<string name="auto_queue_toggle">قائمة انتظار تلقائيّة</string>
|
||||
<string name="clear_queue_confirmation_description">سيتم استبدال قائمة انتظار للمشغل النشط</string>
|
||||
|
@ -104,7 +104,6 @@
|
||||
<string name="content">Məzmun</string>
|
||||
<string name="popup_playing_toast">Ani pəncərədə oxudulur</string>
|
||||
<string name="background_player_playing_toast">Fonda oxudulur</string>
|
||||
<string name="settings_category_notification_title">Bildiriş</string>
|
||||
<string name="settings_category_updates_title">Yeniləmələr</string>
|
||||
<string name="settings_category_debug_title">Sazlama</string>
|
||||
<string name="settings_category_appearance_title">Görünüş</string>
|
||||
|
@ -515,7 +515,6 @@
|
||||
<string name="youtube_restricted_mode_enabled_summary">YouTube forne\'l «Mou torgáu» qu\'anubre conteníu\'l que seya potencialmente p\'adultos</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Activar el «Mou torgáu» de YouTube</string>
|
||||
<string name="show_age_restricted_content_summary">Amuesa\'l conteníu que quiciabes nun seya afayadizu pa guaḥes porque tien una llende d\'edá (como +18)</string>
|
||||
<string name="settings_category_notification_title">Avisu permanente</string>
|
||||
<string name="settings_category_debug_title">Depuración</string>
|
||||
<string name="peertube_instance_add_https_only">Namás se sofiten URLs HTTPS</string>
|
||||
<string name="peertube_instance_add_help">Introduz la URL d\'una instancia</string>
|
||||
|
@ -136,7 +136,6 @@
|
||||
<string name="content">Tarkib</string>
|
||||
<string name="popup_playing_toast">Pop-up rejimda ijro etish</string>
|
||||
<string name="background_player_playing_toast">Ijro etish foni</string>
|
||||
<string name="settings_category_notification_title">Bildirishnoma</string>
|
||||
<string name="settings_category_updates_title">Yangilanishlar</string>
|
||||
<string name="settings_category_debug_title">Nosozliklarni tuzatish</string>
|
||||
<string name="settings_category_appearance_title">Tashqi ko\'rinish</string>
|
||||
|
@ -552,7 +552,6 @@
|
||||
<string name="notification_action_0_title">第一操作按钮</string>
|
||||
<string name="notification_scale_to_square_image_summary">将通知中视频缩略图的长宽比从 16:9 强制缩放到 1:1(可能会导致失真)</string>
|
||||
<string name="notification_scale_to_square_image_title">强制缩放缩略图至 1:1 比例</string>
|
||||
<string name="settings_category_notification_title">通知</string>
|
||||
<string name="show_memory_leaks">显示内存泄漏</string>
|
||||
<string name="enqueued">已加入播放队列</string>
|
||||
<string name="enqueue_stream">加入播放队列</string>
|
||||
|
@ -481,7 +481,6 @@
|
||||
<string name="notification_action_shuffle">Ператасаваць</string>
|
||||
<string name="notification_action_repeat">Паўтор</string>
|
||||
<string name="notification_action_4_title">Кнопка пятага дзеяння</string>
|
||||
<string name="settings_category_notification_title">Паведамленні</string>
|
||||
<string name="notification_colorize_summary">Афарбоўваць апавяшчэнне асноўным колерам мініяцюры. Падтрымваецца не ўсімі прыладамі</string>
|
||||
<string name="notification_actions_at_most_three">У кампактным апавяшчэнні дасяжна не больш за тры дзеянні!</string>
|
||||
<string name="notification_actions_summary">Дзеянні можна змяніць, націснуўшы на іх. Адзначце не больш за трох для адлюстравання ў кампактным апавяшчэнні</string>
|
||||
|
@ -511,7 +511,6 @@
|
||||
<string name="songs">Песни</string>
|
||||
<string name="artists">Изпълнители</string>
|
||||
<string name="albums">Албуми</string>
|
||||
<string name="settings_category_notification_title">Известие</string>
|
||||
<string name="recent">Скорошни</string>
|
||||
<string name="metadata_category">Категория</string>
|
||||
<string name="download_has_started">Изтеглянето започна</string>
|
||||
|
@ -289,7 +289,6 @@
|
||||
</plurals>
|
||||
<string name="description_tab_description">বিবরণ</string>
|
||||
<string name="comments_tab_description">মন্তব্য</string>
|
||||
<string name="settings_category_notification_title">নোটিফিকেশন</string>
|
||||
<string name="show_meta_info_title">মেটা ইনফো দেখান</string>
|
||||
<string name="show_description_title">বিবরণ দেখান</string>
|
||||
<string name="night_theme_title">রাত্রি থিম</string>
|
||||
|
@ -330,7 +330,6 @@
|
||||
<item quantity="other">%s সদস্যতাগণ</item>
|
||||
</plurals>
|
||||
<string name="users">ব্যবহারকারীরা</string>
|
||||
<string name="settings_category_notification_title">বিজ্ঞপ্তি</string>
|
||||
<string name="resume_on_audio_focus_gain_summary">বাধার পর প্লে চালিয়ে যাও (উদাহরণস্বরূপ ফোনকল)</string>
|
||||
<string name="subscriptions_export_unsuccessful">সদস্যতা রপ্তানি করা যায়নি</string>
|
||||
<string name="subscriptions_import_unsuccessful">সদস্যতা আমদানি করা যায়নি</string>
|
||||
|
@ -557,7 +557,6 @@
|
||||
<string name="hash_channel_name">Notificació de comprovació del vídeo</string>
|
||||
<string name="youtube_restricted_mode_enabled_summary">YouTube proporciona un \"mode restringit\" que amaga contingut potencialment inadequat per a infants</string>
|
||||
<string name="show_age_restricted_content_summary">Mostra contingut que podria ser inadequat pels infants</string>
|
||||
<string name="settings_category_notification_title">Notificació</string>
|
||||
<string name="unsupported_url_dialog_message">No s\'ha pogut reconèixer l\'adreça URL. Obrir-la amb una altra aplicació\?</string>
|
||||
<string name="auto_queue_toggle">Posa a la cua automàticament</string>
|
||||
<string name="show_meta_info_summary">Desactiveu-ho per deixar de mostrar les metadades, que contenen informació addicional sobre el creador del directe, el contingut o una sol·licitud de cerca</string>
|
||||
|
@ -541,7 +541,6 @@
|
||||
<string name="error_report_open_github_notice">تكایه پشكنینێك بكه كه ئاخۆ كێشهیهك ههیه باسی كڕاشهكهت بكات. لهكاتی سازدانی پلیتی لێكچوو ، كات له ئێمه دهگریت كه ئێمه سهرقاڵی چارهسهركردنی ههمان كێشه دهكهیت.</string>
|
||||
<string name="error_report_open_issue_button_text">سكاڵا لەسەر GitHub</string>
|
||||
<string name="copy_for_github">لهبهرگرتنهوهی سكاڵای جۆركراو</string>
|
||||
<string name="settings_category_notification_title">پەیام</string>
|
||||
<string name="unsupported_url_dialog_message">ناتوانرێت بهستهرهكه بناسرێتەوە. بە بەرنامەیەکی دیكه بکرێتەوە؟</string>
|
||||
<string name="auto_queue_toggle">خستنه نۆبهتی-خۆكاری</string>
|
||||
<string name="clear_queue_confirmation_description">نۆبهتهكه لە لێدەری چالاکەوە جێگۆڕکێی دەکرێت</string>
|
||||
|
@ -559,7 +559,6 @@
|
||||
<string name="clear_queue_confirmation_description">Fronta aktivního přehrávače bude smazána</string>
|
||||
<string name="clear_queue_confirmation_summary">Při přechodu z jednoho přehrávače do druhého může dojít k smazání fronty</string>
|
||||
<string name="clear_queue_confirmation_title">Žádat potvrzení před vyklizením fronty</string>
|
||||
<string name="settings_category_notification_title">Oznámení</string>
|
||||
<string name="notification_action_nothing">Nic</string>
|
||||
<string name="notification_action_buffering">Bufferovat</string>
|
||||
<string name="notification_action_shuffle">Promíchat</string>
|
||||
|
@ -342,6 +342,8 @@
|
||||
<string name="brightness_gesture_control_title">Gestensteuerung für Helligkeit</string>
|
||||
<string name="brightness_gesture_control_summary">Gesten verwenden, um die Helligkeit einzustellen</string>
|
||||
<string name="settings_category_updates_title">Aktualisierungen</string>
|
||||
<string name="settings_category_player_notification_title">Wiedergabebenachrichtigung</string>
|
||||
<string name="settings_category_player_notification_summary">Konfiguriert die Benachrichtigung zum aktuell abgespielten Stream</string>
|
||||
<string name="file_deleted">Datei gelöscht</string>
|
||||
<string name="app_update_notification_channel_name">Benachrichtigung über App-Update</string>
|
||||
<string name="app_update_notification_channel_description">Benachrichtigungen über neue NewPipe-Versionen</string>
|
||||
@ -550,7 +552,6 @@
|
||||
<string name="never">Nie</string>
|
||||
<string name="notification_actions_at_most_three">Du kannst maximal drei Aktionen auswählen, die in der Kompaktbenachrichtigung angezeigt werden sollen!</string>
|
||||
<string name="notification_actions_summary">Bearbeite jede Benachrichtigungsaktion unten, indem du darauf tippst. Wähle mithilfe der Kontrollkästchen rechts bis zu drei aus, die in der Kompaktbenachrichtigung angezeigt werden sollen</string>
|
||||
<string name="settings_category_notification_title">Benachrichtigung</string>
|
||||
<string name="unsupported_url_dialog_message">Konnte die angegebene URL nicht erkennen. Mit einer anderen Anwendung öffnen\?</string>
|
||||
<string name="notification_action_4_title">Fünfte Aktionstaste</string>
|
||||
<string name="notification_action_3_title">Vierte Aktionstaste</string>
|
||||
|
@ -483,7 +483,6 @@
|
||||
\n
|
||||
\nΕνεργοποιήστε το «%1$s» στις ρυθμίσεις εάν θέλετε να το δείτε.</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Λειτουργία περιορισμένης πρόσβασης του YouTube</string>
|
||||
<string name="settings_category_notification_title">Ειδοποίηση</string>
|
||||
<string name="unsupported_url_dialog_message">Δεν ήταν δυνατή η αναγνώριση της διεύθυνσης URL. Άνοιγμα με άλλη εφαρμογή;</string>
|
||||
<string name="auto_queue_toggle">Αυτόματη προσθήκη στην ουρά</string>
|
||||
<string name="clear_queue_confirmation_description">Η ουρά του ενεργού αναπαραγωγού θα αντικατασταθεί</string>
|
||||
|
@ -512,8 +512,7 @@
|
||||
<string name="restricted_video">Tiu ĉi filmeto havas aĝlimon.
|
||||
\n
|
||||
\nŜalti \"%1$s\" en la agordoj, se vi volas vidi ĝin.</string>
|
||||
<string name="settings_category_notification_title">Sciigo</string>
|
||||
<string name="night_theme_title">Nokta etoso</string>
|
||||
<string name="night_theme_title">Malhela etoso</string>
|
||||
<string name="notification_colorize_title">farbi sciigon</string>
|
||||
<string name="notification_action_buffering">Alŝuto</string>
|
||||
<string name="notification_scale_to_square_image_title">Skali bildeton ĝis 1:1 proportio</string>
|
||||
|
@ -549,7 +549,6 @@
|
||||
<string name="wifi_only">Solo en Wi-Fi</string>
|
||||
<string name="autoplay_summary">Comenzar reproducción automáticamente — %s</string>
|
||||
<string name="title_activity_play_queue">Reproducir cola</string>
|
||||
<string name="settings_category_notification_title">Notificación</string>
|
||||
<string name="unsupported_url_dialog_message">No se pudo reconocer la URL. ¿Abrir con otra aplicación\?</string>
|
||||
<string name="auto_queue_toggle">Poner en cola automáticamente</string>
|
||||
<string name="clear_queue_confirmation_summary">Cambiar de un reproductor a otro puede reemplazar la cola de reproducción</string>
|
||||
|
@ -484,7 +484,6 @@
|
||||
\nKui sa soovid seda näha, siis lülita seadistustest „%1$s“ sisse.</string>
|
||||
<string name="youtube_restricted_mode_enabled_summary">YouTube\'is leiduv „Piiratud režiim“ peidab võimaliku täiskasvanutele mõeldud sisu</string>
|
||||
<string name="show_age_restricted_content_summary">Näita sisu, mis vanusepiirangu tõttu ilmselt ei sobi lastele (näiteks 18+)</string>
|
||||
<string name="settings_category_notification_title">Teavitus</string>
|
||||
<string name="peertube_instance_add_https_only">Sa saad kasutada vaid HTTPS-urle</string>
|
||||
<string name="night_theme_title">Öine teema</string>
|
||||
<string name="never">Ei iialgi</string>
|
||||
|
@ -547,7 +547,6 @@
|
||||
<string name="show_age_restricted_content_summary">Adinez mugatuta dagoen eta haurrentzako desegokia izan daitezkeen edukia erakutsi (+18 adibidez)</string>
|
||||
<string name="youtube_restricted_mode_enabled_summary">YouTube-ren \"Modu Murriztua\" helduentzako edukia izan daitekeen edukia ezkutatzen du</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Piztu YouTube-ren \"Modu Murriztua\"</string>
|
||||
<string name="settings_category_notification_title">Jakinarazpena</string>
|
||||
<string name="unsupported_url_dialog_message">Ezin izan da URL-a ezagutu. Beste aplikazio batekin ireki\?</string>
|
||||
<string name="auto_queue_toggle">Auto-ilara</string>
|
||||
<string name="clear_queue_confirmation_description">Erreprodukzio ilara aktiboa ordezkatuko da</string>
|
||||
|
@ -542,7 +542,6 @@
|
||||
<string name="wifi_only">فقط روی وایفای</string>
|
||||
<string name="autoplay_summary">شروع خودکار پخش — %s</string>
|
||||
<string name="title_activity_play_queue">پخش صف</string>
|
||||
<string name="settings_category_notification_title">اعلان</string>
|
||||
<string name="unsupported_url_dialog_message">نشانی قابل تشخیص نبود. با برنامه دیگری باز شود؟</string>
|
||||
<string name="auto_queue_toggle">صفگذاری خودکار</string>
|
||||
<string name="clear_queue_confirmation_description">صف پخشکنندهٔ فعال جایگزین میشود</string>
|
||||
|
@ -544,7 +544,6 @@
|
||||
<string name="wifi_only">Vain Wi-Fi-verkossa</string>
|
||||
<string name="autoplay_summary">Aloita toisto automaattisesti — %s</string>
|
||||
<string name="title_activity_play_queue">Toistojono</string>
|
||||
<string name="settings_category_notification_title">Ilmoitus</string>
|
||||
<string name="unsupported_url_dialog_message">Ei tunnistettu URL:ää. Avataanko toisessa sovelluksessa\?</string>
|
||||
<string name="auto_queue_toggle">Automaattinen jonoon lisääminen</string>
|
||||
<string name="clear_queue_confirmation_description">Aktiivisen soittimen jono korvataan</string>
|
||||
|
@ -552,7 +552,6 @@
|
||||
<string name="auto_queue_toggle">Ajouter automatiquement à la liste de lecture</string>
|
||||
<string name="clear_queue_confirmation_description">La liste de lecture du lecteur actif sera remplacée</string>
|
||||
<string name="clear_queue_confirmation_title">Confirmer av. de suppr. la liste de lecture</string>
|
||||
<string name="settings_category_notification_title">Notification</string>
|
||||
<string name="notification_action_nothing">Rien</string>
|
||||
<string name="notification_action_buffering">Chargement</string>
|
||||
<string name="notification_action_shuffle">Lire aléatoirement</string>
|
||||
|
@ -593,7 +593,6 @@
|
||||
<string name="restricted_video_no_stream">Este vídeo ten restrición de idade.
|
||||
\nDebido ás novas políticas de Youtube cos vídeos con restrición de idade, NewPipe non pode acceder ás transmisións do vídeo, polo que non pode reproducilo.</string>
|
||||
<string name="youtube_restricted_mode_enabled_summary">Youtube ten un \"Modo Restrinxido\" que oculta contido potencialmente só para adultos</string>
|
||||
<string name="settings_category_notification_title">Notificación</string>
|
||||
<string name="unsupported_url_dialog_message">URL non recoñecido. Abrir con outra aplicación\?</string>
|
||||
<string name="show_meta_info_title">Mostrar metainformación</string>
|
||||
<string name="show_description_summary">Desactíveo para ocultar a descrición do vídeo e a información adicional</string>
|
||||
|
@ -569,7 +569,6 @@
|
||||
<string name="clear_queue_confirmation_description">התור מהנגן הפעיל יוחלף</string>
|
||||
<string name="clear_queue_confirmation_summary">מעבר מנגן אחד למשנהו עלול להחליף את התור שלך</string>
|
||||
<string name="clear_queue_confirmation_title">לבקש אישור לפני מחיקת התור</string>
|
||||
<string name="settings_category_notification_title">התראה</string>
|
||||
<string name="notification_action_nothing">כלום</string>
|
||||
<string name="notification_action_buffering">איסוף</string>
|
||||
<string name="notification_action_shuffle">ערבוב</string>
|
||||
|
@ -463,7 +463,6 @@
|
||||
<string name="youtube_restricted_mode_enabled_summary">यूट्यूब एक \"प्रतिबंधित मोड\" प्रदान करता है जो संभावित रूप से परिपक्व सामग्री को छुपाता है</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">यूट्यूब का \"प्रतिबंधित मोड\" चालू करें</string>
|
||||
<string name="show_age_restricted_content_summary">बच्चों के लिए अनुपयुक्त सामग्री दिखाएं क्योंकि इसकी आयु सीमा है (जैसे 18)</string>
|
||||
<string name="settings_category_notification_title">अधिसूचना</string>
|
||||
<string name="peertube_instance_add_https_only">केवल HTTPS यूआरएल ही समर्थित हैं</string>
|
||||
<string name="unsupported_url_dialog_message">URL की पहचान नहीं हो सकी। दूसरे ऐप से खोलें\?</string>
|
||||
<string name="auto_queue_toggle">ऑटोमैटिकली कतार करे</string>
|
||||
|
@ -447,7 +447,6 @@
|
||||
<string name="albums">Albumi</string>
|
||||
<string name="songs">Pjesme</string>
|
||||
<string name="channel_created_by">Napravio %s</string>
|
||||
<string name="settings_category_notification_title">Obavijest</string>
|
||||
<string name="never">Nikada</string>
|
||||
<string name="enable_queue_limit">Ograniči popis preuzimanja</string>
|
||||
<string name="downloads_storage_use_saf_title">Koristi birač mapa sustava (SAF)</string>
|
||||
|
@ -485,7 +485,6 @@
|
||||
\n
|
||||
\nEngedélyezze a(z) „%1$s” beállítást, ha meg szeretné tekinteni.</string>
|
||||
<string name="show_age_restricted_content_summary">Gyermekek számára esetlegesen nem megfelelő, korhatáros tartalom megjelenítése (például 18+)</string>
|
||||
<string name="settings_category_notification_title">Értesítés</string>
|
||||
<string name="peertube_instance_add_https_only">Csak a HTTPS URL-ek támogatottak</string>
|
||||
<string name="show_meta_info_title">Metainformációk megjelenítése</string>
|
||||
<string name="clear_queue_confirmation_description">A jelenleg aktív lejátszási sor le lesz cserélve</string>
|
||||
|
@ -58,7 +58,6 @@
|
||||
<string name="tab_about">Մասին</string>
|
||||
<string name="channels">Ալիքներ</string>
|
||||
<string name="all">Ամենը</string>
|
||||
<string name="settings_category_notification_title">Ծանուցում</string>
|
||||
<string name="settings_category_appearance_title">Տեսք</string>
|
||||
<string name="settings_category_updates_title">Թարմացումներ</string>
|
||||
<string name="enable_watch_history_title">Դիտման պատմություն</string>
|
||||
|
@ -539,7 +539,6 @@
|
||||
<string name="clear_queue_confirmation_description">Antrean dari pemutar yang aktif akan digantikan</string>
|
||||
<string name="clear_queue_confirmation_summary">Beralih ke pemutar yang lain mungkin akan mengganti antrean Anda</string>
|
||||
<string name="clear_queue_confirmation_title">Konfirmasi sebelum mengosongkan antrean</string>
|
||||
<string name="settings_category_notification_title">Notifikasi</string>
|
||||
<string name="notification_action_nothing">Tidak ada</string>
|
||||
<string name="notification_action_buffering">Bufer</string>
|
||||
<string name="notification_action_shuffle">Aduk</string>
|
||||
|
@ -558,7 +558,6 @@
|
||||
<string name="notification_action_buffering">Buffer in corso</string>
|
||||
<string name="notification_actions_at_most_three">Nella notifica compatta è possibile visualizzare al massimo 3 azioni!</string>
|
||||
<string name="notification_action_shuffle">Casuale</string>
|
||||
<string name="settings_category_notification_title">Notifica</string>
|
||||
<string name="notification_action_nothing">Niente</string>
|
||||
<string name="notification_action_repeat">Ripeti</string>
|
||||
<string name="notification_scale_to_square_image_title">Ridimensiona copertina alla proporzione 1:1</string>
|
||||
|
@ -552,7 +552,6 @@
|
||||
<string name="notification_action_repeat">繰り返し</string>
|
||||
<string name="notification_action_shuffle">シャッフル</string>
|
||||
<string name="notification_action_buffering">バッファリング</string>
|
||||
<string name="settings_category_notification_title">通知</string>
|
||||
<string name="youtube_restricted_mode_enabled_summary">YouTube は、成人向けの可能性があるコンテンツを除外する「制限付きモード」を提供しています</string>
|
||||
<string name="show_age_restricted_content_summary">年齢制限 (18+ など) の理由で、子供には不適切な可能性のあるコンテンツを表示する</string>
|
||||
<string name="enqueue_stream">キューに追加</string>
|
||||
|
@ -193,7 +193,6 @@
|
||||
<string name="content">Dilşad</string>
|
||||
<string name="popup_playing_toast">Di moda popupê de dilîzin</string>
|
||||
<string name="background_player_playing_toast">Di paşayê de dilîzin</string>
|
||||
<string name="settings_category_notification_title">Agahdayin</string>
|
||||
<string name="settings_category_updates_title">Nûvekirin</string>
|
||||
<string name="settings_category_debug_title">Xeletkirin</string>
|
||||
<string name="settings_category_appearance_title">Xuyabûnî</string>
|
||||
|
@ -497,7 +497,6 @@
|
||||
<string name="remove_watched_popup_title">시청 기록을 지우겠습니까\?</string>
|
||||
<string name="remove_watched">시청 기록 지우기</string>
|
||||
<string name="title_activity_play_queue">재생목록 실행</string>
|
||||
<string name="settings_category_notification_title">알림</string>
|
||||
<string name="unsupported_url_dialog_message">URL을 인식할 수 없습니다. 다른 앱으로 여시겠습니까\?</string>
|
||||
<string name="clear_queue_confirmation_title">대기열을 비우기 전 확인하도록 합니다.</string>
|
||||
<string name="notification_colorize_summary">안드로이드에서 썸네일의 색상에 따라 알림 색상을 조절합니다. (지원되지 않는 기기가 있을 수 있습니다.)</string>
|
||||
|
@ -538,7 +538,6 @@
|
||||
<string name="autoplay_summary">دەسپێکردنی کارپێکەر بەخۆکاری — %s</string>
|
||||
<string name="title_activity_play_queue">لێدانی ڕیز</string>
|
||||
<string name="no_playlist_bookmarked_yet">هیچ لیستەلێدانێک نیشانە نەکراوە</string>
|
||||
<string name="settings_category_notification_title">پەیام</string>
|
||||
<string name="unsupported_url_dialog_message">بەستەرەکە نەناسرایەوە. لە ئەپێکیتردا بکرێتەوە؟</string>
|
||||
<string name="auto_queue_toggle">ڕیزبوونی خۆکار</string>
|
||||
<string name="clear_queue_confirmation_description">ڕیزی لێدەری چالاک جێیدەگیرێتەوە</string>
|
||||
|
@ -330,7 +330,6 @@
|
||||
<string name="youtube_restricted_mode_enabled_summary">Youtube turi „apribotą režimą“ kuriame slepiamas galimai suaugusiems skirtas turinys</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Įjungti YouTube „apribotą režimą“</string>
|
||||
<string name="show_age_restricted_content_summary">Rodyti turinį kuris gali būti netinkamas vaikams (18+)</string>
|
||||
<string name="settings_category_notification_title">Pranešimai</string>
|
||||
<string name="settings_category_updates_title">Atnaujinimai</string>
|
||||
<string name="peertube_instance_add_exists">Kopija jau yra</string>
|
||||
<string name="peertube_instance_add_https_only">Palaikomi tik HTTPS adresai</string>
|
||||
|
@ -260,7 +260,6 @@
|
||||
<string name="content">Saturs</string>
|
||||
<string name="popup_playing_toast">Atskaņo popup režīmā</string>
|
||||
<string name="background_player_playing_toast">Atskaņo fonā</string>
|
||||
<string name="settings_category_notification_title">Notifikācija</string>
|
||||
<string name="settings_category_updates_title">Atjauninājumi</string>
|
||||
<string name="settings_category_debug_title">Atkļūdošana</string>
|
||||
<string name="settings_category_appearance_title">Izskats</string>
|
||||
|
@ -587,7 +587,6 @@
|
||||
\nപ്രായ-നിയന്ത്രിത വീഡിയോകളുള്ള പുതിയ യൂട്യൂബ് നയങ്ങൾ കാരണം, ന്യൂപൈപ്പിന് അതിന്റെ വീഡിയോ സ്ട്രീമുകളിലൊന്നും ആക്സസ് ചെയ്യാൻ കഴിയില്ല, അതിനാൽ ഇത് പ്ലേ ചെയ്യാൻ കഴിയില്ല.</string>
|
||||
<string name="youtube_restricted_mode_enabled_summary">പക്വതയുള്ള ഉള്ളടക്കം മറയ്ക്കുന്ന \"നിയന്ത്രിത മോഡ്\" യൂട്യൂബ് നൽകുന്നു</string>
|
||||
<string name="show_age_restricted_content_summary">കുട്ടികൾക്ക് അനുയോജ്യമല്ലാത്ത ഉള്ളടക്കം കാണിക്കുക കാരണം അതിന് പ്രായപരിധി ഉണ്ട് (18+ പോലെ)</string>
|
||||
<string name="settings_category_notification_title">അറിയിപ്പ്</string>
|
||||
<string name="unsupported_url_dialog_message">URL തിരിച്ചറിയാൻ കഴിഞ്ഞില്ല. മറ്റൊരു അപ്ലിക്കേഷൻ ഉപയോഗിച്ച് തുറക്കണോ\?</string>
|
||||
<string name="auto_queue_toggle">യാന്ത്രിക-ക്യൂ</string>
|
||||
<string name="show_meta_info_summary">സ്ട്രീം സ്രഷ്ടാവ്, സ്ട്രീം ഉള്ളടക്കം അല്ലെങ്കിൽ ഒരു തിരയൽ അഭ്യർത്ഥന എന്നിവയെക്കുറിച്ചുള്ള കൂടുതൽ വിവരങ്ങൾ ഉൾക്കൊള്ളുന്ന മെറ്റാ വിവര ബോക്സുകൾ മറയ്ക്കുന്നതിന് ഓഫാക്കുക</string>
|
||||
|
@ -382,7 +382,6 @@
|
||||
<item quantity="other">%d hari</item>
|
||||
</plurals>
|
||||
<string name="help">Bantuan</string>
|
||||
<string name="settings_category_notification_title">Pemberitahuan</string>
|
||||
<string name="open_with">Buka dengan</string>
|
||||
<plurals name="listening">
|
||||
<item quantity="other">%s pendengar</item>
|
||||
|
@ -552,7 +552,6 @@
|
||||
<string name="title_activity_play_queue">Spill kø</string>
|
||||
<string name="no_playlist_bookmarked_yet">Ingen spillelistebokmerker enda</string>
|
||||
<string name="copy_for_github">Kopier formatert rapport</string>
|
||||
<string name="settings_category_notification_title">Merknad</string>
|
||||
<string name="notification_action_repeat">Gjenta</string>
|
||||
<string name="notification_action_4_title">Femte handlingstast</string>
|
||||
<string name="notification_action_3_title">Fjerde handlingstast</string>
|
||||
|
@ -537,7 +537,6 @@
|
||||
<string name="youtube_restricted_mode_enabled_summary">YouTube biedt een \"beperkte modes\" aan, dit verbergt mogelijk materiaal voor volwassenen</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">YouTube \"beperkte modus\" aanzetten</string>
|
||||
<string name="show_age_restricted_content_summary">Toon inhoud die mogelijk niet geschikt is voor kinderen omwille van een leeftijdslimiet (zoals 18+)</string>
|
||||
<string name="settings_category_notification_title">Melding</string>
|
||||
<string name="peertube_instance_add_exists">Kanaal bestaat al</string>
|
||||
<string name="peertube_instance_add_https_only">Alleen HTTPS URL\'s worden ondersteund</string>
|
||||
<string name="peertube_instance_add_fail">Kon kanaal niet valideren</string>
|
||||
|
@ -544,7 +544,6 @@
|
||||
<string name="wifi_only">Enkel via Wi-Fi</string>
|
||||
<string name="autoplay_summary">Start automatisch met afspelen — %s</string>
|
||||
<string name="title_activity_play_queue">Speel wachtrij af</string>
|
||||
<string name="settings_category_notification_title">Notificatie</string>
|
||||
<string name="unsupported_url_dialog_message">Kon de URL niet herkennen. In een andere app openen\?</string>
|
||||
<string name="clear_queue_confirmation_description">De actieve spelerswachtrij wordt vervangen</string>
|
||||
<string name="clear_queue_confirmation_summary">Veranderen van één speler naar een andere kan jouw wachtrij vervangen</string>
|
||||
|
@ -584,7 +584,6 @@
|
||||
<string name="youtube_restricted_mode_enabled_summary">ਯੂਟਿਊਬ \"ਪਾਬੰਦੀਸ਼ੁਦਾ ਮੋਡ\" ਉਪਲਬਧ ਕਰਾਉਂਦਾ ਹੈ ਜੋ ਬਾਲਗਾਂ ਵਾਲ਼ੀ ਸਮੱਗਰੀ ਲੁਕਾਉਂਦਾ ਹੈ</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">ਯੂਟਿਊਬ ਦਾ ਪਾਬੰਦੀਸ਼ੁਦਾ ਮੋਡ ਚਾਲੂ ਕਰੋ</string>
|
||||
<string name="show_age_restricted_content_summary">ਉਹ ਸਮੱਗਰੀ ਵੀ ਵਿਖਾਓ ਜੋ ਉਮਰ-ਸੀਮਾ ਕਰਕੇ ਬੱਚਿਆਂ ਲਈ ਸ਼ਾਇਦ ਸਹੀ ਨਾ ਹੋਵੇ (ਜਿਵੇਂ 18+)</string>
|
||||
<string name="settings_category_notification_title">ਇਤਲਾਹਾਂ</string>
|
||||
<string name="peertube_instance_add_exists">ਸਥਿਤੀ ਪਹਿਲਾਂ ਨੂੰ ਮੌਜੂਦ ਹੈ</string>
|
||||
<string name="peertube_instance_add_https_only">ਸਿਰਫ਼ HTTP URLs ਹੀ ਮਾਣਨਯੋਗ ਹਨ</string>
|
||||
<string name="peertube_instance_add_fail">ਸਥਿਤੀ ਦੀ ਜਾਇਜ਼ਗੀ ਤਸਦੀਕ ਨਹੀਂ ਹੋ ਸਕੀ</string>
|
||||
|
@ -564,7 +564,6 @@
|
||||
<string name="clear_queue_confirmation_description">Kolejka aktywnego odtwarzacza zostanie zastąpiona</string>
|
||||
<string name="clear_queue_confirmation_summary">Przejście z jednego odtwarzacza na inny może zastąpić kolejkę</string>
|
||||
<string name="clear_queue_confirmation_title">Poproś o potwierdzenie przed wyczyszczeniem kolejki</string>
|
||||
<string name="settings_category_notification_title">Powiadomienie</string>
|
||||
<string name="notification_action_nothing">Nic</string>
|
||||
<string name="notification_action_buffering">Buforowanie</string>
|
||||
<string name="notification_action_shuffle">Losuj</string>
|
||||
|
@ -551,7 +551,6 @@
|
||||
<string name="clear_queue_confirmation_title">Pedir confirmação antes de limpar uma fila</string>
|
||||
<string name="notification_action_shuffle">Aleatório</string>
|
||||
<string name="notification_action_buffering">Carregando</string>
|
||||
<string name="settings_category_notification_title">Notificação</string>
|
||||
<string name="notification_action_nothing">Nada</string>
|
||||
<string name="notification_action_repeat">Repetir</string>
|
||||
<string name="notification_actions_at_most_three">Você pode selecionar até no máximo três botões para mostrar na notificação compacta!</string>
|
||||
|
@ -543,7 +543,6 @@
|
||||
<string name="wifi_only">Apenas em Wi-Fi</string>
|
||||
<string name="autoplay_summary">Iniciar reprodução automaticamente — %s</string>
|
||||
<string name="title_activity_play_queue">Reproduzir fila</string>
|
||||
<string name="settings_category_notification_title">Notificação</string>
|
||||
<string name="unsupported_url_dialog_message">URL não reconhecido. Abrir com outra aplicação\?</string>
|
||||
<string name="auto_queue_toggle">Enfileiramento automático</string>
|
||||
<string name="clear_queue_confirmation_description">A fila do reprodutor ativo será substituída</string>
|
||||
|
@ -554,7 +554,6 @@
|
||||
<string name="unsupported_url_dialog_message">URL não reconhecido. Abrir com outra aplicação\?</string>
|
||||
<string name="auto_queue_toggle">Enfileiramento automático</string>
|
||||
<string name="notification_action_shuffle">Baralhar</string>
|
||||
<string name="settings_category_notification_title">Notificação</string>
|
||||
<string name="wifi_only">Apenas em Wi-Fi</string>
|
||||
<string name="notification_action_nothing">Nada</string>
|
||||
<string name="clear_queue_confirmation_summary">Mudar de um reprodutor para outro pode substituir a sua fila</string>
|
||||
|
@ -363,7 +363,6 @@
|
||||
<string name="youtube_restricted_mode_enabled_summary">YouTube oferă un \"Mod restricționat\" care ascunde conținutul potențial matur</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Activați \"Modul restricționat\" de pe YouTube</string>
|
||||
<string name="show_age_restricted_content_summary">Afișați conținut posibil nepotrivit pentru copii, deoarece are o limită de vârstă (cum ar fi 18+)</string>
|
||||
<string name="settings_category_notification_title">Notificare</string>
|
||||
<string name="unsupported_url_dialog_message">Adresa URL nu a putut fi recunoscută. Deschideți cu o altă aplicație\?</string>
|
||||
<string name="show_meta_info_title">Afișează informațiile meta</string>
|
||||
<string name="notification_colorize_summary">Faceți ca Android să personalizeze culoarea notificării în funcție de culoarea principală din miniatură (rețineți că aceasta nu este disponibilă pe toate dispozitivele)</string>
|
||||
|
@ -167,6 +167,11 @@
|
||||
<item quantity="many">%s видео</item>
|
||||
<item quantity="other">%s видео</item>
|
||||
</plurals>
|
||||
<plurals name="new_streams">
|
||||
<item quantity="one">%s новое видео</item>
|
||||
<item quantity="few">%s новых видео</item>
|
||||
<item quantity="many">%s новых видео</item>
|
||||
</plurals>
|
||||
<string name="delete_item_search_history">Удалить этот элемент из истории поиска?</string>
|
||||
<string name="main_page_content">Главная страница</string>
|
||||
<string name="blank_page_summary">Пустая страница</string>
|
||||
@ -561,7 +566,7 @@
|
||||
<string name="clear_queue_confirmation_description">Очередь активного плеера будет заменена</string>
|
||||
<string name="clear_queue_confirmation_title">Подтверждать очистку очереди</string>
|
||||
<string name="clear_queue_confirmation_summary">Переход от одного плеера к другому может заменить вашу очередь</string>
|
||||
<string name="settings_category_notification_title">Уведомление</string>
|
||||
<string name="settings_category_player_notification_summary">Настроить уведомление о воспроизводимом сейчас потоке</string>
|
||||
<string name="notification_action_nothing">Ничего</string>
|
||||
<string name="notification_action_buffering">Буферизация</string>
|
||||
<string name="notification_action_shuffle">Перемешать</string>
|
||||
@ -686,6 +691,20 @@
|
||||
<string name="manual_update_title">Проверить обновления</string>
|
||||
<string name="checking_updates_toast">Проверка обновлений…</string>
|
||||
<string name="feed_new_items">Новое на канале</string>
|
||||
<string name="report_player_errors_title">Отчёт об ошибках плеера</string>
|
||||
<string name="report_player_errors_summary">Подробные отчёты об ошибках плеера вместо коротких всплывающих сообщений (полезно при диагностике проблем)</string>
|
||||
<string name="notifications">Уведомления</string>
|
||||
<string name="streams_notification_channel_name">Новые видео</string>
|
||||
<string name="streams_notification_channel_description">Уведомления о новых видео в подписках</string>
|
||||
<string name="streams_notifications_interval_title">Частота проверки</string>
|
||||
<string name="enable_streams_notifications_title">Уведомлять о новых видео</string>
|
||||
<string name="enable_streams_notifications_summary">Получать уведомления о новых видео из каналов, на которые Вы подписаны</string>
|
||||
<string name="streams_notifications_network_title">Тип подключения</string>
|
||||
<string name="any_network">Любая сеть</string>
|
||||
<string name="notifications_disabled">Уведомления отключены</string>
|
||||
<string name="get_notified">Уведомлять</string>
|
||||
<string name="you_successfully_subscribed">Вы подписались на канал</string>
|
||||
<string name="toggle_all">Переключить все</string>
|
||||
<string name="show_crash_the_player_title">Показать \"Вызвать сбой плеера\"</string>
|
||||
<string name="show_crash_the_player_summary">Показать функцию вызова сбоя при работе плеера</string>
|
||||
<string name="crash_the_player">Вызвать сбой плеера</string>
|
||||
|
@ -548,7 +548,6 @@
|
||||
<string name="clear_queue_confirmation_description">Sa lista dae su riproduidore ativu at a èssere remplasada</string>
|
||||
<string name="clear_queue_confirmation_summary">Colende dae unu riproduidore a s\'àteru dias pòdere remplasare sa lista tua</string>
|
||||
<string name="clear_queue_confirmation_title">Pedi una cunfirma in antis de iscantzellare una lista</string>
|
||||
<string name="settings_category_notification_title">Notìfica</string>
|
||||
<string name="notification_action_shuffle">Òrdine casuale</string>
|
||||
<string name="notification_actions_summary">Modìfica cada atzione de notìfica inoghe in suta incarchende·la. Ischerta·nde finas a tres de ammustrare in sa notìfica cumpata impreende sas casellas de controllu a destra</string>
|
||||
<string name="notification_scale_to_square_image_summary">Iscala sa miniadura ammustrada in sa notìfica dae su formadu in 16:9 a cussu 1:1 (diat pòdere causare istorchimentos)</string>
|
||||
|
@ -554,7 +554,6 @@
|
||||
<string name="no_playlist_bookmarked_yet">Zatiaľ bez záložiek zoznamu</string>
|
||||
<string name="select_a_playlist">Vyberte zoznam skladieb</string>
|
||||
<string name="error_report_open_github_notice">Skontrolujte prosím, či rovnaká chyba už nie je nahlásená. Vytváranie duplicitných hlásení komplikuje prácu vývojárov.</string>
|
||||
<string name="settings_category_notification_title">Oznámenia</string>
|
||||
<string name="unsupported_url_dialog_message">Nemožno rozpoznať URL. Otvoriť pomocou inej aplikácie\?</string>
|
||||
<string name="auto_queue_toggle">Automatický rad</string>
|
||||
<string name="clear_queue_confirmation_description">Zoznam aktuálneho prehrávača bude prepísaný</string>
|
||||
|
@ -425,7 +425,6 @@
|
||||
<string name="youtube_restricted_mode_enabled_summary">Youtube ponuja \"omejeni način\", ki skrije potencialno vsebino za odrasle</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Vklop YouTubovega \"omejenega načina\"</string>
|
||||
<string name="show_age_restricted_content_summary">Prikaz vsebin, ki so morda neprimerne za otroke zaradi omejitve starosti (kot na primer 18+)</string>
|
||||
<string name="settings_category_notification_title">Obvestilo</string>
|
||||
<string name="peertube_instance_add_exists">Instanca že obstaja</string>
|
||||
<string name="peertube_instance_add_fail">Validacija instance ni bila mogoča</string>
|
||||
<string name="peertube_instance_add_help">Vnesite URL instance</string>
|
||||
|
@ -360,7 +360,6 @@
|
||||
<string name="content">Luuqada & Fadhiga Kale</string>
|
||||
<string name="popup_playing_toast">Ku daaraya daaqada</string>
|
||||
<string name="background_player_playing_toast">Ka daaraya xaga dambe</string>
|
||||
<string name="settings_category_notification_title">Ogaysiisyada</string>
|
||||
<string name="settings_category_updates_title">Cusboonaysiinta</string>
|
||||
<string name="settings_category_debug_title">Cilad bixinta</string>
|
||||
<string name="settings_category_appearance_title">Nashqada</string>
|
||||
|
@ -544,7 +544,6 @@
|
||||
<string name="never">Kurrë</string>
|
||||
<string name="autoplay_summary">Nise luajtjen automatikisht — %s</string>
|
||||
<string name="title_activity_play_queue">Lista e luajtjes</string>
|
||||
<string name="settings_category_notification_title">Njoftim</string>
|
||||
<string name="unsupported_url_dialog_message">Nuk u njoh URL. Të hapet me një aplikacion tjetër\?</string>
|
||||
<string name="auto_queue_toggle">Listë automatike luajtjeje</string>
|
||||
<string name="clear_queue_confirmation_description">Lista aktive e luajtjes do të zëvendësohet</string>
|
||||
|
@ -484,7 +484,6 @@
|
||||
<string name="youtube_restricted_mode_enabled_summary">Јутјуб омогућава „Ограничени режим“ који скрива потенцијални садржај за одрасле</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Укључити Јутјубов „Ограничени режим“</string>
|
||||
<string name="show_age_restricted_content_summary">Приказ садржаја који можда није прикладан за децу јер има старосну границу (попут 18+)</string>
|
||||
<string name="settings_category_notification_title">Обавештење</string>
|
||||
<string name="settings_category_updates_title">Ажурирања</string>
|
||||
<string name="peertube_instance_add_exists">Инстанца већ постоји</string>
|
||||
<string name="peertube_instance_add_https_only">Подржане су само HTTPS УРЛ адресе</string>
|
||||
|
@ -529,7 +529,6 @@
|
||||
<string name="notification_scale_to_square_image_summary">Skala videominiatyrbilden som visas i aviseringen från 16:9- till 1:1-förhållande (kan orsaka bildförvrängning)</string>
|
||||
<string name="autoplay_summary">Starta uppspelning automatiskt — %s</string>
|
||||
<string name="title_activity_play_queue">Uppspelningskö</string>
|
||||
<string name="settings_category_notification_title">Aviseringar</string>
|
||||
<string name="unsupported_url_dialog_message">Kunde inte känna igen URL:en. Vill du öppna med annan app\?</string>
|
||||
<string name="auto_queue_toggle">Köa automatiskt</string>
|
||||
<string name="clear_queue_confirmation_description">Den aktiva spellistan kommer att ersättas</string>
|
||||
|
@ -229,7 +229,6 @@
|
||||
<string name="peertube_instance_add_help">ఉదాహరణ URLని నమోదు చేయండి</string>
|
||||
<string name="peertube_instance_add_fail">ఉదాహరణను ధృవీకరించడం సాధ్యపడలేదు</string>
|
||||
<string name="peertube_instance_add_exists">ఉదాహరణ ఇప్పటికే ఉంది</string>
|
||||
<string name="settings_category_notification_title">నోటిఫికేషన్</string>
|
||||
<string name="users">వినియోగదారులు</string>
|
||||
<string name="events">ఈవెంట్స్</string>
|
||||
<string name="app_update_notification_channel_description">కొత్త NewPipe వెర్షన్ కోసం నోటిఫికేషన్లు</string>
|
||||
|
@ -549,7 +549,6 @@
|
||||
<string name="clear_queue_confirmation_description">Etkin oynatıcının kuyruğu değiştirilecek</string>
|
||||
<string name="clear_queue_confirmation_summary">Bir oynatıcıdan diğerine geçmek kuyruğunuzu değiştirebilir</string>
|
||||
<string name="clear_queue_confirmation_title">Bir kuyruğu temizlemeden önce onay iste</string>
|
||||
<string name="settings_category_notification_title">Bildirim</string>
|
||||
<string name="notification_action_nothing">Hiçbir şey</string>
|
||||
<string name="notification_action_buffering">Ara belleğe alınıyor</string>
|
||||
<string name="notification_action_shuffle">Karıştır</string>
|
||||
|
@ -133,7 +133,6 @@
|
||||
<string name="downloads_title">Tagamin</string>
|
||||
<string name="downloads">Tagamin</string>
|
||||
<string name="duration_live">Usrid</string>
|
||||
<string name="settings_category_notification_title">Tineɣmisin</string>
|
||||
<string name="settings_category_updates_title">Tisdɣiwin</string>
|
||||
<string name="settings_category_player_title">Ameɣri</string>
|
||||
<string name="download_dialog_title">Agem</string>
|
||||
|
@ -579,7 +579,7 @@
|
||||
<string name="description_tab_description">Опис</string>
|
||||
<string name="related_items_tab_description">Повʼязані елементи</string>
|
||||
<string name="comments_tab_description">Коментарі</string>
|
||||
<string name="settings_category_notification_title">Сповіщення</string>
|
||||
<string name="settings_category_player_notification_summary">Налаштувати повідомлення про відтворюваний наразі потік</string>
|
||||
<string name="unsupported_url_dialog_message">Не розпізнано URL. Відкрити через іншу програму\?</string>
|
||||
<string name="auto_queue_toggle">Автоматична черга</string>
|
||||
<string name="show_meta_info_title">Показувати метадані</string>
|
||||
|
@ -472,7 +472,6 @@
|
||||
<string name="youtube_restricted_mode_enabled_summary">یوٹیوب ایک \"پابندی والا وضع\" فراہم کرتا ہے جو امکانی طور پر نازیبا مواد کو چھپاتا ہے</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">یوٹیوب کا \"پابندی والا وضع\" چالو کریں</string>
|
||||
<string name="show_age_restricted_content_summary">وہ مواد دکھائیں جو بچوں کے لیے ممکنہ طور پر نا مناسب ہیں کیوں کہ اس میں عمر کی حد ہے (جیسے 18+)</string>
|
||||
<string name="settings_category_notification_title">اطلاع</string>
|
||||
<string name="unsupported_url_dialog_message">URL کو نہیں پہچان سکے۔ کسی اور ایپ کے ساتھ کھولیں؟</string>
|
||||
<string name="auto_queue_toggle">ازخود قطار</string>
|
||||
<string name="show_meta_info_summary">اسٹریم کے موجد، اسٹریم مواد یا تلاش کی درخواست کے بارے میں اضافی معلومات والے میٹا انفارمیشن بکسوں کو چھپانے کیلئے بند کریں۔</string>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user