mirror of
				https://github.com/TeamNewPipe/NewPipe
				synced 2025-10-31 07:13:00 +00:00 
			
		
		
		
	Merge pull request #2335 from nv95/feature/notifications
New streams notifications
This commit is contained in:
		| @@ -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
		Reference in New Issue
	
	Block a user
	 Stypox
					Stypox