diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 8073503ad..000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,3 +0,0 @@ -- [ ] I carefully read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md) and agree to them. -- [ ] I checked if the issue/feature exists in the latest version. -- [ ] I did use the [incredible bugreport to markdown converter](https://teamnewpipe.github.io/CrashReportToMarkdown/) to paste bug reports. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..202e8a71a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,44 @@ +--- +name: Bug report +about: Create a bug report to help us improve +labels: bug +assignees: '' + +--- + + +### Version + +- + + +### Steps to reproduce the bug + +Steps to reproduce the behavior: +1. Go to '...' +2. Press on '....' +3. Swipe down to '....' + +### Expected behavior +Tell us what you expected to happen. + +### Actual behaviour +Tell us what happens instead. + +### Screenshots/Screen records +If applicable, add screenshots or a screen recording to help explain your problem. GitHub should support uploading them directly in the issue field. If your file is too big, feel free to paste a link from an image/video hoster here instead. + +### Logs +If your bug includes a crash, please head over to the [incredible bugreport to markdown converter](https://teamnewpipe.github.io/CrashReportToMarkdown/). Copy the result. Paste it here: + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..89fe58658 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,28 @@ +--- +name: Feature request +about: Suggest an idea for this project +labels: enhancement +assignees: '' + +--- + +#### Is your feature request related to a problem? Please describe it +A clear and concise description of what the problem is. +Example: *I want to do X, but there is no way to do it.* + +#### Describe the solution you'd like +A clear and concise description of what you want to happen. +Example: *I think it would be nice if you add feature Y which makes X possible.* + +#### (Optional) Describe alternatives you've considered +A clear and concise description of any alternative solutions or features you've considered. +Example: *I considered Z, but that didn't turn out to be a good idea because...* + +#### Additional context +Add any other context or screenshots about the feature request here. +Example: *Here's a photo of my cat!* + +#### How will you/everyone benefit from this feature? +Convince us! How does it change your NewPipe experience and/or your life? +The better this paragraph is, the more likely a developer will think about working on it. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d0e58680a..9a1193767 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1 +1,26 @@ + + +#### What is it? +- [ ] Bug fix +- [ ] Feature + +#### Long description of the changes in your PR + +- record videos +- create clones +- take over the world + +#### Fixes the following issue(s) + +- + +#### Relies on the following changes + +- + +#### Testing apk + +debug.zip + +#### Agreement - [ ] I carefully read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md) and agree to them. diff --git a/app/build.gradle b/app/build.gradle index e7f43551c..37b03e4ae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,13 +9,20 @@ android { defaultConfig { applicationId "org.schabi.newpipe" + resValue "string", "app_name", "NewPipe" minSdkVersion 19 targetSdkVersion 28 - versionCode 870 - versionName "0.18.7" + versionCode 900 + versionName "0.19.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true + + javaCompileOptions { + annotationProcessorOptions { + arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] + } + } } buildTypes { @@ -28,7 +35,18 @@ android { debug { multiDexEnabled true debuggable true - applicationIdSuffix ".debug" + + // suffix the app id and the app name with git branch name + def workingBranch = getGitWorkingBranch() + def normalizedWorkingBranch = workingBranch.replaceAll("[^A-Za-z]+", "").toLowerCase() + if (normalizedWorkingBranch.isEmpty() || workingBranch == "master" || workingBranch == "dev") { + // default values when branch name could not be determined or is master or dev + applicationIdSuffix ".debug" + resValue "string", "app_name", "NewPipe Debug" + } else { + applicationIdSuffix ".debug." + normalizedWorkingBranch + resValue "string", "app_name", "NewPipe " + workingBranch + } } } @@ -43,6 +61,15 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + // Required and used only by groupie + androidExtensions { + experimental = true + } + + sourceSets { + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + } } ext { @@ -59,11 +86,13 @@ ext { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + androidTestImplementation "android.arch.persistence.room:testing:1.1.1" androidTestImplementation('androidx.test.espresso:espresso-core:3.1.0', { exclude module: 'support-annotations' }) - implementation 'com.github.TeamNewPipe:NewPipeExtractor:6f03c6e87' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:69e0624e3' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:2.23.0' @@ -75,6 +104,13 @@ dependencies { implementation "androidx.cardview:cardview:${androidxLibVersion}" implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'com.xwray:groupie:2.7.0' + implementation 'com.xwray:groupie-kotlin-android-extensions:2.7.0' + + implementation 'androidx.lifecycle:lifecycle-livedata:2.0.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel:2.0.0' + implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0' + // Originally in NewPipeExtractor implementation 'com.grack:nanojson:1.1' implementation 'org.jsoup:jsoup:1.9.2' @@ -113,3 +149,19 @@ dependencies { implementation "io.noties.markwon:core:${markwonVersion}" implementation "io.noties.markwon:linkify:${markwonVersion}" } + +static String getGitWorkingBranch() { + try { + def gitProcess = "git rev-parse --abbrev-ref HEAD".execute() + gitProcess.waitFor() + if (gitProcess.exitValue() == 0) { + return gitProcess.text.trim() + } else { + // not a git repository + return "" + } + } catch (IOException ignored) { + // git was not found + return "" + } +} \ No newline at end of file diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/2.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/2.json new file mode 100644 index 000000000..2532e330e --- /dev/null +++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/2.json @@ -0,0 +1,479 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "b7856223e2595ddf20a3ce6243ce9527", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatar_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subscriberCount", + "columnName": "subscriber_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_subscriptions_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationDate", + "columnName": "creation_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_search_history_search", + "unique": false, + "columnNames": [ + "search" + ], + "createSql": "CREATE INDEX `index_search_history_search` ON `${TABLE_NAME}` (`search`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "streams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, `stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, `thumbnail_url` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamType", + "columnName": "stream_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_streams_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "stream_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessDate", + "columnName": "access_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatCount", + "columnName": "repeat_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id", + "access_date" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_stream_history_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "createSql": "CREATE INDEX `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "stream_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressTime", + "columnName": "progress_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX `index_playlists_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "playlist_stream_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "playlistUid", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "join_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "playlist_id", + "join_index" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_playlist_stream_join_playlist_id_join_index", + "unique": true, + "columnNames": [ + "playlist_id", + "join_index" + ], + "createSql": "CREATE UNIQUE INDEX `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" + }, + { + "name": "index_playlist_stream_join_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "createSql": "CREATE INDEX `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "remote_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamCount", + "columnName": "stream_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_remote_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_remote_playlists_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"b7856223e2595ddf20a3ce6243ce9527\")" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/3.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/3.json new file mode 100644 index 000000000..313c3e27c --- /dev/null +++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/3.json @@ -0,0 +1,707 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "9f825b1ee281480bedd38b971feac327", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatar_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subscriberCount", + "columnName": "subscriber_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_subscriptions_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationDate", + "columnName": "creation_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_search_history_search", + "unique": false, + "columnNames": [ + "search" + ], + "createSql": "CREATE INDEX `index_search_history_search` ON `${TABLE_NAME}` (`search`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "streams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "streamType", + "columnName": "stream_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "viewCount", + "columnName": "view_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "textualUploadDate", + "columnName": "textual_upload_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "upload_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isUploadDateApproximation", + "columnName": "is_upload_date_approximation", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_streams_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "stream_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessDate", + "columnName": "access_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatCount", + "columnName": "repeat_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id", + "access_date" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_stream_history_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "createSql": "CREATE INDEX `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "stream_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressTime", + "columnName": "progress_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX `index_playlists_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "playlist_stream_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "playlistUid", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "join_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "playlist_id", + "join_index" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_playlist_stream_join_playlist_id_join_index", + "unique": true, + "columnNames": [ + "playlist_id", + "join_index" + ], + "createSql": "CREATE UNIQUE INDEX `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" + }, + { + "name": "index_playlist_stream_join_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "createSql": "CREATE INDEX `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "remote_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamCount", + "columnName": "stream_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_remote_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_remote_playlists_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id", + "subscription_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_feed_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "createSql": "CREATE INDEX `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_feed_group_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "createSql": "CREATE INDEX `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed_group_subscription_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "feedGroupId", + "columnName": "group_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "group_id", + "subscription_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_feed_group_subscription_join_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "createSql": "CREATE INDEX `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "feed_group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "group_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_last_updated", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "subscription_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9f825b1ee281480bedd38b971feac327')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/schabi/newpipe/database/AppDatabaseTest.kt b/app/src/androidTest/java/org/schabi/newpipe/database/AppDatabaseTest.kt new file mode 100644 index 000000000..9ecea9f86 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/database/AppDatabaseTest.kt @@ -0,0 +1,79 @@ +package org.schabi.newpipe.database + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.schabi.newpipe.extractor.stream.StreamType + +@RunWith(AndroidJUnit4::class) +class AppDatabaseTest { + companion object { + private const val DEFAULT_SERVICE_ID = 0 + private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4" + private const val DEFAULT_TITLE = "Test Title" + private val DEFAULT_TYPE = StreamType.VIDEO_STREAM + private const val DEFAULT_DURATION = 480L + private const val DEFAULT_UPLOADER_NAME = "Uploader Test" + private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg" + } + + @get:Rule val testHelper = MigrationTestHelper(InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory()); + + @Test + fun migrateDatabaseFrom2to3() { + val databaseInV2 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_2) + + databaseInV2.run { + insert("streams", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { + // put("uid", null) + put("service_id", DEFAULT_SERVICE_ID) + put("url", DEFAULT_URL) + put("title", DEFAULT_TITLE) + put("stream_type", DEFAULT_TYPE.name) + put("duration", DEFAULT_DURATION) + put("uploader", DEFAULT_UPLOADER_NAME) + put("thumbnail_url", DEFAULT_THUMBNAIL) + }) + close() + } + + testHelper.runMigrationsAndValidate(AppDatabase.DATABASE_NAME, Migrations.DB_VER_3, + true, Migrations.MIGRATION_2_3); + + val migratedDatabaseV3 = getMigratedDatabase() + val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst() + assertEquals(1, listFromDB.size) + + val streamFromMigratedDatabase = listFromDB.first() + assertEquals(DEFAULT_SERVICE_ID, streamFromMigratedDatabase.serviceId) + assertEquals(DEFAULT_URL, streamFromMigratedDatabase.url) + assertEquals(DEFAULT_TITLE, streamFromMigratedDatabase.title) + assertEquals(DEFAULT_TYPE, streamFromMigratedDatabase.streamType) + assertEquals(DEFAULT_DURATION, streamFromMigratedDatabase.duration) + assertEquals(DEFAULT_UPLOADER_NAME, streamFromMigratedDatabase.uploader) + assertEquals(DEFAULT_THUMBNAIL, streamFromMigratedDatabase.thumbnailUrl) + assertNull(streamFromMigratedDatabase.viewCount) + assertNull(streamFromMigratedDatabase.textualUploadDate) + assertNull(streamFromMigratedDatabase.uploadDate) + assertNull(streamFromMigratedDatabase.isUploadDateApproximation) + } + + private fun getMigratedDatabase(): AppDatabase { + val database: AppDatabase = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), + AppDatabase::class.java, AppDatabase.DATABASE_NAME) + .build() + testHelper.closeWhenFinished(database) + return database + } +} \ No newline at end of file diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index a16d6796a..5cc2fa66a 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -6,12 +6,5 @@ - - - + tools:replace="android:name" /> \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7ca04eed0..df1c27ffa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -74,6 +74,7 @@ + + @@ -244,14 +246,7 @@ - - - - - - - - + @@ -277,8 +272,26 @@ - + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java b/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java new file mode 100644 index 000000000..9fd32b735 --- /dev/null +++ b/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java @@ -0,0 +1,318 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.fragment.app; + +import android.os.Bundle; +import android.os.Parcelable; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; +import androidx.viewpager.widget.PagerAdapter; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; + +// TODO: Replace this deprecated class with its ViewPager2 counterpart + +/** + * This is a copy from {@link androidx.fragment.app.FragmentStatePagerAdapter}. + *

+ * It includes a workaround to fix the menu visibility when the adapter is restored. + *

+ * When restoring the state of this adapter, all the fragments' menu visibility were set to false, + * effectively disabling the menu from the user until he switched pages or another event that triggered the + * menu to be visible again happened. + *

+ *
Check out the changes in: + *

    + *
  • {@link #saveState()}
  • + *
  • {@link #restoreState(Parcelable, ClassLoader)}
  • + *
+ */ +@SuppressWarnings("deprecation") +public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapter { + private static final String TAG = "FragmentStatePagerAdapt"; + private static final boolean DEBUG = false; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({BEHAVIOR_SET_USER_VISIBLE_HINT, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT}) + private @interface Behavior { } + + /** + * Indicates that {@link Fragment#setUserVisibleHint(boolean)} will be called when the current + * fragment changes. + * + * @deprecated This behavior relies on the deprecated + * {@link Fragment#setUserVisibleHint(boolean)} API. Use + * {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} to switch to its replacement, + * {@link FragmentTransaction#setMaxLifecycle}. + * @see #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int) + */ + @Deprecated + public static final int BEHAVIOR_SET_USER_VISIBLE_HINT = 0; + + /** + * Indicates that only the current fragment will be in the {@link Lifecycle.State#RESUMED} + * state. All other Fragments are capped at {@link Lifecycle.State#STARTED}. + * + * @see #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int) + */ + public static final int BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT = 1; + + private final FragmentManager mFragmentManager; + private final int mBehavior; + private FragmentTransaction mCurTransaction = null; + + private ArrayList mSavedState = new ArrayList(); + private ArrayList mFragments = new ArrayList(); + private Fragment mCurrentPrimaryItem = null; + + /** + * Constructor for {@link FragmentStatePagerAdapterMenuWorkaround} that sets the fragment manager for the + * adapter. This is the equivalent of calling + * {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} and passing in + * {@link #BEHAVIOR_SET_USER_VISIBLE_HINT}. + * + *

Fragments will have {@link Fragment#setUserVisibleHint(boolean)} called whenever the + * current Fragment changes.

+ * + * @param fm fragment manager that will interact with this adapter + * @deprecated use {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} with + * {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} + */ + @Deprecated + public FragmentStatePagerAdapterMenuWorkaround(@NonNull FragmentManager fm) { + this(fm, BEHAVIOR_SET_USER_VISIBLE_HINT); + } + + /** + * Constructor for {@link FragmentStatePagerAdapterMenuWorkaround}. + * + * If {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} is passed in, then only the current + * Fragment is in the {@link Lifecycle.State#RESUMED} state, while all other fragments are + * capped at {@link Lifecycle.State#STARTED}. If {@link #BEHAVIOR_SET_USER_VISIBLE_HINT} is + * passed, all fragments are in the {@link Lifecycle.State#RESUMED} state and there will be + * callbacks to {@link Fragment#setUserVisibleHint(boolean)}. + * + * @param fm fragment manager that will interact with this adapter + * @param behavior determines if only current fragments are in a resumed state + */ + public FragmentStatePagerAdapterMenuWorkaround(@NonNull FragmentManager fm, + @Behavior int behavior) { + mFragmentManager = fm; + mBehavior = behavior; + } + + /** + * Return the Fragment associated with a specified position. + */ + @NonNull + public abstract Fragment getItem(int position); + + @Override + public void startUpdate(@NonNull ViewGroup container) { + if (container.getId() == View.NO_ID) { + throw new IllegalStateException("ViewPager with adapter " + this + + " requires a view id"); + } + } + + @SuppressWarnings("deprecation") + @NonNull + @Override + public Object instantiateItem(@NonNull ViewGroup container, int position) { + // If we already have this item instantiated, there is nothing + // to do. This can happen when we are restoring the entire pager + // from its saved state, where the fragment manager has already + // taken care of restoring the fragments we previously had instantiated. + if (mFragments.size() > position) { + Fragment f = mFragments.get(position); + if (f != null) { + return f; + } + } + + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction(); + } + + Fragment fragment = getItem(position); + if (DEBUG) Log.v(TAG, "Adding item #" + position + ": f=" + fragment); + if (mSavedState.size() > position) { + Fragment.SavedState fss = mSavedState.get(position); + if (fss != null) { + fragment.setInitialSavedState(fss); + } + } + while (mFragments.size() <= position) { + mFragments.add(null); + } + fragment.setMenuVisibility(false); + if (mBehavior == BEHAVIOR_SET_USER_VISIBLE_HINT) { + fragment.setUserVisibleHint(false); + } + + mFragments.set(position, fragment); + mCurTransaction.add(container.getId(), fragment); + + if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.STARTED); + } + + return fragment; + } + + @Override + public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + Fragment fragment = (Fragment) object; + + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction(); + } + if (DEBUG) Log.v(TAG, "Removing item #" + position + ": f=" + object + + " v=" + ((Fragment)object).getView()); + while (mSavedState.size() <= position) { + mSavedState.add(null); + } + mSavedState.set(position, fragment.isAdded() + ? mFragmentManager.saveFragmentInstanceState(fragment) : null); + mFragments.set(position, null); + + mCurTransaction.remove(fragment); + if (fragment == mCurrentPrimaryItem) { + mCurrentPrimaryItem = null; + } + } + + @Override + @SuppressWarnings({"ReferenceEquality", "deprecation"}) + public void setPrimaryItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + Fragment fragment = (Fragment)object; + if (fragment != mCurrentPrimaryItem) { + if (mCurrentPrimaryItem != null) { + mCurrentPrimaryItem.setMenuVisibility(false); + if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction(); + } + mCurTransaction.setMaxLifecycle(mCurrentPrimaryItem, Lifecycle.State.STARTED); + } else { + mCurrentPrimaryItem.setUserVisibleHint(false); + } + } + fragment.setMenuVisibility(true); + if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction(); + } + mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.RESUMED); + } else { + fragment.setUserVisibleHint(true); + } + + mCurrentPrimaryItem = fragment; + } + } + + @Override + public void finishUpdate(@NonNull ViewGroup container) { + if (mCurTransaction != null) { + mCurTransaction.commitNowAllowingStateLoss(); + mCurTransaction = null; + } + } + + @Override + public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { + return ((Fragment)object).getView() == view; + } + + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + private final String SELECTED_FRAGMENT = "selected_fragment"; + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + @Override + @Nullable + public Parcelable saveState() { + Bundle state = null; + if (mSavedState.size() > 0) { + state = new Bundle(); + Fragment.SavedState[] fss = new Fragment.SavedState[mSavedState.size()]; + mSavedState.toArray(fss); + state.putParcelableArray("states", fss); + } + for (int i=0; i keys = bundle.keySet(); + for (String key: keys) { + if (key.startsWith("f")) { + int index = Integer.parseInt(key.substring(1)); + Fragment f = mFragmentManager.getFragment(bundle, key); + if (f != null) { + while (mFragments.size() <= index) { + mFragments.add(null); + } + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + final boolean wasSelected = bundle.getString(SELECTED_FRAGMENT, "").equals(key); + f.setMenuVisibility(wasSelected); + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + mFragments.set(index, f); + } else { + Log.w(TAG, "Bad fragment at key " + key); + } + } + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 29dde41c1..1d7a930ba 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -159,7 +159,7 @@ public class MainActivity extends AppCompatActivity { .add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, R.string.tab_subscriptions) .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel)); drawerItems.getMenu() - .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_whats_new) + .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title) .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.rss)); drawerItems.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks) @@ -240,7 +240,7 @@ public class MainActivity extends AppCompatActivity { NavigationHelper.openSubscriptionFragment(getSupportFragmentManager()); break; case ITEM_ID_FEED: - NavigationHelper.openWhatsNewFragment(getSupportFragmentManager()); + NavigationHelper.openFeedFragment(getSupportFragmentManager()); break; case ITEM_ID_BOOKMARKS: NavigationHelper.openBookmarksFragment(getSupportFragmentManager()); @@ -389,7 +389,7 @@ public class MainActivity extends AppCompatActivity { .add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, R.string.tab_subscriptions) .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel)); drawerItems.getMenu() - .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_whats_new) + .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title) .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.rss)); drawerItems.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks) diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java index f3356d6e8..81b5dd72f 100644 --- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java @@ -1,13 +1,16 @@ package org.schabi.newpipe; -import androidx.room.Room; import android.content.Context; +import android.database.Cursor; + import androidx.annotation.NonNull; +import androidx.room.Room; import org.schabi.newpipe.database.AppDatabase; import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME; -import static org.schabi.newpipe.database.Migrations.MIGRATION_11_12; +import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2; +import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3; public final class NewPipeDatabase { @@ -20,8 +23,7 @@ public final class NewPipeDatabase { private static AppDatabase getDatabase(Context context) { return Room .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME) - .addMigrations(MIGRATION_11_12) - .fallbackToDestructiveMigration() + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build(); } @@ -39,4 +41,14 @@ public final class NewPipeDatabase { return result; } + + public static void checkpoint() { + if (databaseInstance == null) { + throw new IllegalStateException("database is not initialized"); + } + Cursor c = databaseInstance.query("pragma wal_checkpoint(full)", null); + if (c.moveToFirst() && c.getInt(0) == 1) { + throw new RuntimeException("Checkpoint was blocked from completing"); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 1be6e096a..1ed659e47 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -9,12 +9,6 @@ import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.os.Bundle; import android.preference.PreferenceManager; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; import android.text.TextUtils; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; @@ -26,6 +20,12 @@ import android.widget.RadioButton; import android.widget.RadioGroup; import android.widget.Toast; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.NotificationCompat; import androidx.fragment.app.FragmentManager; import org.schabi.newpipe.download.DownloadDialog; @@ -49,12 +49,11 @@ import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.ThemeHelper; +import org.schabi.newpipe.util.urlfinder.UrlFinder; import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; -import java.util.HashSet; import java.util.List; import icepick.Icepick; @@ -625,78 +624,18 @@ public class RouterActivity extends AppCompatActivity { // Utils //////////////////////////////////////////////////////////////////////////*/ - /** - * Removes invisible separators (\p{Z}) and punctuation characters including - * brackets (\p{P}). See http://www.regular-expressions.info/unicode.html for - * more details. - */ - private final static String REGEX_REMOVE_FROM_URL = "[\\p{Z}\\p{P}]"; - + @Nullable private String getUrl(Intent intent) { - // first gather data and find service - String videoUrl = null; + String foundUrl = null; if (intent.getData() != null) { - // this means the video was called though another app - videoUrl = intent.getData().toString(); + // Called from another app + foundUrl = intent.getData().toString(); } else if (intent.getStringExtra(Intent.EXTRA_TEXT) != null) { - //this means that vidoe was called through share menu - String extraText = intent.getStringExtra(Intent.EXTRA_TEXT); - final String[] uris = getUris(extraText); - videoUrl = uris.length > 0 ? uris[0] : null; + // Called from the share menu + final String extraText = intent.getStringExtra(Intent.EXTRA_TEXT); + foundUrl = UrlFinder.firstUrlFromInput(extraText); } - return videoUrl; - } - - private String removeHeadingGibberish(final String input) { - int start = 0; - for (int i = input.indexOf("://") - 1; i >= 0; i--) { - if (!input.substring(i, i + 1).matches("\\p{L}")) { - start = i + 1; - break; - } - } - return input.substring(start, input.length()); - } - - private String trim(final String input) { - if (input == null || input.length() < 1) { - return input; - } else { - String output = input; - while (output.length() > 0 && output.substring(0, 1).matches(REGEX_REMOVE_FROM_URL)) { - output = output.substring(1); - } - while (output.length() > 0 - && output.substring(output.length() - 1, output.length()).matches(REGEX_REMOVE_FROM_URL)) { - output = output.substring(0, output.length() - 1); - } - return output; - } - } - - /** - * Retrieves all Strings which look remotely like URLs from a text. - * Used if NewPipe was called through share menu. - * - * @param sharedText text to scan for URLs. - * @return potential URLs - */ - protected String[] getUris(final String sharedText) { - final Collection result = new HashSet<>(); - if (sharedText != null) { - final String[] array = sharedText.split("\\p{Space}"); - for (String s : array) { - s = trim(s); - if (s.length() != 0) { - if (s.matches(".+://.+")) { - result.add(removeHeadingGibberish(s)); - } else if (s.matches(".+\\..+")) { - result.add("http://" + s); - } - } - } - } - return result.toArray(new String[result.size()]); + return foundUrl; } } diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.java b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.java index 7fab44f8f..0a4e9e865 100644 --- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.java +++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.java @@ -45,7 +45,8 @@ public class AboutActivity extends AppCompatActivity { new SoftwareComponent("RxJava", "2016 - 2020", "RxJava Contributors", "https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2), new SoftwareComponent("RxBinding", "2015 - 2018", "Jake Wharton", "https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2), new SoftwareComponent("PrettyTime", "2012 - 2020", "Lincoln Baxter, III", "https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2), - new SoftwareComponent("Markwon", "2017 - 2020", "Noties", "https://github.com/noties/Markwon", StandardLicenses.APACHE2) + new SoftwareComponent("Markwon", "2017 - 2020", "Noties", "https://github.com/noties/Markwon", StandardLicenses.APACHE2), + new SoftwareComponent("Groupie", "2016", "Lisa Wray", "https://github.com/lisawray/groupie", StandardLicenses.MIT) }; /** diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java index d374f254b..d3cd6eb80 100644 --- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java @@ -4,6 +4,12 @@ import androidx.room.Database; import androidx.room.RoomDatabase; import androidx.room.TypeConverters; +import org.schabi.newpipe.database.feed.dao.FeedDAO; +import org.schabi.newpipe.database.feed.dao.FeedGroupDAO; +import org.schabi.newpipe.database.feed.model.FeedEntity; +import org.schabi.newpipe.database.feed.model.FeedGroupEntity; +import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity; +import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity; import org.schabi.newpipe.database.history.dao.SearchHistoryDAO; import org.schabi.newpipe.database.history.dao.StreamHistoryDAO; import org.schabi.newpipe.database.history.model.SearchHistoryEntry; @@ -21,35 +27,33 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.database.subscription.SubscriptionDAO; import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import static org.schabi.newpipe.database.Migrations.DB_VER_12_0; +import static org.schabi.newpipe.database.Migrations.DB_VER_3; @TypeConverters({Converters.class}) @Database( entities = { SubscriptionEntity.class, SearchHistoryEntry.class, StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class, - PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class + PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class, + FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, + FeedLastUpdatedEntity.class }, - version = DB_VER_12_0, - exportSchema = false + version = DB_VER_3 ) public abstract class AppDatabase extends RoomDatabase { - public static final String DATABASE_NAME = "newpipe.db"; - public abstract SubscriptionDAO subscriptionDAO(); - public abstract SearchHistoryDAO searchHistoryDAO(); public abstract StreamDAO streamDAO(); - public abstract StreamHistoryDAO streamHistoryDAO(); - public abstract StreamStateDAO streamStateDAO(); public abstract PlaylistDAO playlistDAO(); - public abstract PlaylistStreamDAO playlistStreamDAO(); - public abstract PlaylistRemoteDAO playlistRemoteDAO(); + + public abstract FeedDAO feedDAO(); + public abstract FeedGroupDAO feedGroupDAO(); + public abstract SubscriptionDAO subscriptionDAO(); } diff --git a/app/src/main/java/org/schabi/newpipe/database/Converters.java b/app/src/main/java/org/schabi/newpipe/database/Converters.java index bb781d194..2f510c8ec 100644 --- a/app/src/main/java/org/schabi/newpipe/database/Converters.java +++ b/app/src/main/java/org/schabi/newpipe/database/Converters.java @@ -3,6 +3,7 @@ package org.schabi.newpipe.database; import androidx.room.TypeConverter; import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.local.subscription.FeedGroupIcon; import java.util.Date; @@ -37,4 +38,18 @@ public class Converters { public static String stringOf(StreamType streamType) { return streamType.name(); } + + @TypeConverter + public static Integer integerOf(FeedGroupIcon feedGroupIcon) { + return feedGroupIcon.getId(); + } + + @TypeConverter + public static FeedGroupIcon feedGroupIconOf(Integer id) { + for (FeedGroupIcon icon : FeedGroupIcon.values()) { + if (icon.getId() == id) return icon; + } + + throw new IllegalArgumentException("There's no feed group icon with the id \"" + id + "\""); + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.java b/app/src/main/java/org/schabi/newpipe/database/Migrations.java index 07d9749b2..ccb097a7b 100644 --- a/app/src/main/java/org/schabi/newpipe/database/Migrations.java +++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.java @@ -8,14 +8,14 @@ import android.util.Log; import org.schabi.newpipe.BuildConfig; public class Migrations { - - public static final int DB_VER_11_0 = 1; - public static final int DB_VER_12_0 = 2; + public static final int DB_VER_1 = 1; + public static final int DB_VER_2 = 2; + public static final int DB_VER_3 = 3; public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release"); private static final String TAG = Migrations.class.getName(); - public static final Migration MIGRATION_11_12 = new Migration(DB_VER_11_0, DB_VER_12_0) { + public static final Migration MIGRATION_1_2 = new Migration(DB_VER_1, DB_VER_2) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { if(DEBUG) { @@ -71,4 +71,32 @@ public class Migrations { } } }; + + public static final Migration MIGRATION_2_3 = new Migration(DB_VER_2, DB_VER_3) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + // Add NOT NULLs and new fields + database.execSQL("CREATE TABLE IF NOT EXISTS streams_new " + + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, stream_type TEXT NOT NULL," + + " duration INTEGER NOT NULL, uploader TEXT NOT NULL, thumbnail_url TEXT, view_count INTEGER, textual_upload_date TEXT, upload_date INTEGER," + + " is_upload_date_approximation INTEGER)"); + + database.execSQL("INSERT INTO streams_new (uid, service_id, url, title, stream_type, duration, uploader, thumbnail_url, view_count, textual_upload_date, upload_date, is_upload_date_approximation)"+ + " SELECT uid, service_id, url, title, stream_type, duration, uploader, thumbnail_url, NULL, NULL, NULL, NULL FROM streams"); + + database.execSQL("DROP TABLE streams"); + database.execSQL("ALTER TABLE streams_new RENAME TO streams"); + database.execSQL("CREATE UNIQUE INDEX index_streams_service_id_url ON streams (service_id, url)"); + + // Tables for feed feature + database.execSQL("CREATE TABLE IF NOT EXISTS feed (stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, PRIMARY KEY(stream_id, subscription_id), FOREIGN KEY(stream_id) REFERENCES streams(uid) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"); + database.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)"); + database.execSQL("CREATE TABLE IF NOT EXISTS feed_group (uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)"); + database.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)"); + database.execSQL("CREATE TABLE IF NOT EXISTS feed_group_subscription_join (group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, PRIMARY KEY(group_id, subscription_id), FOREIGN KEY(group_id) REFERENCES feed_group(uid) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"); + database.execSQL("CREATE INDEX index_feed_group_subscription_join_subscription_id ON feed_group_subscription_join (subscription_id)"); + database.execSQL("CREATE TABLE IF NOT EXISTS feed_last_updated (subscription_id INTEGER NOT NULL, last_updated INTEGER, PRIMARY KEY(subscription_id), FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"); + } + }; + } diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt new file mode 100644 index 000000000..cba834942 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt @@ -0,0 +1,147 @@ +package org.schabi.newpipe.database.feed.dao + +import androidx.room.* +import io.reactivex.Flowable +import org.schabi.newpipe.database.feed.model.FeedEntity +import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import java.util.* + +@Dao +abstract class FeedDAO { + @Query("DELETE FROM feed") + abstract fun deleteAll(): Int + + @Query(""" + SELECT s.* FROM streams s + + INNER JOIN feed f + ON s.uid = f.stream_id + + ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC + + LIMIT 500 + """) + abstract fun getAllStreams(): Flowable> + + @Query(""" + SELECT s.* FROM streams s + + INNER JOIN feed f + ON s.uid = f.stream_id + + INNER JOIN feed_group_subscription_join fgs + ON fgs.subscription_id = f.subscription_id + + INNER JOIN feed_group fg + ON fg.uid = fgs.group_id + + WHERE fgs.group_id = :groupId + + ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC + LIMIT 500 + """) + abstract fun getAllStreamsFromGroup(groupId: Long): Flowable> + + @Query(""" + DELETE FROM feed WHERE + + feed.stream_id IN ( + SELECT s.uid FROM streams s + + INNER JOIN feed f + ON s.uid = f.stream_id + + WHERE s.upload_date < :date + ) + """) + abstract fun unlinkStreamsOlderThan(date: Date) + + @Query(""" + DELETE FROM feed + + WHERE feed.subscription_id = :subscriptionId + + AND feed.stream_id IN ( + SELECT s.uid FROM streams s + + INNER JOIN feed f + ON s.uid = f.stream_id + + WHERE s.stream_type = "LIVE_STREAM" OR s.stream_type = "AUDIO_LIVE_STREAM" + ) + """) + abstract fun unlinkOldLivestreams(subscriptionId: Long) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract fun insert(feedEntity: FeedEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract fun insertAll(entities: List): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + internal abstract fun insertLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity): Long + + @Update(onConflict = OnConflictStrategy.IGNORE) + internal abstract fun updateLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity) + + @Transaction + open fun setLastUpdatedForSubscription(lastUpdatedEntity: FeedLastUpdatedEntity) { + val id = insertLastUpdated(lastUpdatedEntity) + + if (id == -1L) { + updateLastUpdated(lastUpdatedEntity) + } + } + + @Query(""" + SELECT MIN(lu.last_updated) FROM feed_last_updated lu + + INNER JOIN feed_group_subscription_join fgs + ON fgs.subscription_id = lu.subscription_id AND fgs.group_id = :groupId + """) + abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable> + + @Query("SELECT MIN(last_updated) FROM feed_last_updated") + abstract fun oldestSubscriptionUpdateFromAll(): Flowable> + + @Query("SELECT COUNT(*) FROM feed_last_updated WHERE last_updated IS NULL") + abstract fun notLoadedCount(): Flowable + + @Query(""" + SELECT COUNT(*) FROM subscriptions s + + INNER JOIN feed_group_subscription_join fgs + ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId + + LEFT JOIN feed_last_updated lu + ON s.uid = lu.subscription_id + + WHERE lu.last_updated IS NULL + """) + abstract fun notLoadedCountForGroup(groupId: Long): Flowable + + @Query(""" + SELECT s.* FROM subscriptions s + + LEFT JOIN feed_last_updated lu + ON s.uid = lu.subscription_id + + WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold + """) + abstract fun getAllOutdated(outdatedThreshold: Date): Flowable> + + @Query(""" + SELECT s.* FROM subscriptions s + + INNER JOIN feed_group_subscription_join fgs + ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId + + LEFT JOIN feed_last_updated lu + ON s.uid = lu.subscription_id + + WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold + """) + abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: Date): Flowable> +} diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt new file mode 100644 index 000000000..d2616f7d6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt @@ -0,0 +1,62 @@ +package org.schabi.newpipe.database.feed.dao + +import androidx.room.* +import io.reactivex.Flowable +import io.reactivex.Maybe +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity + +@Dao +abstract class FeedGroupDAO { + + @Query("SELECT * FROM feed_group ORDER BY sort_order ASC") + abstract fun getAll(): Flowable> + + @Query("SELECT * FROM feed_group WHERE uid = :groupId") + abstract fun getGroup(groupId: Long): Maybe + + @Transaction + open fun insert(feedGroupEntity: FeedGroupEntity): Long { + val nextSortOrder = nextSortOrder() + feedGroupEntity.sortOrder = nextSortOrder + return insertInternal(feedGroupEntity) + } + + @Update(onConflict = OnConflictStrategy.IGNORE) + abstract fun update(feedGroupEntity: FeedGroupEntity): Int + + @Query("DELETE FROM feed_group") + abstract fun deleteAll(): Int + + @Query("DELETE FROM feed_group WHERE uid = :groupId") + abstract fun delete(groupId: Long): Int + + @Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId") + abstract fun getSubscriptionIdsFor(groupId: Long): Flowable> + + @Query("DELETE FROM feed_group_subscription_join WHERE group_id = :groupId") + abstract fun deleteSubscriptionsFromGroup(groupId: Long): Int + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract fun insertSubscriptionsToGroup(entities: List): List + + @Transaction + open fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List) { + deleteSubscriptionsFromGroup(groupId) + insertSubscriptionsToGroup(subscriptionIds.map { FeedGroupSubscriptionEntity(groupId, it) }) + } + + @Transaction + open fun updateOrder(orderMap: Map) { + orderMap.forEach { (groupId, sortOrder) -> updateOrder(groupId, sortOrder) } + } + + @Query("UPDATE feed_group SET sort_order = :sortOrder WHERE uid = :groupId") + abstract fun updateOrder(groupId: Long, sortOrder: Long): Int + + @Query("SELECT IFNULL(MAX(sort_order) + 1, 0) FROM feed_group") + protected abstract fun nextSortOrder(): Long + + @Insert(onConflict = OnConflictStrategy.ABORT) + protected abstract fun insertInternal(feedGroupEntity: FeedGroupEntity): Long +} diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedEntity.kt new file mode 100644 index 000000000..e73af7fcf --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedEntity.kt @@ -0,0 +1,43 @@ +package org.schabi.newpipe.database.feed.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.FEED_TABLE +import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.STREAM_ID +import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.SUBSCRIPTION_ID +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.subscription.SubscriptionEntity + +@Entity(tableName = FEED_TABLE, + primaryKeys = [STREAM_ID, SUBSCRIPTION_ID], + indices = [Index(SUBSCRIPTION_ID)], + foreignKeys = [ + ForeignKey( + entity = StreamEntity::class, + parentColumns = [StreamEntity.STREAM_ID], + childColumns = [STREAM_ID], + onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true), + ForeignKey( + entity = SubscriptionEntity::class, + parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], + childColumns = [SUBSCRIPTION_ID], + onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true) + ] +) +data class FeedEntity( + @ColumnInfo(name = STREAM_ID) + var streamId: Long, + + @ColumnInfo(name = SUBSCRIPTION_ID) + var subscriptionId: Long +) { + + companion object { + const val FEED_TABLE = "feed" + + const val STREAM_ID = "stream_id" + const val SUBSCRIPTION_ID = "subscription_id" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt new file mode 100644 index 000000000..a84568dd6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt @@ -0,0 +1,39 @@ +package org.schabi.newpipe.database.feed.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.FEED_GROUP_TABLE +import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.SORT_ORDER +import org.schabi.newpipe.local.subscription.FeedGroupIcon + +@Entity( + tableName = FEED_GROUP_TABLE, + indices = [Index(SORT_ORDER)] +) +data class FeedGroupEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ID) + val uid: Long, + + @ColumnInfo(name = NAME) + var name: String, + + @ColumnInfo(name = ICON) + var icon: FeedGroupIcon, + + @ColumnInfo(name = SORT_ORDER) + var sortOrder: Long = -1 +) { + companion object { + const val FEED_GROUP_TABLE = "feed_group" + + const val ID = "uid" + const val NAME = "name" + const val ICON = "icon_id" + const val SORT_ORDER = "sort_order" + + const val GROUP_ALL_ID = -1L + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt new file mode 100644 index 000000000..55fe5d4df --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt @@ -0,0 +1,45 @@ +package org.schabi.newpipe.database.feed.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.ForeignKey.CASCADE +import androidx.room.Index +import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.FEED_GROUP_SUBSCRIPTION_TABLE +import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.GROUP_ID +import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.SUBSCRIPTION_ID +import org.schabi.newpipe.database.subscription.SubscriptionEntity + +@Entity( + tableName = FEED_GROUP_SUBSCRIPTION_TABLE, + primaryKeys = [GROUP_ID, SUBSCRIPTION_ID], + indices = [Index(SUBSCRIPTION_ID)], + foreignKeys = [ + ForeignKey( + entity = FeedGroupEntity::class, + parentColumns = [FeedGroupEntity.ID], + childColumns = [GROUP_ID], + onDelete = CASCADE, onUpdate = CASCADE, deferred = true), + + ForeignKey( + entity = SubscriptionEntity::class, + parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], + childColumns = [SUBSCRIPTION_ID], + onDelete = CASCADE, onUpdate = CASCADE, deferred = true) + ] +) +data class FeedGroupSubscriptionEntity( + @ColumnInfo(name = GROUP_ID) + var feedGroupId: Long, + + @ColumnInfo(name = SUBSCRIPTION_ID) + var subscriptionId: Long +) { + + companion object { + const val FEED_GROUP_SUBSCRIPTION_TABLE = "feed_group_subscription_join" + + const val GROUP_ID = "group_id" + const val SUBSCRIPTION_ID = "subscription_id" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedLastUpdatedEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedLastUpdatedEntity.kt new file mode 100644 index 000000000..d6d7e7dec --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedLastUpdatedEntity.kt @@ -0,0 +1,37 @@ +package org.schabi.newpipe.database.feed.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.FEED_LAST_UPDATED_TABLE +import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.SUBSCRIPTION_ID +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import java.util.* + +@Entity( + tableName = FEED_LAST_UPDATED_TABLE, + foreignKeys = [ + ForeignKey( + entity = SubscriptionEntity::class, + parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], + childColumns = [SUBSCRIPTION_ID], + onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true) + ] +) +data class FeedLastUpdatedEntity( + @PrimaryKey + @ColumnInfo(name = SUBSCRIPTION_ID) + var subscriptionId: Long, + + @ColumnInfo(name = LAST_UPDATED) + var lastUpdated: Date? = null +) { + + companion object { + const val FEED_LAST_UPDATED_TABLE = "feed_last_updated" + + const val SUBSCRIPTION_ID = "subscription_id" + const val LAST_UPDATED = "last_updated" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.java b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.java deleted file mode 100644 index ad66451e4..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.schabi.newpipe.database.history.model; - -import androidx.room.ColumnInfo; - -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.extractor.stream.StreamType; - -import java.util.Date; - -public class StreamHistoryEntry { - @ColumnInfo(name = StreamEntity.STREAM_ID) - final public long uid; - @ColumnInfo(name = StreamEntity.STREAM_SERVICE_ID) - final public int serviceId; - @ColumnInfo(name = StreamEntity.STREAM_URL) - final public String url; - @ColumnInfo(name = StreamEntity.STREAM_TITLE) - final public String title; - @ColumnInfo(name = StreamEntity.STREAM_TYPE) - final public StreamType streamType; - @ColumnInfo(name = StreamEntity.STREAM_DURATION) - final public long duration; - @ColumnInfo(name = StreamEntity.STREAM_UPLOADER) - final public String uploader; - @ColumnInfo(name = StreamEntity.STREAM_THUMBNAIL_URL) - final public String thumbnailUrl; - @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) - final public long streamId; - @ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE) - final public Date accessDate; - @ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT) - final public long repeatCount; - - public StreamHistoryEntry(long uid, int serviceId, String url, String title, - StreamType streamType, long duration, String uploader, - String thumbnailUrl, long streamId, Date accessDate, - long repeatCount) { - this.uid = uid; - this.serviceId = serviceId; - this.url = url; - this.title = title; - this.streamType = streamType; - this.duration = duration; - this.uploader = uploader; - this.thumbnailUrl = thumbnailUrl; - this.streamId = streamId; - this.accessDate = accessDate; - this.repeatCount = repeatCount; - } - - public StreamHistoryEntity toStreamHistoryEntity() { - return new StreamHistoryEntity(streamId, accessDate, repeatCount); - } - - public boolean hasEqualValues(StreamHistoryEntry other) { - return this.uid == other.uid && streamId == other.streamId && - accessDate.compareTo(other.accessDate) == 0; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt new file mode 100644 index 000000000..e06ecee36 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt @@ -0,0 +1,30 @@ +package org.schabi.newpipe.database.history.model + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import org.schabi.newpipe.database.stream.model.StreamEntity +import java.util.* + +data class StreamHistoryEntry( + @Embedded + val streamEntity: StreamEntity, + + @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) + val streamId: Long, + + @ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE) + val accessDate: Date, + + @ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT) + val repeatCount: Long +) { + + fun toStreamHistoryEntity(): StreamHistoryEntity { + return StreamHistoryEntity(streamId, accessDate, repeatCount) + } + + fun hasEqualValues(other: StreamHistoryEntry): Boolean { + return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId && + accessDate.compareTo(other.accessDate) == 0 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.java deleted file mode 100644 index fb45c3564..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.java +++ /dev/null @@ -1,60 +0,0 @@ -package org.schabi.newpipe.database.playlist; - -import androidx.room.ColumnInfo; - -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; - -public class PlaylistStreamEntry implements LocalItem { - @ColumnInfo(name = StreamEntity.STREAM_ID) - final public long uid; - @ColumnInfo(name = StreamEntity.STREAM_SERVICE_ID) - final public int serviceId; - @ColumnInfo(name = StreamEntity.STREAM_URL) - final public String url; - @ColumnInfo(name = StreamEntity.STREAM_TITLE) - final public String title; - @ColumnInfo(name = StreamEntity.STREAM_TYPE) - final public StreamType streamType; - @ColumnInfo(name = StreamEntity.STREAM_DURATION) - final public long duration; - @ColumnInfo(name = StreamEntity.STREAM_UPLOADER) - final public String uploader; - @ColumnInfo(name = StreamEntity.STREAM_THUMBNAIL_URL) - final public String thumbnailUrl; - @ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID) - final public long streamId; - @ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX) - final public int joinIndex; - - public PlaylistStreamEntry(long uid, int serviceId, String url, String title, - StreamType streamType, long duration, String uploader, - String thumbnailUrl, long streamId, int joinIndex) { - this.uid = uid; - this.serviceId = serviceId; - this.url = url; - this.title = title; - this.streamType = streamType; - this.duration = duration; - this.uploader = uploader; - this.thumbnailUrl = thumbnailUrl; - this.streamId = streamId; - this.joinIndex = joinIndex; - } - - public StreamInfoItem toStreamInfoItem() throws IllegalArgumentException { - StreamInfoItem item = new StreamInfoItem(serviceId, url, title, streamType); - item.setThumbnailUrl(thumbnailUrl); - item.setUploaderName(uploader); - item.setDuration(duration); - return item; - } - - @Override - public LocalItemType getLocalItemType() { - return LocalItemType.PLAYLIST_STREAM_ITEM; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt new file mode 100644 index 000000000..afaf599b9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt @@ -0,0 +1,34 @@ +package org.schabi.newpipe.database.playlist + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import org.schabi.newpipe.database.LocalItem +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.extractor.stream.StreamInfoItem + +class PlaylistStreamEntry( + @Embedded + val streamEntity: StreamEntity, + + @ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID) + val streamId: Long, + + @ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX) + val joinIndex: Int +) : LocalItem { + + @Throws(IllegalArgumentException::class) + fun toStreamInfoItem(): StreamInfoItem { + val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType) + item.duration = streamEntity.duration + item.uploaderName = streamEntity.uploader + item.thumbnailUrl = streamEntity.thumbnailUrl + + return item + } + + override fun getLocalItemType(): LocalItem.LocalItemType { + return LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.java b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.java deleted file mode 100644 index 9b61eb469..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.schabi.newpipe.database.stream; - -import androidx.room.ColumnInfo; - -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.history.model.StreamHistoryEntity; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; - -import java.util.Date; - -public class StreamStatisticsEntry implements LocalItem { - final public static String STREAM_LATEST_DATE = "latestAccess"; - final public static String STREAM_WATCH_COUNT = "watchCount"; - - @ColumnInfo(name = StreamEntity.STREAM_ID) - final public long uid; - @ColumnInfo(name = StreamEntity.STREAM_SERVICE_ID) - final public int serviceId; - @ColumnInfo(name = StreamEntity.STREAM_URL) - final public String url; - @ColumnInfo(name = StreamEntity.STREAM_TITLE) - final public String title; - @ColumnInfo(name = StreamEntity.STREAM_TYPE) - final public StreamType streamType; - @ColumnInfo(name = StreamEntity.STREAM_DURATION) - final public long duration; - @ColumnInfo(name = StreamEntity.STREAM_UPLOADER) - final public String uploader; - @ColumnInfo(name = StreamEntity.STREAM_THUMBNAIL_URL) - final public String thumbnailUrl; - @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) - final public long streamId; - @ColumnInfo(name = StreamStatisticsEntry.STREAM_LATEST_DATE) - final public Date latestAccessDate; - @ColumnInfo(name = StreamStatisticsEntry.STREAM_WATCH_COUNT) - final public long watchCount; - - public StreamStatisticsEntry(long uid, int serviceId, String url, String title, - StreamType streamType, long duration, String uploader, - String thumbnailUrl, long streamId, Date latestAccessDate, - long watchCount) { - this.uid = uid; - this.serviceId = serviceId; - this.url = url; - this.title = title; - this.streamType = streamType; - this.duration = duration; - this.uploader = uploader; - this.thumbnailUrl = thumbnailUrl; - this.streamId = streamId; - this.latestAccessDate = latestAccessDate; - this.watchCount = watchCount; - } - - public StreamInfoItem toStreamInfoItem() { - StreamInfoItem item = new StreamInfoItem(serviceId, url, title, streamType); - item.setDuration(duration); - item.setUploaderName(uploader); - item.setThumbnailUrl(thumbnailUrl); - return item; - } - - @Override - public LocalItemType getLocalItemType() { - return LocalItemType.STATISTIC_STREAM_ITEM; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt new file mode 100644 index 000000000..70081f8ed --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt @@ -0,0 +1,42 @@ +package org.schabi.newpipe.database.stream + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import org.schabi.newpipe.database.LocalItem +import org.schabi.newpipe.database.history.model.StreamHistoryEntity +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import java.util.* + +class StreamStatisticsEntry( + @Embedded + val streamEntity: StreamEntity, + + @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) + val streamId: Long, + + @ColumnInfo(name = STREAM_LATEST_DATE) + val latestAccessDate: Date, + + @ColumnInfo(name = STREAM_WATCH_COUNT) + val watchCount: Long +) : LocalItem { + + fun toStreamInfoItem(): StreamInfoItem { + val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType) + item.duration = streamEntity.duration + item.uploaderName = streamEntity.uploader + item.thumbnailUrl = streamEntity.thumbnailUrl + + return item + } + + override fun getLocalItemType(): LocalItem.LocalItemType { + return LocalItem.LocalItemType.STATISTIC_STREAM_ITEM + } + + companion object { + const val STREAM_LATEST_DATE = "latestAccess" + const val STREAM_WATCH_COUNT = "watchCount" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.java b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.java deleted file mode 100644 index c89f6163f..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.java +++ /dev/null @@ -1,98 +0,0 @@ -package org.schabi.newpipe.database.stream.dao; - -import androidx.room.Dao; -import androidx.room.Insert; -import androidx.room.OnConflictStrategy; -import androidx.room.Query; -import androidx.room.Transaction; - -import org.schabi.newpipe.database.BasicDAO; -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.database.history.model.StreamHistoryEntity; - -import java.util.ArrayList; -import java.util.List; - -import io.reactivex.Flowable; - -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_SERVICE_ID; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE; - -@Dao -public abstract class StreamDAO implements BasicDAO { - @Override - @Query("SELECT * FROM " + STREAM_TABLE) - public abstract Flowable> getAll(); - - @Override - @Query("DELETE FROM " + STREAM_TABLE) - public abstract int deleteAll(); - - @Override - @Query("SELECT * FROM " + STREAM_TABLE + " WHERE " + STREAM_SERVICE_ID + " = :serviceId") - public abstract Flowable> listByService(int serviceId); - - @Query("SELECT * FROM " + STREAM_TABLE + " WHERE " + - STREAM_URL + " = :url AND " + - STREAM_SERVICE_ID + " = :serviceId") - public abstract Flowable> getStream(long serviceId, String url); - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract void silentInsertAllInternal(final List streams); - - @Query("SELECT " + STREAM_ID + " FROM " + STREAM_TABLE + " WHERE " + - STREAM_URL + " = :url AND " + - STREAM_SERVICE_ID + " = :serviceId") - abstract Long getStreamIdInternal(long serviceId, String url); - - @Transaction - public long upsert(StreamEntity stream) { - final Long streamIdCandidate = getStreamIdInternal(stream.getServiceId(), stream.getUrl()); - - if (streamIdCandidate == null) { - return insert(stream); - } else { - stream.setUid(streamIdCandidate); - update(stream); - return streamIdCandidate; - } - } - - @Transaction - public List upsertAll(List streams) { - silentInsertAllInternal(streams); - - final List streamIds = new ArrayList<>(streams.size()); - for (StreamEntity stream : streams) { - final Long streamId = getStreamIdInternal(stream.getServiceId(), stream.getUrl()); - if (streamId == null) { - throw new IllegalStateException("StreamID cannot be null just after insertion."); - } - - streamIds.add(streamId); - stream.setUid(streamId); - } - - update(streams); - return streamIds; - } - - @Query("DELETE FROM " + STREAM_TABLE + " WHERE " + STREAM_ID + - " NOT IN " + - "(SELECT DISTINCT " + STREAM_ID + " FROM " + STREAM_TABLE + - - " LEFT JOIN " + STREAM_HISTORY_TABLE + - " ON " + STREAM_ID + " = " + - StreamHistoryEntity.STREAM_HISTORY_TABLE + "." + StreamHistoryEntity.JOIN_STREAM_ID + - - " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE + - " ON " + STREAM_ID + " = " + - PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE + "." + PlaylistStreamEntity.JOIN_STREAM_ID + - ")") - public abstract int deleteOrphans(); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt new file mode 100644 index 000000000..43793becb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt @@ -0,0 +1,131 @@ +package org.schabi.newpipe.database.stream.dao + +import androidx.room.* +import io.reactivex.Flowable +import org.schabi.newpipe.database.BasicDAO +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import java.util.* +import kotlin.collections.ArrayList + +@Dao +abstract class StreamDAO : BasicDAO { + @Query("SELECT * FROM streams") + abstract override fun getAll(): Flowable> + + @Query("DELETE FROM streams") + abstract override fun deleteAll(): Int + + @Query("SELECT * FROM streams WHERE service_id = :serviceId") + abstract override fun listByService(serviceId: Int): Flowable> + + @Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId") + abstract fun getStream(serviceId: Long, url: String): Flowable> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + internal abstract fun silentInsertInternal(stream: StreamEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + internal abstract fun silentInsertAllInternal(streams: List): List + + @Query(""" + SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration + FROM streams WHERE url = :url AND service_id = :serviceId + """) + internal abstract fun getMinimalStreamForCompare(serviceId: Int, url: String): StreamCompareFeed? + + @Transaction + open fun upsert(newerStream: StreamEntity): Long { + val uid = silentInsertInternal(newerStream) + + if (uid != -1L) { + newerStream.uid = uid + return uid + } + + compareAndUpdateStream(newerStream) + + update(newerStream) + return newerStream.uid + } + + @Transaction + open fun upsertAll(streams: List): List { + val insertUidList = silentInsertAllInternal(streams) + + val streamIds = ArrayList(streams.size) + for ((index, uid) in insertUidList.withIndex()) { + val newerStream = streams[index] + if (uid != -1L) { + streamIds.add(uid) + newerStream.uid = uid + continue + } + + compareAndUpdateStream(newerStream) + streamIds.add(newerStream.uid) + } + + update(streams) + return streamIds + } + + private fun compareAndUpdateStream(newerStream: StreamEntity) { + val existentMinimalStream = getMinimalStreamForCompare(newerStream.serviceId, newerStream.url) + ?: throw IllegalStateException("Stream cannot be null just after insertion.") + newerStream.uid = existentMinimalStream.uid + + val isNewerStreamLive = newerStream.streamType == AUDIO_LIVE_STREAM || newerStream.streamType == LIVE_STREAM + if (!isNewerStreamLive) { + if (existentMinimalStream.uploadDate != null && existentMinimalStream.isUploadDateApproximation != true) { + newerStream.uploadDate = existentMinimalStream.uploadDate + newerStream.textualUploadDate = existentMinimalStream.textualUploadDate + newerStream.isUploadDateApproximation = existentMinimalStream.isUploadDateApproximation + } + + if (existentMinimalStream.duration > 0 && newerStream.duration < 0) { + newerStream.duration = existentMinimalStream.duration + } + + } + } + + @Query(""" + DELETE FROM streams WHERE + + NOT EXISTS (SELECT 1 FROM stream_history sh + WHERE sh.stream_id = streams.uid) + + AND NOT EXISTS (SELECT 1 FROM playlist_stream_join ps + WHERE ps.stream_id = streams.uid) + + AND NOT EXISTS (SELECT 1 FROM feed f + WHERE f.stream_id = streams.uid) + """) + abstract fun deleteOrphans(): Int + + /** + * Minimal entry class used when comparing/updating an existent stream. + */ + internal data class StreamCompareFeed( + @ColumnInfo(name = STREAM_ID) + var uid: Long = 0, + + @ColumnInfo(name = StreamEntity.STREAM_TYPE) + var streamType: StreamType, + + @ColumnInfo(name = StreamEntity.STREAM_TEXTUAL_UPLOAD_DATE) + var textualUploadDate: String? = null, + + @ColumnInfo(name = StreamEntity.STREAM_UPLOAD_DATE) + var uploadDate: Date? = null, + + @ColumnInfo(name = StreamEntity.STREAM_IS_UPLOAD_DATE_APPROXIMATION) + var isUploadDateApproximation: Boolean? = null, + + @ColumnInfo(name = StreamEntity.STREAM_DURATION) + var duration: Long) +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.java b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.java deleted file mode 100644 index 1f26e214d..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.java +++ /dev/null @@ -1,153 +0,0 @@ -package org.schabi.newpipe.database.stream.model; - -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.Ignore; -import androidx.room.Index; -import androidx.room.PrimaryKey; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.util.Constants; - -import java.io.Serializable; - -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_SERVICE_ID; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL; - -@Entity(tableName = STREAM_TABLE, - indices = {@Index(value = {STREAM_SERVICE_ID, STREAM_URL}, unique = true)}) -public class StreamEntity implements Serializable { - - final public static String STREAM_TABLE = "streams"; - final public static String STREAM_ID = "uid"; - final public static String STREAM_SERVICE_ID = "service_id"; - final public static String STREAM_URL = "url"; - final public static String STREAM_TITLE = "title"; - final public static String STREAM_TYPE = "stream_type"; - final public static String STREAM_DURATION = "duration"; - final public static String STREAM_UPLOADER = "uploader"; - final public static String STREAM_THUMBNAIL_URL = "thumbnail_url"; - - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = STREAM_ID) - private long uid = 0; - - @ColumnInfo(name = STREAM_SERVICE_ID) - private int serviceId = Constants.NO_SERVICE_ID; - - @ColumnInfo(name = STREAM_URL) - private String url; - - @ColumnInfo(name = STREAM_TITLE) - private String title; - - @ColumnInfo(name = STREAM_TYPE) - private StreamType streamType; - - @ColumnInfo(name = STREAM_DURATION) - private Long duration; - - @ColumnInfo(name = STREAM_UPLOADER) - private String uploader; - - @ColumnInfo(name = STREAM_THUMBNAIL_URL) - private String thumbnailUrl; - - public StreamEntity(final int serviceId, final String title, final String url, - final StreamType streamType, final String thumbnailUrl, final String uploader, - final long duration) { - this.serviceId = serviceId; - this.title = title; - this.url = url; - this.streamType = streamType; - this.thumbnailUrl = thumbnailUrl; - this.uploader = uploader; - this.duration = duration; - } - - @Ignore - public StreamEntity(final StreamInfoItem item) { - this(item.getServiceId(), item.getName(), item.getUrl(), item.getStreamType(), item.getThumbnailUrl(), - item.getUploaderName(), item.getDuration()); - } - - @Ignore - public StreamEntity(final StreamInfo info) { - this(info.getServiceId(), info.getName(), info.getUrl(), info.getStreamType(), info.getThumbnailUrl(), - info.getUploaderName(), info.getDuration()); - } - - @Ignore - public StreamEntity(final PlayQueueItem item) { - this(item.getServiceId(), item.getTitle(), item.getUrl(), item.getStreamType(), - item.getThumbnailUrl(), item.getUploader(), item.getDuration()); - } - - public long getUid() { - return uid; - } - - public void setUid(long uid) { - this.uid = uid; - } - - public int getServiceId() { - return serviceId; - } - - public void setServiceId(int serviceId) { - this.serviceId = serviceId; - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public StreamType getStreamType() { - return streamType; - } - - public void setStreamType(StreamType type) { - this.streamType = type; - } - - public Long getDuration() { - return duration; - } - - public void setDuration(Long duration) { - this.duration = duration; - } - - public String getUploader() { - return uploader; - } - - public void setUploader(String uploader) { - this.uploader = uploader; - } - - public String getThumbnailUrl() { - return thumbnailUrl; - } - - public void setThumbnailUrl(String thumbnailUrl) { - this.thumbnailUrl = thumbnailUrl; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt new file mode 100644 index 000000000..ed9dc6b42 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt @@ -0,0 +1,115 @@ +package org.schabi.newpipe.database.stream.model + +import androidx.room.* +import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_SERVICE_ID +import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_TABLE +import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_URL +import org.schabi.newpipe.extractor.localization.DateWrapper +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.player.playqueue.PlayQueueItem +import java.io.Serializable +import java.util.* + +@Entity(tableName = STREAM_TABLE, + indices = [ + Index(value = [STREAM_SERVICE_ID, STREAM_URL], unique = true) + ] +) +data class StreamEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = STREAM_ID) + var uid: Long = 0, + + @ColumnInfo(name = STREAM_SERVICE_ID) + var serviceId: Int, + + @ColumnInfo(name = STREAM_URL) + var url: String, + + @ColumnInfo(name = STREAM_TITLE) + var title: String, + + @ColumnInfo(name = STREAM_TYPE) + var streamType: StreamType, + + @ColumnInfo(name = STREAM_DURATION) + var duration: Long, + + @ColumnInfo(name = STREAM_UPLOADER) + var uploader: String, + + @ColumnInfo(name = STREAM_THUMBNAIL_URL) + var thumbnailUrl: String? = null, + + @ColumnInfo(name = STREAM_VIEWS) + var viewCount: Long? = null, + + @ColumnInfo(name = STREAM_TEXTUAL_UPLOAD_DATE) + var textualUploadDate: String? = null, + + @ColumnInfo(name = STREAM_UPLOAD_DATE) + var uploadDate: Date? = null, + + @ColumnInfo(name = STREAM_IS_UPLOAD_DATE_APPROXIMATION) + var isUploadDateApproximation: Boolean? = null +) : Serializable { + + @Ignore + constructor(item: StreamInfoItem) : this( + serviceId = item.serviceId, url = item.url, title = item.name, + streamType = item.streamType, duration = item.duration, uploader = item.uploaderName, + thumbnailUrl = item.thumbnailUrl, viewCount = item.viewCount, + textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.date()?.time, + isUploadDateApproximation = item.uploadDate?.isApproximation + ) + + @Ignore + constructor(info: StreamInfo) : this( + serviceId = info.serviceId, url = info.url, title = info.name, + streamType = info.streamType, duration = info.duration, uploader = info.uploaderName, + thumbnailUrl = info.thumbnailUrl, viewCount = info.viewCount, + textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.date()?.time, + isUploadDateApproximation = info.uploadDate?.isApproximation + ) + + @Ignore + constructor(item: PlayQueueItem) : this( + serviceId = item.serviceId, url = item.url, title = item.title, + streamType = item.streamType, duration = item.duration, uploader = item.uploader, + thumbnailUrl = item.thumbnailUrl + ) + + fun toStreamInfoItem(): StreamInfoItem { + val item = StreamInfoItem(serviceId, url, title, streamType) + item.duration = duration + item.uploaderName = uploader + item.thumbnailUrl = thumbnailUrl + + if (viewCount != null) item.viewCount = viewCount as Long + item.textualUploadDate = textualUploadDate + item.uploadDate = uploadDate?.let { + DateWrapper(Calendar.getInstance().apply { time = it }, isUploadDateApproximation ?: false) + } + + return item + } + + companion object { + const val STREAM_TABLE = "streams" + const val STREAM_ID = "uid" + const val STREAM_SERVICE_ID = "service_id" + const val STREAM_URL = "url" + const val STREAM_TITLE = "title" + const val STREAM_TYPE = "stream_type" + const val STREAM_DURATION = "duration" + const val STREAM_UPLOADER = "uploader" + const val STREAM_THUMBNAIL_URL = "thumbnail_url" + + const val STREAM_VIEWS = "view_count" + const val STREAM_TEXTUAL_UPLOAD_DATE = "textual_upload_date" + const val STREAM_UPLOAD_DATE = "upload_date" + const val STREAM_IS_UPLOAD_DATE_APPROXIMATION = "is_upload_date_approximation" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java deleted file mode 100644 index 0869d60ff..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.schabi.newpipe.database.subscription; - -import androidx.room.Dao; -import androidx.room.Insert; -import androidx.room.OnConflictStrategy; -import androidx.room.Query; -import androidx.room.Transaction; - -import org.schabi.newpipe.database.BasicDAO; - -import java.util.List; - -import io.reactivex.Flowable; - -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID; -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE; -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_UID; -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL; - -@Dao -public abstract class SubscriptionDAO implements BasicDAO { - @Override - @Query("SELECT * FROM " + SUBSCRIPTION_TABLE) - public abstract Flowable> getAll(); - - @Override - @Query("DELETE FROM " + SUBSCRIPTION_TABLE) - public abstract int deleteAll(); - - @Override - @Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + SUBSCRIPTION_SERVICE_ID + " = :serviceId") - public abstract Flowable> listByService(int serviceId); - - @Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + - SUBSCRIPTION_URL + " LIKE :url AND " + - SUBSCRIPTION_SERVICE_ID + " = :serviceId") - public abstract Flowable> getSubscription(int serviceId, String url); - - @Query("SELECT " + SUBSCRIPTION_UID + " FROM " + SUBSCRIPTION_TABLE + " WHERE " + - SUBSCRIPTION_URL + " LIKE :url AND " + - SUBSCRIPTION_SERVICE_ID + " = :serviceId") - abstract Long getSubscriptionIdInternal(int serviceId, String url); - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract Long insertInternal(final SubscriptionEntity entities); - - @Transaction - public List upsertAll(List entities) { - for (SubscriptionEntity entity : entities) { - Long uid = insertInternal(entity); - - if (uid != -1) { - entity.setUid(uid); - continue; - } - - uid = getSubscriptionIdInternal(entity.getServiceId(), entity.getUrl()); - entity.setUid(uid); - - if (uid == -1) { - throw new IllegalStateException("Invalid subscription id (-1)"); - } - - update(entity); - } - - return entities; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt new file mode 100644 index 000000000..bd13d9088 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt @@ -0,0 +1,60 @@ +package org.schabi.newpipe.database.subscription + +import androidx.room.* +import io.reactivex.Flowable +import io.reactivex.Maybe +import org.schabi.newpipe.database.BasicDAO + +@Dao +abstract class SubscriptionDAO : BasicDAO { + @Query("SELECT COUNT(*) FROM subscriptions") + abstract fun rowCount(): Flowable + + @Query("SELECT * FROM subscriptions WHERE service_id = :serviceId") + abstract override fun listByService(serviceId: Int): Flowable> + + @Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC") + abstract override fun getAll(): Flowable> + + @Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") + abstract fun getSubscriptionFlowable(serviceId: Int, url: String): Flowable> + + @Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") + abstract fun getSubscription(serviceId: Int, url: String): Maybe + + @Query("SELECT * FROM subscriptions WHERE uid = :subscriptionId") + abstract fun getSubscription(subscriptionId: Long): SubscriptionEntity + + @Query("DELETE FROM subscriptions") + abstract override fun deleteAll(): Int + + @Query("DELETE FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") + abstract fun deleteSubscription(serviceId: Int, url: String): Int + + @Query("SELECT uid FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") + internal abstract fun getSubscriptionIdInternal(serviceId: Int, url: String): Long? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + internal abstract fun silentInsertAllInternal(entities: List): List + + @Transaction + open fun upsertAll(entities: List): List { + val insertUidList = silentInsertAllInternal(entities) + + insertUidList.forEachIndexed { index: Int, uidFromInsert: Long -> + val entity = entities[index] + + if (uidFromInsert != -1L) { + entity.uid = uidFromInsert + } else { + val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url) + ?: throw IllegalStateException("Subscription cannot be null just after insertion.") + entity.uid = subscriptionIdFromDb + + update(entity) + } + } + + return entities + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java index 1e69567e1..ec98c583a 100644 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java @@ -19,14 +19,14 @@ import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCR indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)}) public class SubscriptionEntity { - final static String SUBSCRIPTION_UID = "uid"; - final static String SUBSCRIPTION_TABLE = "subscriptions"; - final static String SUBSCRIPTION_SERVICE_ID = "service_id"; - final static String SUBSCRIPTION_URL = "url"; - final static String SUBSCRIPTION_NAME = "name"; - final static String SUBSCRIPTION_AVATAR_URL = "avatar_url"; - final static String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count"; - final static String SUBSCRIPTION_DESCRIPTION = "description"; + public static final String SUBSCRIPTION_UID = "uid"; + public static final String SUBSCRIPTION_TABLE = "subscriptions"; + public static final String SUBSCRIPTION_SERVICE_ID = "service_id"; + public static final String SUBSCRIPTION_URL = "url"; + public static final String SUBSCRIPTION_NAME = "name"; + public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url"; + public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count"; + public static final String SUBSCRIPTION_DESCRIPTION = "description"; @PrimaryKey(autoGenerate = true) private long uid = 0; diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java index 8e328266e..f9852b7b0 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java @@ -17,6 +17,7 @@ import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.ReCaptchaActivity; +import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; @@ -181,6 +182,9 @@ public abstract class BaseStateFragment extends BaseFragment implements ViewC if (exception instanceof ReCaptchaException) { onReCaptchaException((ReCaptchaException) exception); return true; + } else if (exception instanceof ContentNotAvailableException) { + showError(getString(R.string.content_not_available), false); + return true; } else if (exception instanceof IOException) { showError(getString(R.string.network_error), true); return true; diff --git a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java index 88a4c9c63..a157f34bf 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java @@ -16,7 +16,7 @@ import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentStatePagerAdapter; +import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround; import androidx.viewpager.widget.ViewPager; import com.google.android.material.tabs.TabLayout; @@ -136,16 +136,17 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte // Tabs //////////////////////////////////////////////////////////////////////////*/ - public void setupTabs() { + private void setupTabs() { tabsList.clear(); tabsList.addAll(tabsManager.getTabs()); if (pagerAdapter == null || !pagerAdapter.sameTabs(tabsList)) { pagerAdapter = new SelectedTabsPagerAdapter(requireContext(), getChildFragmentManager(), tabsList); } - // Clear previous tabs/fragments and set new adapter - viewPager.setAdapter(pagerAdapter); + + viewPager.setAdapter(null); viewPager.setOffscreenPageLimit(tabsList.size()); + viewPager.setAdapter(pagerAdapter); updateTabsIconAndDescription(); updateTitleForTab(viewPager.getCurrentItem()); @@ -184,7 +185,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte updateTitleForTab(tab.getPosition()); } - private static class SelectedTabsPagerAdapter extends FragmentStatePagerAdapter { + private static class SelectedTabsPagerAdapter extends FragmentStatePagerAdapterMenuWorkaround { private final Context context; private final List internalTabsList; @@ -194,6 +195,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte this.internalTabsList = new ArrayList<>(tabsList); } + @NonNull @Override public Fragment getItem(int position) { final Tab tab = internalTabsList.get(position); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index b28c71d72..ebec8db0a 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -51,7 +51,6 @@ import org.schabi.newpipe.download.DownloadDialog; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ParsingException; import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor; @@ -1220,20 +1219,12 @@ public class VideoDetailFragment protected boolean onError(Throwable exception) { if (super.onError(exception)) return true; - else if (exception instanceof ContentNotAvailableException) { - showError(getString(R.string.content_not_available), false); - } else { - int errorId = exception instanceof YoutubeStreamExtractor.DecryptException - ? R.string.youtube_signature_decryption_error - : exception instanceof ParsingException - ? R.string.parsing_error - : R.string.general_error; - onUnrecoverableError(exception, - UserAction.REQUESTED_STREAM, - NewPipe.getNameOfService(serviceId), - url, - errorId); - } + int errorId = exception instanceof YoutubeStreamExtractor.DecryptException ? R.string.youtube_signature_decryption_error + : exception instanceof ExtractionException ? R.string.parsing_error + : R.string.general_error; + + onUnrecoverableError(exception, UserAction.REQUESTED_STREAM, + NewPipe.getNameOfService(serviceId), url, errorId); return true; } @@ -1246,12 +1237,22 @@ public class VideoDetailFragment final boolean playbackResumeEnabled = prefs.getBoolean(activity.getString(R.string.enable_watch_history_key), true) && prefs.getBoolean(activity.getString(R.string.enable_playback_resume_key), true); + if (!playbackResumeEnabled || info.getDuration() <= 0) { positionView.setVisibility(View.INVISIBLE); detailPositionView.setVisibility(View.GONE); - return; + + // TODO: Remove this check when separation of concerns is done. + // (live streams weren't getting updated because they are mixed) + if (!info.getStreamType().equals(StreamType.LIVE_STREAM) && + !info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { + return; + } } final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); + + // TODO: Separate concerns when updating database data. + // (move the updating part to when the loading happens) positionSubscriber = recordManager.loadStreamState(info) .subscribeOn(Schedulers.io()) .onErrorComplete() diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index d6fd1dd00..d55bf3f40 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -59,7 +59,10 @@ public abstract class BaseListFragment extends BaseStateFragment implem @Override public void onAttach(Context context) { super.onAttach(context); - infoListAdapter = new InfoListAdapter(activity); + + if (infoListAdapter == null) { + infoListAdapter = new InfoListAdapter(activity); + } } @Override @@ -78,7 +81,7 @@ public abstract class BaseListFragment extends BaseStateFragment implem @Override public void onDestroy() { super.onDestroy(); - StateSaver.onDestroy(savedState); + if (useDefaultStateSaving) StateSaver.onDestroy(savedState); PreferenceManager.getDefaultSharedPreferences(activity) .unregisterOnSharedPreferenceChangeListener(this); } @@ -103,6 +106,16 @@ public abstract class BaseListFragment extends BaseStateFragment implem //////////////////////////////////////////////////////////////////////////*/ protected StateSaver.SavedState savedState; + protected boolean useDefaultStateSaving = true; + + /** + * If the default implementation of {@link StateSaver.WriteRead} should be used. + * + * @see StateSaver + */ + public void useDefaultStateSaving(boolean useDefault) { + this.useDefaultStateSaving = useDefault; + } @Override public String generateSuffix() { @@ -112,26 +125,28 @@ public abstract class BaseListFragment extends BaseStateFragment implem @Override public void writeTo(Queue objectsToSave) { - objectsToSave.add(infoListAdapter.getItemsList()); + if (useDefaultStateSaving) objectsToSave.add(infoListAdapter.getItemsList()); } @Override @SuppressWarnings("unchecked") public void readFrom(@NonNull Queue savedObjects) throws Exception { - infoListAdapter.getItemsList().clear(); - infoListAdapter.getItemsList().addAll((List) savedObjects.poll()); + if (useDefaultStateSaving) { + infoListAdapter.getItemsList().clear(); + infoListAdapter.getItemsList().addAll((List) savedObjects.poll()); + } } @Override public void onSaveInstanceState(Bundle bundle) { super.onSaveInstanceState(bundle); - savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this); + if (useDefaultStateSaving) savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this); } @Override protected void onRestoreInstanceState(@NonNull Bundle bundle) { super.onRestoreInstanceState(bundle); - savedState = StateSaver.tryToRestore(bundle, this); + if (useDefaultStateSaving) savedState = StateSaver.tryToRestore(bundle, this); } /*////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index 4742fcca1..40df990f9 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -33,7 +33,7 @@ import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.local.subscription.SubscriptionService; +import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.report.UserAction; @@ -66,7 +66,7 @@ public class ChannelFragment extends BaseListInfoFragment { private final CompositeDisposable disposables = new CompositeDisposable(); private Disposable subscribeButtonMonitor; - private SubscriptionService subscriptionService; + private SubscriptionManager subscriptionManager; /*////////////////////////////////////////////////////////////////////////// // Views @@ -109,7 +109,7 @@ public class ChannelFragment extends BaseListInfoFragment { @Override public void onAttach(Context context) { super.onAttach(context); - subscriptionService = SubscriptionService.getInstance(activity); + subscriptionManager = new SubscriptionManager(activity); } @Override @@ -212,8 +212,8 @@ public class ChannelFragment extends BaseListInfoFragment { 0); }; - final Observable> observable = subscriptionService.subscriptionTable() - .getSubscription(info.getServiceId(), info.getUrl()) + final Observable> observable = subscriptionManager.subscriptionTable() + .getSubscriptionFlowable(info.getServiceId(), info.getUrl()) .toObservable(); disposables.add(observable @@ -231,16 +231,16 @@ public class ChannelFragment extends BaseListInfoFragment { } - private Function mapOnSubscribe(final SubscriptionEntity subscription) { + private Function mapOnSubscribe(final SubscriptionEntity subscription, ChannelInfo info) { return (@NonNull Object o) -> { - subscriptionService.subscriptionTable().insert(subscription); + subscriptionManager.insertSubscription(subscription, info); return o; }; } private Function mapOnUnsubscribe(final SubscriptionEntity subscription) { return (@NonNull Object o) -> { - subscriptionService.subscriptionTable().delete(subscription); + subscriptionManager.deleteSubscription(subscription); return o; }; } @@ -258,7 +258,7 @@ public class ChannelFragment extends BaseListInfoFragment { "Updating Subscription for " + info.getUrl(), R.string.subscription_update_failed); - disposables.add(subscriptionService.updateChannelInfo(info) + disposables.add(subscriptionManager.updateChannelInfo(info) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(onComplete, onError)); @@ -288,7 +288,7 @@ public class ChannelFragment extends BaseListInfoFragment { private Consumer> getSubscribeUpdateMonitor(final ChannelInfo info) { return (List subscriptionEntities) -> { if (DEBUG) - Log.d(TAG, "subscriptionService.subscriptionTable.doOnNext() called with: subscriptionEntities = [" + subscriptionEntities + "]"); + Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: subscriptionEntities = [" + subscriptionEntities + "]"); if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose(); if (subscriptionEntities.isEmpty()) { @@ -300,7 +300,7 @@ public class ChannelFragment extends BaseListInfoFragment { info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount()); - subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel)); + subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel, info)); } else { if (DEBUG) Log.d(TAG, "Found subscription to this channel!"); final SubscriptionEntity subscription = subscriptionEntities.get(0); @@ -440,16 +440,12 @@ public class ChannelFragment extends BaseListInfoFragment { protected boolean onError(Throwable exception) { if (super.onError(exception)) return true; - if (exception instanceof ContentNotAvailableException) { - showError(getString(R.string.content_not_available), false); - } else { - int errorId = exception instanceof ExtractionException ? R.string.parsing_error : R.string.general_error; - onUnrecoverableError(exception, - UserAction.REQUESTED_CHANNEL, - NewPipe.getNameOfService(serviceId), - url, - errorId); - } + int errorId = exception instanceof ExtractionException + ? R.string.parsing_error : R.string.general_error; + + onUnrecoverableError(exception, UserAction.REQUESTED_CHANNEL, + NewPipe.getNameOfService(serviceId), url, errorId); + return true; } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java index 594ec81af..54cb6326c 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java @@ -122,7 +122,7 @@ public class InfoListAdapter extends RecyclerView.Adapter data) { + public void addInfoItemList(@Nullable final List data) { if (data == null) { return; } @@ -147,6 +147,12 @@ public class InfoListAdapter extends RecyclerView.Adapter data) { + infoItemList.clear(); + infoItemList.addAll(data); + notifyDataSetChanged(); + } + public void addInfoItem(@Nullable InfoItem data) { if (data == null) { return; diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt new file mode 100644 index 000000000..5231e16c6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt @@ -0,0 +1,166 @@ +package org.schabi.newpipe.local.feed + +import android.content.Context +import android.util.Log +import io.reactivex.Completable +import io.reactivex.Flowable +import io.reactivex.Maybe +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipe.MainActivity.DEBUG +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.database.feed.model.FeedEntity +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.local.subscription.FeedGroupIcon +import java.util.* +import kotlin.collections.ArrayList + +class FeedDatabaseManager(context: Context) { + private val database = NewPipeDatabase.getInstance(context) + private val feedTable = database.feedDAO() + private val feedGroupTable = database.feedGroupDAO() + private val streamTable = database.streamDAO() + + companion object { + /** + * Only items that are newer than this will be saved. + */ + val FEED_OLDEST_ALLOWED_DATE: Calendar = Calendar.getInstance().apply { + add(Calendar.WEEK_OF_YEAR, -13) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + } + + fun groups() = feedGroupTable.getAll() + + fun database() = database + + fun asStreamItems(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable> { + val streams = when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> feedTable.getAllStreams() + else -> feedTable.getAllStreamsFromGroup(groupId) + } + + return streams.map> { + val items = ArrayList(it.size) + for (streamEntity in it) items.add(streamEntity.toStreamInfoItem()) + return@map items + } + } + + fun outdatedSubscriptions(outdatedThreshold: Date) = feedTable.getAllOutdated(outdatedThreshold) + + fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable { + return when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount() + else -> feedTable.notLoadedCountForGroup(groupId) + } + } + + fun outdatedSubscriptionsForGroup(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, outdatedThreshold: Date) = + feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold) + + fun markAsOutdated(subscriptionId: Long) = feedTable + .setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null)) + + fun upsertAll(subscriptionId: Long, items: List, + oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time) { + val itemsToInsert = ArrayList() + loop@ for (streamItem in items) { + val uploadDate = streamItem.uploadDate + + itemsToInsert += when { + uploadDate == null && streamItem.streamType == StreamType.LIVE_STREAM -> streamItem + uploadDate != null && uploadDate.date().time >= oldestAllowedDate -> streamItem + else -> continue@loop + } + } + + feedTable.unlinkOldLivestreams(subscriptionId) + + if (itemsToInsert.isNotEmpty()) { + val streamEntities = itemsToInsert.map { StreamEntity(it) } + val streamIds = streamTable.upsertAll(streamEntities) + val feedEntities = streamIds.map { FeedEntity(it, subscriptionId) } + + feedTable.insertAll(feedEntities) + } + + feedTable.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, Calendar.getInstance().time)) + } + + fun removeOrphansOrOlderStreams(oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time) { + feedTable.unlinkStreamsOlderThan(oldestAllowedDate) + streamTable.deleteOrphans() + } + + fun clear() { + feedTable.deleteAll() + val deletedOrphans = streamTable.deleteOrphans() + if (DEBUG) Log.d(this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans") + } + + /////////////////////////////////////////////////////////////////////////// + // Feed Groups + /////////////////////////////////////////////////////////////////////////// + + fun subscriptionIdsForGroup(groupId: Long): Flowable> { + return feedGroupTable.getSubscriptionIdsFor(groupId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List): Completable { + return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun createGroup(name: String, icon: FeedGroupIcon): Maybe { + return Maybe.fromCallable { feedGroupTable.insert(FeedGroupEntity(0, name, icon)) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun getGroup(groupId: Long): Maybe { + return feedGroupTable.getGroup(groupId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun updateGroup(feedGroupEntity: FeedGroupEntity): Completable { + return Completable.fromCallable { feedGroupTable.update(feedGroupEntity) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun deleteGroup(groupId: Long): Completable { + return Completable.fromCallable { feedGroupTable.delete(groupId) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun updateGroupsOrder(groupIdList: List): Completable { + var index = 0L + val orderMap = groupIdList.associateBy({ it }, { index++ }) + + return Completable.fromCallable { feedGroupTable.updateOrder(orderMap) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun oldestSubscriptionUpdate(groupId: Long): Flowable> { + return when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> feedTable.oldestSubscriptionUpdateFromAll() + else -> feedTable.oldestSubscriptionUpdate(groupId) + } + + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java deleted file mode 100644 index 04406c3da..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java +++ /dev/null @@ -1,444 +0,0 @@ -package org.schabi.newpipe.local.feed; - -import android.os.Bundle; -import android.os.Handler; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.fragments.list.BaseListFragment; -import org.schabi.newpipe.local.subscription.SubscriptionService; -import org.schabi.newpipe.report.UserAction; - -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Queue; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -import io.reactivex.Flowable; -import io.reactivex.MaybeObserver; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.disposables.Disposable; - -public class FeedFragment extends BaseListFragment, Void> { - - private static final int OFF_SCREEN_ITEMS_COUNT = 3; - private static final int MIN_ITEMS_INITIAL_LOAD = 8; - private int FEED_LOAD_COUNT = MIN_ITEMS_INITIAL_LOAD; - - private int subscriptionPoolSize; - - private SubscriptionService subscriptionService; - - private AtomicBoolean allItemsLoaded = new AtomicBoolean(false); - private HashSet itemsLoaded = new HashSet<>(); - private final AtomicInteger requestLoadedAtomic = new AtomicInteger(); - - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - private Disposable subscriptionObserver; - private Subscription feedSubscriber; - - /*////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - subscriptionService = SubscriptionService.getInstance(activity); - - FEED_LOAD_COUNT = howManyItemsToLoad(); - } - - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { - - if(!useAsFrontPage) { - setTitle(activity.getString(R.string.fragment_whats_new)); - } - return inflater.inflate(R.layout.fragment_feed, container, false); - } - - @Override - public void onPause() { - super.onPause(); - disposeEverything(); - } - - @Override - public void onResume() { - super.onResume(); - if (wasLoading.get()) doInitialLoadLogic(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - - disposeEverything(); - subscriptionService = null; - compositeDisposable = null; - subscriptionObserver = null; - feedSubscriber = null; - } - - @Override - public void onDestroyView() { - // Do not monitor for updates when user is not viewing the feed fragment. - // This is a waste of bandwidth. - disposeEverything(); - super.onDestroyView(); - } - - @Override - public void setUserVisibleHint(boolean isVisibleToUser) { - super.setUserVisibleHint(isVisibleToUser); - if (activity != null && isVisibleToUser) { - setTitle(activity.getString(R.string.fragment_whats_new)); - } - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - - ActionBar supportActionBar = activity.getSupportActionBar(); - - if(useAsFrontPage) { - supportActionBar.setDisplayShowTitleEnabled(true); - //supportActionBar.setDisplayShowTitleEnabled(false); - } - } - - @Override - public void reloadContent() { - resetFragment(); - super.reloadContent(); - } - - /*////////////////////////////////////////////////////////////////////////// - // StateSaving - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void writeTo(Queue objectsToSave) { - super.writeTo(objectsToSave); - objectsToSave.add(allItemsLoaded); - objectsToSave.add(itemsLoaded); - } - - @Override - @SuppressWarnings("unchecked") - public void readFrom(@NonNull Queue savedObjects) throws Exception { - super.readFrom(savedObjects); - allItemsLoaded = (AtomicBoolean) savedObjects.poll(); - itemsLoaded = (HashSet) savedObjects.poll(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Feed Loader - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void startLoading(boolean forceLoad) { - if (DEBUG) Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]"); - if (subscriptionObserver != null) subscriptionObserver.dispose(); - - if (allItemsLoaded.get()) { - if (infoListAdapter.getItemsList().size() == 0) { - showEmptyState(); - } else { - showListFooter(false); - hideLoading(); - } - - isLoading.set(false); - return; - } - - isLoading.set(true); - showLoading(); - showListFooter(true); - subscriptionObserver = subscriptionService.getSubscription() - .onErrorReturnItem(Collections.emptyList()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::handleResult, this::onError); - } - - @Override - public void handleResult(@androidx.annotation.NonNull List result) { - super.handleResult(result); - - if (result.isEmpty()) { - infoListAdapter.clearStreamItemList(); - showEmptyState(); - return; - } - - subscriptionPoolSize = result.size(); - Flowable.fromIterable(result) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getSubscriptionObserver()); - } - - /** - * Responsible for reacting to user pulling request and starting a request for new feed stream. - *

- * On initialization, it automatically requests the amount of feed needed to display - * a minimum amount required (FEED_LOAD_SIZE). - *

- * Upon receiving a user pull, it creates a Single Observer to fetch the ChannelInfo - * containing the feed streams. - **/ - private Subscriber getSubscriptionObserver() { - return new Subscriber() { - @Override - public void onSubscribe(Subscription s) { - if (feedSubscriber != null) feedSubscriber.cancel(); - feedSubscriber = s; - - int requestSize = FEED_LOAD_COUNT - infoListAdapter.getItemsList().size(); - if (wasLoading.getAndSet(false)) requestSize = FEED_LOAD_COUNT; - - boolean hasToLoad = requestSize > 0; - if (hasToLoad) { - requestLoadedAtomic.set(infoListAdapter.getItemsList().size()); - requestFeed(requestSize); - } - isLoading.set(hasToLoad); - } - - @Override - public void onNext(SubscriptionEntity subscriptionEntity) { - if (!itemsLoaded.contains(subscriptionEntity.getServiceId() + subscriptionEntity.getUrl())) { - subscriptionService.getChannelInfo(subscriptionEntity) - .observeOn(AndroidSchedulers.mainThread()) - .onErrorComplete( - (@io.reactivex.annotations.NonNull Throwable throwable) -> - FeedFragment.super.onError(throwable)) - .subscribe( - getChannelInfoObserver(subscriptionEntity.getServiceId(), - subscriptionEntity.getUrl())); - } else { - requestFeed(1); - } - } - - @Override - public void onError(Throwable exception) { - FeedFragment.this.onError(exception); - } - - @Override - public void onComplete() { - if (DEBUG) Log.d(TAG, "getSubscriptionObserver > onComplete() called"); - } - }; - } - - /** - * On each request, a subscription item from the updated table is transformed - * into a ChannelInfo, containing the latest streams from the channel. - *

- * Currently, the feed uses the first into from the list of streams. - *

- * If chosen feed already displayed, then we request another feed from another - * subscription, until the subscription table runs out of new items. - *

- * This Observer is self-contained and will close itself when complete. However, this - * does not obey the fragment lifecycle and may continue running in the background - * until it is complete. This is done due to RxJava2 no longer propagate errors once - * an observer is unsubscribed while the thread process is still running. - *

- * To solve the above issue, we can either set a global RxJava Error Handler, or - * manage exceptions case by case. This should be done if the current implementation is - * too costly when dealing with larger subscription sets. - * - * @param url + serviceId to put in {@link #allItemsLoaded} to signal that this specific entity has been loaded. - */ - private MaybeObserver getChannelInfoObserver(final int serviceId, final String url) { - return new MaybeObserver() { - private Disposable observer; - - @Override - public void onSubscribe(Disposable d) { - observer = d; - compositeDisposable.add(d); - isLoading.set(true); - } - - // Called only when response is non-empty - @Override - public void onSuccess(final ChannelInfo channelInfo) { - if (infoListAdapter == null || channelInfo.getRelatedItems().isEmpty()) { - onDone(); - return; - } - - final InfoItem item = channelInfo.getRelatedItems().get(0); - // Keep requesting new items if the current one already exists - boolean itemExists = doesItemExist(infoListAdapter.getItemsList(), item); - if (!itemExists) { - infoListAdapter.addInfoItem(item); - //updateSubscription(channelInfo); - } else { - requestFeed(1); - } - onDone(); - } - - @Override - public void onError(Throwable exception) { - showSnackBarError(exception, - UserAction.SUBSCRIPTION, - NewPipe.getNameOfService(serviceId), - url, 0); - requestFeed(1); - onDone(); - } - - // Called only when response is empty - @Override - public void onComplete() { - onDone(); - } - - private void onDone() { - if (observer.isDisposed()) { - return; - } - - itemsLoaded.add(serviceId + url); - compositeDisposable.remove(observer); - - int loaded = requestLoadedAtomic.incrementAndGet(); - if (loaded >= Math.min(FEED_LOAD_COUNT, subscriptionPoolSize)) { - requestLoadedAtomic.set(0); - isLoading.set(false); - } - - if (itemsLoaded.size() == subscriptionPoolSize) { - if (DEBUG) Log.d(TAG, "getChannelInfoObserver > All Items Loaded"); - allItemsLoaded.set(true); - showListFooter(false); - isLoading.set(false); - hideLoading(); - if (infoListAdapter.getItemsList().size() == 0) { - showEmptyState(); - } - } - } - }; - } - - @Override - protected void loadMoreItems() { - isLoading.set(true); - delayHandler.removeCallbacksAndMessages(null); - // Add a little of a delay when requesting more items because the cache is so fast, - // that the view seems stuck to the user when he scroll to the bottom - delayHandler.postDelayed(() -> requestFeed(FEED_LOAD_COUNT), 300); - } - - @Override - protected boolean hasMoreItems() { - return !allItemsLoaded.get(); - } - - private final Handler delayHandler = new Handler(); - - private void requestFeed(final int count) { - if (DEBUG) Log.d(TAG, "requestFeed() called with: count = [" + count + "], feedSubscriber = [" + feedSubscriber + "]"); - if (feedSubscriber == null) return; - - isLoading.set(true); - delayHandler.removeCallbacksAndMessages(null); - feedSubscriber.request(count); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - private void resetFragment() { - if (DEBUG) Log.d(TAG, "resetFragment() called"); - if (subscriptionObserver != null) subscriptionObserver.dispose(); - if (compositeDisposable != null) compositeDisposable.clear(); - if (infoListAdapter != null) infoListAdapter.clearStreamItemList(); - - delayHandler.removeCallbacksAndMessages(null); - requestLoadedAtomic.set(0); - allItemsLoaded.set(false); - showListFooter(false); - itemsLoaded.clear(); - } - - private void disposeEverything() { - if (subscriptionObserver != null) subscriptionObserver.dispose(); - if (compositeDisposable != null) compositeDisposable.clear(); - if (feedSubscriber != null) feedSubscriber.cancel(); - delayHandler.removeCallbacksAndMessages(null); - } - - private boolean doesItemExist(final List items, final InfoItem item) { - for (final InfoItem existingItem : items) { - if (existingItem.getInfoType() == item.getInfoType() && - existingItem.getServiceId() == item.getServiceId() && - existingItem.getName().equals(item.getName()) && - existingItem.getUrl().equals(item.getUrl())) return true; - } - return false; - } - - private int howManyItemsToLoad() { - int heightPixels = getResources().getDisplayMetrics().heightPixels; - int itemHeightPixels = activity.getResources().getDimensionPixelSize(R.dimen.video_item_search_height); - - int items = itemHeightPixels > 0 - ? heightPixels / itemHeightPixels + OFF_SCREEN_ITEMS_COUNT - : MIN_ITEMS_INITIAL_LOAD; - return Math.max(MIN_ITEMS_INITIAL_LOAD, items); - } - - /*////////////////////////////////////////////////////////////////////////// - // Fragment Error Handling - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showError(String message, boolean showRetryButton) { - resetFragment(); - super.showError(message, showRetryButton); - } - - @Override - protected boolean onError(Throwable exception) { - if (super.onError(exception)) return true; - - int errorId = exception instanceof ExtractionException - ? R.string.parsing_error - : R.string.general_error; - onUnrecoverableError(exception, - UserAction.SOMETHING_ELSE, - "none", - "Requesting feed", - errorId); - return true; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt new file mode 100644 index 000000000..d41a2e37b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -0,0 +1,327 @@ +/* + * Copyright 2019 Mauricio Colli + * FeedFragment.kt is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.local.feed + +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import android.view.* +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.preference.PreferenceManager +import icepick.State +import kotlinx.android.synthetic.main.error_retry.* +import kotlinx.android.synthetic.main.fragment_feed.* +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.fragments.list.BaseListFragment +import org.schabi.newpipe.local.feed.service.FeedLoadService +import org.schabi.newpipe.report.UserAction +import org.schabi.newpipe.util.AnimationUtils.animateView +import org.schabi.newpipe.util.Localization +import java.util.* + +class FeedFragment : BaseListFragment() { + private lateinit var viewModel: FeedViewModel + @State @JvmField var listState: Parcelable? = null + + private var groupId = FeedGroupEntity.GROUP_ALL_ID + private var groupName = "" + private var oldestSubscriptionUpdate: Calendar? = null + + init { + setHasOptionsMenu(true) + useDefaultStateSaving(false) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) ?: FeedGroupEntity.GROUP_ALL_ID + groupName = arguments?.getString(KEY_GROUP_NAME) ?: "" + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_feed, container, false) + } + + override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) { + super.onViewCreated(rootView, savedInstanceState) + + viewModel = ViewModelProviders.of(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java) + viewModel.stateLiveData.observe(viewLifecycleOwner, Observer { it?.let(::handleResult) }) + } + + override fun onPause() { + super.onPause() + listState = items_list?.layoutManager?.onSaveInstanceState() + } + + override fun onResume() { + super.onResume() + updateRelativeTimeViews() + } + + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + + if (!isVisibleToUser && view != null) { + updateRelativeTimeViews() + } + } + + override fun initListeners() { + super.initListeners() + refresh_root_view.setOnClickListener { + triggerUpdate() + } + } + + /////////////////////////////////////////////////////////////////////////// + // Menu + /////////////////////////////////////////////////////////////////////////// + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + activity.supportActionBar?.setTitle(R.string.fragment_feed_title) + activity.supportActionBar?.subtitle = groupName + + inflater.inflate(R.menu.menu_feed_fragment, menu) + + if (useAsFrontPage) { + menu.findItem(R.id.menu_item_feed_help).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER); + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.menu_item_feed_help) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + + val usingDedicatedMethod = sharedPreferences.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) + val enableDisableButtonText = when { + usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button + else -> R.string.feed_use_dedicated_fetch_method_enable_button + } + + AlertDialog.Builder(requireContext()) + .setMessage(R.string.feed_use_dedicated_fetch_method_help_text) + .setNeutralButton(enableDisableButtonText) { _, _ -> + sharedPreferences.edit() + .putBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), !usingDedicatedMethod) + .apply() + } + .setPositiveButton(resources.getString(R.string.finish), null) + .create() + .show() + return true + } + + return super.onOptionsItemSelected(item) + } + + override fun onDestroyOptionsMenu() { + super.onDestroyOptionsMenu() + activity?.supportActionBar?.subtitle = null + } + + override fun onDestroy() { + super.onDestroy() + activity?.supportActionBar?.subtitle = null + } + + /////////////////////////////////////////////////////////////////////////// + // Handling + /////////////////////////////////////////////////////////////////////////// + + override fun showLoading() { + animateView(refresh_root_view, false, 0) + animateView(items_list, false, 0) + + animateView(loading_progress_bar, true, 200) + animateView(loading_progress_text, true, 200) + + empty_state_view?.let { animateView(it, false, 0) } + animateView(error_panel, false, 0) + } + + override fun hideLoading() { + animateView(refresh_root_view, true, 200) + animateView(items_list, true, 300) + + animateView(loading_progress_bar, false, 0) + animateView(loading_progress_text, false, 0) + + empty_state_view?.let { animateView(it, false, 0) } + animateView(error_panel, false, 0) + } + + override fun showEmptyState() { + animateView(refresh_root_view, true, 200) + animateView(items_list, false, 0) + + animateView(loading_progress_bar, false, 0) + animateView(loading_progress_text, false, 0) + + empty_state_view?.let { animateView(it, true, 800) } + animateView(error_panel, false, 0) + } + + override fun showError(message: String, showRetryButton: Boolean) { + infoListAdapter.clearStreamItemList() + animateView(refresh_root_view, false, 120) + animateView(items_list, false, 120) + + animateView(loading_progress_bar, false, 120) + animateView(loading_progress_text, false, 120) + + error_message_view.text = message + animateView(error_button_retry, showRetryButton, if (showRetryButton) 600 else 0) + animateView(error_panel, true, 300) + } + + override fun handleResult(result: FeedState) { + when (result) { + is FeedState.ProgressState -> handleProgressState(result) + is FeedState.LoadedState -> handleLoadedState(result) + is FeedState.ErrorState -> if (handleErrorState(result)) return + } + + updateRefreshViewState() + } + + private fun handleProgressState(progressState: FeedState.ProgressState) { + showLoading() + + val isIndeterminate = progressState.currentProgress == -1 && + progressState.maxProgress == -1 + + if (!isIndeterminate) { + loading_progress_text.text = "${progressState.currentProgress}/${progressState.maxProgress}" + } else if (progressState.progressMessage > 0) { + loading_progress_text?.setText(progressState.progressMessage) + } else { + loading_progress_text?.text = "∞/∞" + } + + loading_progress_bar.isIndeterminate = isIndeterminate || + (progressState.maxProgress > 0 && progressState.currentProgress == 0) + loading_progress_bar.progress = progressState.currentProgress + + loading_progress_bar.max = progressState.maxProgress + } + + private fun handleLoadedState(loadedState: FeedState.LoadedState) { + infoListAdapter.setInfoItemList(loadedState.items) + listState?.run { + items_list.layoutManager?.onRestoreInstanceState(listState) + listState = null + } + + oldestSubscriptionUpdate = loadedState.oldestUpdate + + if (loadedState.notLoadedCount > 0) { + refresh_subtitle_text.visibility = View.VISIBLE + refresh_subtitle_text.text = getString(R.string.feed_subscription_not_loaded_count, loadedState.notLoadedCount) + } else { + refresh_subtitle_text.visibility = View.GONE + } + + if (loadedState.itemsErrors.isNotEmpty()) { + showSnackBarError(loadedState.itemsErrors, UserAction.REQUESTED_FEED, + "none", "Loading feed", R.string.general_error) + } + + if (loadedState.items.isEmpty()) { + showEmptyState() + } else { + hideLoading() + } + } + + + private fun handleErrorState(errorState: FeedState.ErrorState): Boolean { + hideLoading() + errorState.error?.let { + onError(errorState.error) + return true + } + return false + } + + private fun updateRelativeTimeViews() { + updateRefreshViewState() + infoListAdapter.notifyDataSetChanged() + } + + private fun updateRefreshViewState() { + val oldestSubscriptionUpdateText = when { + oldestSubscriptionUpdate != null -> Localization.relativeTime(oldestSubscriptionUpdate!!) + else -> "—" + } + + refresh_text?.text = getString(R.string.feed_oldest_subscription_update, oldestSubscriptionUpdateText) + } + + /////////////////////////////////////////////////////////////////////////// + // Load Service Handling + /////////////////////////////////////////////////////////////////////////// + + override fun doInitialLoadLogic() {} + override fun reloadContent() = triggerUpdate() + override fun loadMoreItems() {} + override fun hasMoreItems() = false + + private fun triggerUpdate() { + getActivity()?.startService(Intent(requireContext(), FeedLoadService::class.java).apply { + putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId) + }) + listState = null + } + + override fun onError(exception: Throwable): Boolean { + if (super.onError(exception)) return true + + if (useAsFrontPage) { + showSnackBarError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0) + return true + } + + onUnrecoverableError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0) + return true + } + + companion object { + const val KEY_GROUP_ID = "ARG_GROUP_ID" + const val KEY_GROUP_NAME = "ARG_GROUP_NAME" + + @JvmStatic + fun newInstance(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, groupName: String? = null): FeedFragment { + val feedFragment = FeedFragment() + + feedFragment.arguments = Bundle().apply { + putLong(KEY_GROUP_ID, groupId) + putString(KEY_GROUP_NAME, groupName) + } + + return feedFragment + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt new file mode 100644 index 000000000..c37d6a0b3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt @@ -0,0 +1,24 @@ +package org.schabi.newpipe.local.feed + +import androidx.annotation.StringRes +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import java.util.* + +sealed class FeedState { + data class ProgressState( + val currentProgress: Int = -1, + val maxProgress: Int = -1, + @StringRes val progressMessage: Int = 0 + ) : FeedState() + + data class LoadedState( + val items: List, + val oldestUpdate: Calendar? = null, + val notLoadedCount: Long, + val itemsErrors: List = emptyList() + ) : FeedState() + + data class ErrorState( + val error: Throwable? = null + ) : FeedState() +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt new file mode 100644 index 000000000..adc262ecb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt @@ -0,0 +1,71 @@ +package org.schabi.newpipe.local.feed + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.Flowable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.functions.Function4 +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.local.feed.service.FeedEventManager +import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.* +import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT +import java.util.* +import java.util.concurrent.TimeUnit + +class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() { + class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return FeedViewModel(context.applicationContext, groupId) as T + } + } + + private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) + + private val mutableStateLiveData = MutableLiveData() + val stateLiveData: LiveData = mutableStateLiveData + + private var combineDisposable = Flowable + .combineLatest( + FeedEventManager.events(), + feedDatabaseManager.asStreamItems(groupId), + feedDatabaseManager.notLoadedCount(groupId), + feedDatabaseManager.oldestSubscriptionUpdate(groupId), + + Function4 { t1: FeedEventManager.Event, t2: List, t3: Long, t4: List -> + return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull()) + } + ) + .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + val (event, listFromDB, notLoadedCount, oldestUpdate) = it + + val oldestUpdateCalendar = + oldestUpdate?.let { Calendar.getInstance().apply { time = it } } + + mutableStateLiveData.postValue(when (event) { + is IdleEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount) + is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) + is SuccessResultEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount, event.itemsErrors) + is ErrorResultEvent -> FeedState.ErrorState(event.error) + }) + + if (event is ErrorResultEvent || event is SuccessResultEvent) { + FeedEventManager.reset() + } + } + + override fun onCleared() { + super.onCleared() + combineDisposable.dispose() + } + + private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List, val t3: Long, val t4: Date?) +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt new file mode 100644 index 000000000..e9012ff37 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt @@ -0,0 +1,38 @@ +package org.schabi.newpipe.local.feed.service + +import androidx.annotation.StringRes +import io.reactivex.Flowable +import io.reactivex.processors.BehaviorProcessor +import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent +import java.util.concurrent.atomic.AtomicBoolean + +object FeedEventManager { + private var processor: BehaviorProcessor = BehaviorProcessor.create() + private var ignoreUpstream = AtomicBoolean() + private var eventsFlowable = processor.startWith(IdleEvent) + + fun postEvent(event: Event) { + processor.onNext(event) + } + + fun events(): Flowable { + return eventsFlowable.filter { !ignoreUpstream.get() } + } + + fun reset() { + ignoreUpstream.set(true) + postEvent(IdleEvent) + ignoreUpstream.set(false) + } + + sealed class Event { + object IdleEvent : Event() + data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() { + constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage) + } + + data class SuccessResultEvent(val itemsErrors: List = emptyList()) : Event() + data class ErrorResultEvent(val error: Throwable) : Event() + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt new file mode 100644 index 000000000..294a7fcd5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt @@ -0,0 +1,464 @@ +/* + * Copyright 2019 Mauricio Colli + * FeedLoadService.kt is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.local.feed.service + +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.IBinder +import android.preference.PreferenceManager +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import io.reactivex.Flowable +import io.reactivex.Notification +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.functions.Consumer +import io.reactivex.functions.Function +import io.reactivex.processors.PublishProcessor +import io.reactivex.schedulers.Schedulers +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription +import org.schabi.newpipe.MainActivity.DEBUG +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.local.feed.FeedDatabaseManager +import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.* +import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent +import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.util.ExtractorHelper +import java.io.IOException +import java.util.* +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.collections.ArrayList + +class FeedLoadService : Service() { + companion object { + private val TAG = FeedLoadService::class.java.simpleName + private const val NOTIFICATION_ID = 7293450 + private const val ACTION_CANCEL = "org.schabi.newpipe.local.feed.service.FeedLoadService.CANCEL" + + /** + * How often the notification will be updated. + */ + private const val NOTIFICATION_SAMPLING_PERIOD = 1500 + + /** + * How many extractions will be running in parallel. + */ + private const val PARALLEL_EXTRACTIONS = 6 + + /** + * Number of items to buffer to mass-insert in the database. + */ + private const val BUFFER_COUNT_BEFORE_INSERT = 20 + + const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID" + } + + private var loadingSubscription: Subscription? = null + private lateinit var subscriptionManager: SubscriptionManager + + private lateinit var feedDatabaseManager: FeedDatabaseManager + private lateinit var feedResultsHolder: ResultsHolder + + private var disposables = CompositeDisposable() + private var notificationUpdater = PublishProcessor.create() + + /////////////////////////////////////////////////////////////////////////// + // Lifecycle + /////////////////////////////////////////////////////////////////////////// + + override fun onCreate() { + super.onCreate() + subscriptionManager = SubscriptionManager(this) + feedDatabaseManager = FeedDatabaseManager(this) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (DEBUG) { + Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "]," + + " flags = [" + flags + "], startId = [" + startId + "]") + } + + if (intent == null || loadingSubscription != null) { + return START_NOT_STICKY + } + + setupNotification() + setupBroadcastReceiver() + val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) + + val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) + val useFeedExtractor = defaultSharedPreferences + .getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) + + val thresholdOutdatedSecondsString = defaultSharedPreferences + .getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value)) + val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt() + + startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds) + + return START_NOT_STICKY + } + + private fun disposeAll() { + unregisterReceiver(broadcastReceiver) + + loadingSubscription?.cancel() + loadingSubscription = null + + disposables.dispose() + } + + private fun stopService() { + disposeAll() + stopForeground(true) + notificationManager.cancel(NOTIFICATION_ID) + stopSelf() + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + /////////////////////////////////////////////////////////////////////////// + // Loading & Handling + /////////////////////////////////////////////////////////////////////////// + + private class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) { + companion object { + fun wrapList(subscriptionId: Long, info: ListInfo): List { + val toReturn = ArrayList(info.errors.size) + for (error in info.errors) { + toReturn.add(RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, error)) + } + return toReturn + } + } + } + + private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) { + feedResultsHolder = ResultsHolder() + + val outdatedThreshold = Calendar.getInstance().apply { + add(Calendar.SECOND, -thresholdOutdatedSeconds) + }.time + + val subscriptions = when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold) + else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold) + } + + subscriptions + .limit(1) + + .doOnNext { + currentProgress.set(0) + maxProgress.set(it.size) + } + .filter { it.isNotEmpty() } + + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { + startForeground(NOTIFICATION_ID, notificationBuilder.build()) + updateNotificationProgress(null) + broadcastProgress() + } + + .observeOn(Schedulers.io()) + .flatMap { Flowable.fromIterable(it) } + .takeWhile { !cancelSignal.get() } + + .parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2) + .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2) + .filter { !cancelSignal.get() } + + .map { subscriptionEntity -> + try { + val listInfo = if (useFeedExtractor) { + ExtractorHelper + .getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url) + .blockingGet() + } else { + ExtractorHelper + .getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true) + .blockingGet() + } as ListInfo + + return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo)) + } catch (e: Throwable) { + val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" + val wrapper = RequestException(subscriptionEntity.uid, request, e) + return@map Notification.createOnError>>(wrapper) + } + } + .sequential() + + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext(errorHandlingConsumer) + + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext(notificationsConsumer) + + .observeOn(Schedulers.io()) + .buffer(BUFFER_COUNT_BEFORE_INSERT) + .doOnNext(databaseConsumer) + + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(resultSubscriber) + } + + private fun broadcastProgress() { + postEvent(ProgressEvent(currentProgress.get(), maxProgress.get())) + } + + private val resultSubscriber + get() = object : Subscriber>>>> { + + override fun onSubscribe(s: Subscription) { + loadingSubscription = s + s.request(java.lang.Long.MAX_VALUE) + } + + override fun onNext(notification: List>>>) { + if (DEBUG) Log.v(TAG, "onNext() → $notification") + } + + override fun onError(error: Throwable) { + handleError(error) + } + + override fun onComplete() { + if (maxProgress.get() == 0) { + postEvent(IdleEvent) + stopService() + + return + } + + currentProgress.set(-1) + maxProgress.set(-1) + + notificationUpdater.onNext(getString(R.string.feed_processing_message)) + postEvent(ProgressEvent(R.string.feed_processing_message)) + + disposables.add(Single + .fromCallable { + feedResultsHolder.ready() + + postEvent(ProgressEvent(R.string.feed_processing_message)) + feedDatabaseManager.removeOrphansOrOlderStreams() + + postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors)) + true + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { _, throwable -> + if (throwable != null) { + Log.e(TAG, "Error while storing result", throwable) + handleError(throwable) + return@subscribe + } + stopService() + }) + } + } + + private val databaseConsumer: Consumer>>>> + get() = Consumer { + feedDatabaseManager.database().runInTransaction { + for (notification in it) { + + if (notification.isOnNext) { + val subscriptionId = notification.value!!.first + val info = notification.value!!.second + + feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) + subscriptionManager.updateFromInfo(subscriptionId, info) + + if (info.errors.isNotEmpty()) { + feedResultsHolder.addErrors(RequestException.wrapList(subscriptionId, info)) + feedDatabaseManager.markAsOutdated(subscriptionId) + } + + } else if (notification.isOnError) { + val error = notification.error!! + feedResultsHolder.addError(error) + + if (error is RequestException) { + feedDatabaseManager.markAsOutdated(error.subscriptionId) + } + } + } + } + } + + + private val errorHandlingConsumer: Consumer>>> + get() = Consumer { + if (it.isOnError) { + var error = it.error!! + if (error is RequestException) error = error.cause!! + val cause = error.cause + + when { + error is IOException -> throw error + cause is IOException -> throw cause + + error is ReCaptchaException -> throw error + cause is ReCaptchaException -> throw cause + } + } + } + + private val notificationsConsumer: Consumer>>> + get() = Consumer { onItemCompleted(it.value?.second?.name) } + + private fun onItemCompleted(updateDescription: String?) { + currentProgress.incrementAndGet() + notificationUpdater.onNext(updateDescription ?: "") + + broadcastProgress() + } + + /////////////////////////////////////////////////////////////////////////// + // Notification + /////////////////////////////////////////////////////////////////////////// + + private lateinit var notificationManager: NotificationManagerCompat + private lateinit var notificationBuilder: NotificationCompat.Builder + + private var currentProgress = AtomicInteger(-1) + private var maxProgress = AtomicInteger(-1) + + private fun createNotification(): NotificationCompat.Builder { + val cancelActionIntent = PendingIntent.getBroadcast(this, + NOTIFICATION_ID, Intent(ACTION_CANCEL), 0) + + return NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setOngoing(true) + .setProgress(-1, -1, true) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .addAction(0, getString(R.string.cancel), cancelActionIntent) + .setContentTitle(getString(R.string.feed_notification_loading)) + } + + private fun setupNotification() { + notificationManager = NotificationManagerCompat.from(this) + notificationBuilder = createNotification() + + val throttleAfterFirstEmission = Function { flow: Flowable -> + flow.limit(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS)) + } + + disposables.add(notificationUpdater + .publish(throttleAfterFirstEmission) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::updateNotificationProgress)) + } + + private fun updateNotificationProgress(updateDescription: String?) { + notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1) + + if (maxProgress.get() == -1) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null) + if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription) + notificationBuilder.setContentText(updateDescription) + } else { + val progressText = this.currentProgress.toString() + "/" + maxProgress + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)") + } else { + notificationBuilder.setContentInfo(progressText) + if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription) + } + } + + notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()) + } + + /////////////////////////////////////////////////////////////////////////// + // Notification Actions + /////////////////////////////////////////////////////////////////////////// + + private lateinit var broadcastReceiver: BroadcastReceiver + private val cancelSignal = AtomicBoolean() + + private fun setupBroadcastReceiver() { + broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == ACTION_CANCEL) { + cancelSignal.set(true) + } + } + } + registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL)) + } + + /////////////////////////////////////////////////////////////////////////// + // Error handling + /////////////////////////////////////////////////////////////////////////// + + private fun handleError(error: Throwable) { + postEvent(ErrorResultEvent(error)) + stopService() + } + + /////////////////////////////////////////////////////////////////////////// + // Results Holder + /////////////////////////////////////////////////////////////////////////// + + class ResultsHolder { + /** + * List of errors that may have happen during loading. + */ + internal lateinit var itemsErrors: List + + private val itemsErrorsHolder: MutableList = ArrayList() + + fun addError(error: Throwable) { + itemsErrorsHolder.add(error) + } + + fun addErrors(errors: List) { + itemsErrorsHolder.addAll(errors) + } + + fun ready() { + itemsErrors = itemsErrorsHolder.toList() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java index d84fe0195..d208f92b3 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java @@ -269,11 +269,11 @@ public class HistoryRecordManager { for (LocalItem item : items) { long streamId; if (item instanceof StreamStatisticsEntry) { - streamId = ((StreamStatisticsEntry) item).streamId; + streamId = ((StreamStatisticsEntry) item).getStreamId(); } else if (item instanceof PlaylistStreamEntity) { streamId = ((PlaylistStreamEntity) item).getStreamUid(); } else if (item instanceof PlaylistStreamEntry) { - streamId = ((PlaylistStreamEntry) item).streamId; + streamId = ((PlaylistStreamEntry) item).getStreamId(); } else { result.add(null); continue; diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index 31ae70954..a54c2a9a4 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java @@ -76,11 +76,11 @@ public class StatisticsPlaylistFragment switch (sortMode) { case LAST_PLAYED: Collections.sort(results, (left, right) -> - right.latestAccessDate.compareTo(left.latestAccessDate)); + right.getLatestAccessDate().compareTo(left.getLatestAccessDate())); return results; case MOST_PLAYED: Collections.sort(results, (left, right) -> - Long.compare(right.watchCount, left.watchCount)); + Long.compare(right.getWatchCount(), left.getWatchCount())); return results; default: return null; } @@ -153,9 +153,9 @@ public class StatisticsPlaylistFragment if (selectedItem instanceof StreamStatisticsEntry) { final StreamStatisticsEntry item = (StreamStatisticsEntry) selectedItem; NavigationHelper.openVideoDetailFragment(getFM(), - item.serviceId, - item.url, - item.title); + item.getStreamEntity().getServiceId(), + item.getStreamEntity().getUrl(), + item.getStreamEntity().getTitle()); } } @@ -402,7 +402,7 @@ public class StatisticsPlaylistFragment .get(index); if(infoItem instanceof StreamStatisticsEntry) { final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem; - final Disposable onDelete = recordManager.deleteStreamHistory(entry.streamId) + final Disposable onDelete = recordManager.deleteStreamHistory(entry.getStreamId()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( howManyDeleted -> { diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java index 30cc6de32..7eef3e67e 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java @@ -52,12 +52,12 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { if (!(localItem instanceof PlaylistStreamEntry)) return; final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; - itemVideoTitleView.setText(item.title); - itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.uploader, - NewPipe.getNameOfService(item.serviceId))); + itemVideoTitleView.setText(item.getStreamEntity().getTitle()); + itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.getStreamEntity().getUploader(), + NewPipe.getNameOfService(item.getStreamEntity().getServiceId()))); - if (item.duration > 0) { - itemDurationView.setText(Localization.getDurationString(item.duration)); + if (item.getStreamEntity().getDuration() > 0) { + itemDurationView.setText(Localization.getDurationString(item.getStreamEntity().getDuration())); itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); @@ -65,7 +65,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList() {{ add(localItem); }}).blockingGet().get(0); if (state != null) { itemProgressView.setVisibility(View.VISIBLE); - itemProgressView.setMax((int) item.duration); + itemProgressView.setMax((int) item.getStreamEntity().getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); } else { itemProgressView.setVisibility(View.GONE); @@ -75,7 +75,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { } // Default thumbnail is shown on error, while loading and if the url is empty - itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView, + itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView, ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); itemView.setOnClickListener(view -> { @@ -102,8 +102,8 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList() {{ add(localItem); }}).blockingGet().get(0); - if (state != null && item.duration > 0) { - itemProgressView.setMax((int) item.duration); + if (state != null && item.getStreamEntity().getDuration() > 0) { + itemProgressView.setMax((int) item.getStreamEntity().getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); } else { diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java index 75fbf13ea..77f947031 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java @@ -71,9 +71,9 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { private String getStreamInfoDetailLine(final StreamStatisticsEntry entry, final DateFormat dateFormat) { final String watchCount = Localization.shortViewCount(itemBuilder.getContext(), - entry.watchCount); - final String uploadDate = dateFormat.format(entry.latestAccessDate); - final String serviceName = NewPipe.getNameOfService(entry.serviceId); + entry.getWatchCount()); + final String uploadDate = dateFormat.format(entry.getLatestAccessDate()); + final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId()); return Localization.concatenateStrings(watchCount, uploadDate, serviceName); } @@ -82,11 +82,11 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { if (!(localItem instanceof StreamStatisticsEntry)) return; final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; - itemVideoTitleView.setText(item.title); - itemUploaderView.setText(item.uploader); + itemVideoTitleView.setText(item.getStreamEntity().getTitle()); + itemUploaderView.setText(item.getStreamEntity().getUploader()); - if (item.duration > 0) { - itemDurationView.setText(Localization.getDurationString(item.duration)); + if (item.getStreamEntity().getDuration() > 0) { + itemDurationView.setText(Localization.getDurationString(item.getStreamEntity().getDuration())); itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); @@ -94,7 +94,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList() {{ add(localItem); }}).blockingGet().get(0); if (state != null) { itemProgressView.setVisibility(View.VISIBLE); - itemProgressView.setMax((int) item.duration); + itemProgressView.setMax((int) item.getStreamEntity().getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); } else { itemProgressView.setVisibility(View.GONE); @@ -109,7 +109,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { } // Default thumbnail is shown on error, while loading and if the url is empty - itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView, + itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView, ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); itemView.setOnClickListener(view -> { @@ -133,8 +133,8 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList() {{ add(localItem); }}).blockingGet().get(0); - if (state != null && item.duration > 0) { - itemProgressView.setMax((int) item.duration); + if (state != null && item.getStreamEntity().getDuration() > 0) { + itemProgressView.setMax((int) item.getStreamEntity().getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); } else { diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index 17599a1ca..dd9958486 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java @@ -168,7 +168,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment streamIds = new ArrayList<>(items.size()); for (final LocalItem item : items) { if (item instanceof PlaylistStreamEntry) { - streamIds.add(((PlaylistStreamEntry) item).streamId); + streamIds.add(((PlaylistStreamEntry) item).getStreamId()); } } @@ -579,7 +579,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment NavigationHelper.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true)); StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction( - (fragment, infoItemDuplicate) -> changeThumbnailUrl(item.thumbnailUrl)); + (fragment, infoItemDuplicate) -> changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl())); StreamDialogEntry.delete.setCustomAction( (fragment, infoItemDuplicate) -> deleteItem(item)); diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt new file mode 100644 index 000000000..9ff08c32c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt @@ -0,0 +1,63 @@ +package org.schabi.newpipe.local.subscription + +import android.content.Context +import androidx.annotation.AttrRes +import androidx.annotation.DrawableRes +import org.schabi.newpipe.R +import org.schabi.newpipe.util.ThemeHelper + +enum class FeedGroupIcon( + /** + * The id that will be used to store and retrieve icons from some persistent storage (e.g. DB). + */ + val id: Int, + + /** + * The attribute that points to a drawable resource. "R.attr" is used here to support multiple themes. + */ + @AttrRes val drawableResourceAttr: Int +) { + ALL(0, R.attr.ic_asterisk), + MUSIC(1, R.attr.ic_music_note), + EDUCATION(2, R.attr.ic_school), + FITNESS(3, R.attr.ic_fitness), + SPACE(4, R.attr.ic_telescope), + COMPUTER(5, R.attr.ic_computer), + GAMING(6, R.attr.ic_videogame), + SPORTS(7, R.attr.ic_sports), + NEWS(8, R.attr.ic_megaphone), + FAVORITES(9, R.attr.ic_heart), + CAR(10, R.attr.ic_car), + MOTORCYCLE(11, R.attr.ic_motorcycle), + TREND(12, R.attr.ic_trending_up), + MOVIE(13, R.attr.ic_movie), + BACKUP(14, R.attr.ic_backup), + ART(15, R.attr.palette), + PERSON(16, R.attr.ic_person), + PEOPLE(17, R.attr.ic_people), + MONEY(18, R.attr.ic_money), + KIDS(19, R.attr.ic_kids), + FOOD(20, R.attr.ic_fastfood), + SMILE(21, R.attr.ic_smile), + EXPLORE(22, R.attr.ic_explore), + RESTAURANT(23, R.attr.ic_restaurant), + MIC(24, R.attr.ic_mic), + HEADSET(25, R.attr.audio), + RADIO(26, R.attr.ic_radio), + SHOPPING_CART(27, R.attr.ic_shopping_cart), + WATCH_LATER(28, R.attr.ic_watch_later), + WORK(29, R.attr.ic_work), + HOT(30, R.attr.ic_hot), + CHANNEL(31, R.attr.ic_channel), + BOOKMARK(32, R.attr.ic_bookmark), + PETS(33, R.attr.ic_pets), + WORLD(34, R.attr.ic_world), + STAR(35, R.attr.ic_stars), + SUN(36, R.attr.ic_sunny), + RSS(37, R.attr.rss); + + @DrawableRes + fun getDrawableRes(context: Context): Int { + return ThemeHelper.resolveResourceIdFromAttr(context, drawableResourceAttr) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java deleted file mode 100644 index bff6c1b3a..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java +++ /dev/null @@ -1,595 +0,0 @@ -package org.schabi.newpipe.local.subscription; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.AlertDialog; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.os.Bundle; -import android.os.Environment; -import android.os.Parcelable; -import android.preference.PreferenceManager; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentManager; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import androidx.appcompat.app.ActionBar; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; - -import com.nononsenseapps.filepicker.Utils; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.channel.ChannelInfoItem; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; -import org.schabi.newpipe.fragments.BaseStateFragment; -import org.schabi.newpipe.info_list.InfoListAdapter; -import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService; -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService; -import org.schabi.newpipe.report.UserAction; -import org.schabi.newpipe.util.FilePickerActivityHelper; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.OnClickGesture; -import org.schabi.newpipe.util.ServiceHelper; -import org.schabi.newpipe.util.ShareUtils; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.views.CollapsibleView; - -import java.io.File; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Locale; - -import icepick.State; -import io.reactivex.Observer; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.disposables.Disposable; -import io.reactivex.schedulers.Schedulers; - -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE; -import static org.schabi.newpipe.util.AnimationUtils.animateRotation; -import static org.schabi.newpipe.util.AnimationUtils.animateView; - -public class SubscriptionFragment extends BaseStateFragment> implements SharedPreferences.OnSharedPreferenceChangeListener { - private static final int REQUEST_EXPORT_CODE = 666; - private static final int REQUEST_IMPORT_CODE = 667; - - private RecyclerView itemsList; - @State - protected Parcelable itemsListState; - private InfoListAdapter infoListAdapter; - private int updateFlags = 0; - - private static final int LIST_MODE_UPDATE_FLAG = 0x32; - - private View whatsNewItemListHeader; - private View importExportListHeader; - - @State - protected Parcelable importExportOptionsState; - private CollapsibleView importExportOptions; - - private CompositeDisposable disposables = new CompositeDisposable(); - private SubscriptionService subscriptionService; - - /////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - /////////////////////////////////////////////////////////////////////////// - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - PreferenceManager.getDefaultSharedPreferences(activity) - .registerOnSharedPreferenceChangeListener(this); - } - - @Override - public void setUserVisibleHint(boolean isVisibleToUser) { - super.setUserVisibleHint(isVisibleToUser); - if (activity != null && isVisibleToUser) { - setTitle(activity.getString(R.string.tab_subscriptions)); - } - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - infoListAdapter = new InfoListAdapter(activity); - subscriptionService = SubscriptionService.getInstance(activity); - } - - @Override - public void onDetach() { - super.onDetach(); - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_subscription, container, false); - } - - @Override - public void onResume() { - super.onResume(); - setupBroadcastReceiver(); - if (updateFlags != 0) { - if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { - final boolean useGrid = isGridLayout(); - itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); - infoListAdapter.setGridItemVariants(useGrid); - infoListAdapter.notifyDataSetChanged(); - } - updateFlags = 0; - } - } - - @Override - public void onPause() { - super.onPause(); - itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); - importExportOptionsState = importExportOptions.onSaveInstanceState(); - - if (subscriptionBroadcastReceiver != null && activity != null) { - LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver); - } - } - - @Override - public void onDestroyView() { - if (disposables != null) disposables.clear(); - - super.onDestroyView(); - } - - @Override - public void onDestroy() { - if (disposables != null) disposables.dispose(); - disposables = null; - subscriptionService = null; - - PreferenceManager.getDefaultSharedPreferences(activity) - .unregisterOnSharedPreferenceChangeListener(this); - super.onDestroy(); - } - - protected RecyclerView.LayoutManager getListLayoutManager() { - return new LinearLayoutManager(activity); - } - - protected RecyclerView.LayoutManager getGridLayoutManager() { - final Resources resources = activity.getResources(); - int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width); - width += (24 * resources.getDisplayMetrics().density); - final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels / (double)width); - final GridLayoutManager lm = new GridLayoutManager(activity, spanCount); - lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount)); - return lm; - } - - /*///////////////////////////////////////////////////////////////////////// - // Menu - /////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - - ActionBar supportActionBar = activity.getSupportActionBar(); - if (supportActionBar != null) { - supportActionBar.setDisplayShowTitleEnabled(true); - setTitle(getString(R.string.tab_subscriptions)); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Subscriptions import/export - //////////////////////////////////////////////////////////////////////////*/ - - private BroadcastReceiver subscriptionBroadcastReceiver; - - private void setupBroadcastReceiver() { - if (activity == null) return; - - if (subscriptionBroadcastReceiver != null) { - LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver); - } - - final IntentFilter filters = new IntentFilter(); - filters.addAction(SubscriptionsExportService.EXPORT_COMPLETE_ACTION); - filters.addAction(SubscriptionsImportService.IMPORT_COMPLETE_ACTION); - subscriptionBroadcastReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (importExportOptions != null) importExportOptions.collapse(); - } - }; - - LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver, filters); - } - - private View addItemView(final String title, @DrawableRes final int icon, ViewGroup container) { - final View itemRoot = View.inflate(getContext(), R.layout.subscription_import_export_item, null); - final TextView titleView = itemRoot.findViewById(android.R.id.text1); - final ImageView iconView = itemRoot.findViewById(android.R.id.icon1); - - titleView.setText(title); - iconView.setImageResource(icon); - - container.addView(itemRoot); - return itemRoot; - } - - private void setupImportFromItems(final ViewGroup listHolder) { - final View previousBackupItem = addItemView(getString(R.string.previous_export), - ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_backup), listHolder); - previousBackupItem.setOnClickListener(item -> onImportPreviousSelected()); - - final int iconColor = ThemeHelper.isLightThemeSelected(getContext()) ? Color.BLACK : Color.WHITE; - final String[] services = getResources().getStringArray(R.array.service_list); - for (String serviceName : services) { - try { - final StreamingService service = NewPipe.getService(serviceName); - - final SubscriptionExtractor subscriptionExtractor = service.getSubscriptionExtractor(); - if (subscriptionExtractor == null) continue; - - final List supportedSources = subscriptionExtractor.getSupportedSources(); - if (supportedSources.isEmpty()) continue; - - final View itemView = addItemView(serviceName, ServiceHelper.getIcon(service.getServiceId()), listHolder); - final ImageView iconView = itemView.findViewById(android.R.id.icon1); - iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN); - - itemView.setOnClickListener(selectedItem -> onImportFromServiceSelected(service.getServiceId())); - } catch (ExtractionException e) { - throw new RuntimeException("Services array contains an entry that it's not a valid service name (" + serviceName + ")", e); - } - } - } - - private void setupExportToItems(final ViewGroup listHolder) { - final View previousBackupItem = addItemView(getString(R.string.file), ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_save), listHolder); - previousBackupItem.setOnClickListener(item -> onExportSelected()); - } - - private void onImportFromServiceSelected(int serviceId) { - FragmentManager fragmentManager = getFM(); - NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId); - } - - private void onImportPreviousSelected() { - startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE); - } - - private void onExportSelected() { - final String date = new SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(new Date()); - final String exportName = "newpipe_subscriptions_" + date + ".json"; - final File exportFile = new File(Environment.getExternalStorageDirectory(), exportName); - - startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.getAbsolutePath()), REQUEST_EXPORT_CODE); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (data != null && data.getData() != null && resultCode == Activity.RESULT_OK) { - if (requestCode == REQUEST_EXPORT_CODE) { - final File exportFile = Utils.getFileForUri(data.getData()); - if (!exportFile.getParentFile().canWrite() || !exportFile.getParentFile().canRead()) { - Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show(); - } else { - activity.startService(new Intent(activity, SubscriptionsExportService.class) - .putExtra(SubscriptionsExportService.KEY_FILE_PATH, exportFile.getAbsolutePath())); - } - } else if (requestCode == REQUEST_IMPORT_CODE) { - final String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); - ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) - .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) - .putExtra(KEY_VALUE, path)); - } - } - } - /*///////////////////////////////////////////////////////////////////////// - // Fragment Views - /////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void initViews(View rootView, Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - final boolean useGrid = isGridLayout(); - infoListAdapter = new InfoListAdapter(getActivity()); - itemsList = rootView.findViewById(R.id.items_list); - itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); - - View headerRootLayout; - infoListAdapter.setHeader(headerRootLayout = activity.getLayoutInflater().inflate(R.layout.subscription_header, itemsList, false)); - whatsNewItemListHeader = headerRootLayout.findViewById(R.id.whats_new); - importExportListHeader = headerRootLayout.findViewById(R.id.import_export); - importExportOptions = headerRootLayout.findViewById(R.id.import_export_options); - - infoListAdapter.useMiniItemVariants(true); - infoListAdapter.setGridItemVariants(useGrid); - itemsList.setAdapter(infoListAdapter); - - setupImportFromItems(headerRootLayout.findViewById(R.id.import_from_options)); - setupExportToItems(headerRootLayout.findViewById(R.id.export_to_options)); - - if (importExportOptionsState != null) { - importExportOptions.onRestoreInstanceState(importExportOptionsState); - importExportOptionsState = null; - } - - importExportOptions.addListener(getExpandIconSyncListener(headerRootLayout.findViewById(R.id.import_export_expand_icon))); - importExportOptions.ready(); - } - - private CollapsibleView.StateListener getExpandIconSyncListener(final ImageView iconView) { - return newState -> animateRotation(iconView, 250, newState == CollapsibleView.COLLAPSED ? 0 : 180); - } - - @Override - protected void initListeners() { - super.initListeners(); - - infoListAdapter.setOnChannelSelectedListener(new OnClickGesture() { - - public void selected(ChannelInfoItem selectedItem) { - final FragmentManager fragmentManager = getFM(); - NavigationHelper.openChannelFragment(fragmentManager, - selectedItem.getServiceId(), - selectedItem.getUrl(), - selectedItem.getName()); - } - - public void held(ChannelInfoItem selectedItem) { - showLongTapDialog(selectedItem); - } - - }); - - whatsNewItemListHeader.setOnClickListener(v -> { - FragmentManager fragmentManager = getFM(); - NavigationHelper.openWhatsNewFragment(fragmentManager); - }); - importExportListHeader.setOnClickListener(v -> importExportOptions.switchState()); - } - - private void showLongTapDialog(ChannelInfoItem selectedItem) { - final Context context = getContext(); - final Activity activity = getActivity(); - if (context == null || context.getResources() == null || getActivity() == null) return; - - final String[] commands = new String[]{ - context.getResources().getString(R.string.unsubscribe), - context.getResources().getString(R.string.share) - }; - - final DialogInterface.OnClickListener actions = (dialogInterface, i) -> { - switch (i) { - case 0: - deleteChannel(selectedItem); - break; - case 1: - shareChannel(selectedItem); - break; - default: - break; - } - }; - - final View bannerView = View.inflate(activity, R.layout.dialog_title, null); - bannerView.setSelected(true); - - TextView titleView = bannerView.findViewById(R.id.itemTitleView); - titleView.setText(selectedItem.getName()); - - TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails); - detailsView.setVisibility(View.GONE); - - new AlertDialog.Builder(activity) - .setCustomTitle(bannerView) - .setItems(commands, actions) - .create() - .show(); - - } - - private void shareChannel(ChannelInfoItem selectedItem) { - ShareUtils.shareUrl(getContext(), selectedItem.getName(), selectedItem.getUrl()); - } - - @SuppressLint("CheckResult") - private void deleteChannel(ChannelInfoItem selectedItem) { - subscriptionService.subscriptionTable() - .getSubscription(selectedItem.getServiceId(), selectedItem.getUrl()) - .toObservable() - .observeOn(Schedulers.io()) - .subscribe(getDeleteObserver()); - - Toast.makeText(activity, getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show(); - } - - - - private Observer> getDeleteObserver() { - return new Observer>() { - @Override - public void onSubscribe(Disposable d) { - disposables.add(d); - } - - @Override - public void onNext(List subscriptionEntities) { - subscriptionService.subscriptionTable().delete(subscriptionEntities); - } - - @Override - public void onError(Throwable exception) { - SubscriptionFragment.this.onError(exception); - } - - @Override - public void onComplete() { } - }; - } - - private void resetFragment() { - if (disposables != null) disposables.clear(); - if (infoListAdapter != null) infoListAdapter.clearStreamItemList(); - } - - /////////////////////////////////////////////////////////////////////////// - // Subscriptions Loader - /////////////////////////////////////////////////////////////////////////// - - @Override - public void startLoading(boolean forceLoad) { - super.startLoading(forceLoad); - resetFragment(); - - subscriptionService.getSubscription().toObservable() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getSubscriptionObserver()); - } - - private Observer> getSubscriptionObserver() { - return new Observer>() { - @Override - public void onSubscribe(Disposable d) { - showLoading(); - disposables.add(d); - } - - @Override - public void onNext(List subscriptions) { - handleResult(subscriptions); - } - - @Override - public void onError(Throwable exception) { - SubscriptionFragment.this.onError(exception); - } - - @Override - public void onComplete() { - } - }; - } - - @Override - public void handleResult(@NonNull List result) { - super.handleResult(result); - - infoListAdapter.clearStreamItemList(); - - if (result.isEmpty()) { - whatsNewItemListHeader.setVisibility(View.GONE); - showEmptyState(); - } else { - infoListAdapter.addInfoItemList(getSubscriptionItems(result)); - if (itemsListState != null) { - itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); - itemsListState = null; - } - whatsNewItemListHeader.setVisibility(View.VISIBLE); - hideLoading(); - } - } - - - private List getSubscriptionItems(List subscriptions) { - List items = new ArrayList<>(); - for (final SubscriptionEntity subscription : subscriptions) { - items.add(subscription.toChannelInfoItem()); - } - - Collections.sort(items, - (InfoItem o1, InfoItem o2) -> - o1.getName().compareToIgnoreCase(o2.getName())); - return items; - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - super.showLoading(); - animateView(itemsList, false, 100); - } - - @Override - public void hideLoading() { - super.hideLoading(); - animateView(itemsList, true, 200); - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment Error Handling - /////////////////////////////////////////////////////////////////////////// - - @Override - protected boolean onError(Throwable exception) { - resetFragment(); - if (super.onError(exception)) return true; - - onUnrecoverableError(exception, - UserAction.SOMETHING_ELSE, - "none", - "Subscriptions", - R.string.general_error); - return true; - } - - @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - if (key.equals(getString(R.string.list_view_mode_key))) { - updateFlags |= LIST_MODE_UPDATE_FLAG; - } - } - - protected boolean isGridLayout() { - final String list_mode = PreferenceManager.getDefaultSharedPreferences(activity).getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value)); - if ("auto".equals(list_mode)) { - final Configuration configuration = getResources().getConfiguration(); - return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); - } else { - return "grid".equals(list_mode); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt new file mode 100644 index 000000000..98e20a02f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -0,0 +1,421 @@ +package org.schabi.newpipe.local.subscription + +import android.app.Activity +import android.app.AlertDialog +import android.content.* +import android.content.res.Configuration +import android.os.Bundle +import android.os.Environment +import android.os.Parcelable +import android.preference.PreferenceManager +import android.view.* +import android.widget.Toast +import androidx.lifecycle.ViewModelProviders +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.recyclerview.widget.GridLayoutManager +import com.nononsenseapps.filepicker.Utils +import com.xwray.groupie.Group +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.Item +import com.xwray.groupie.Section +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import icepick.State +import io.reactivex.disposables.CompositeDisposable +import kotlinx.android.synthetic.main.dialog_title.view.* +import kotlinx.android.synthetic.main.fragment_subscription.* +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipe.fragments.BaseStateFragment +import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionState +import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog +import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog +import org.schabi.newpipe.local.subscription.item.* +import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService +import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION +import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.KEY_FILE_PATH +import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService +import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.* +import org.schabi.newpipe.report.UserAction +import org.schabi.newpipe.util.* +import org.schabi.newpipe.util.AnimationUtils.animateView +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.floor +import kotlin.math.max + +class SubscriptionFragment : BaseStateFragment() { + private lateinit var viewModel: SubscriptionViewModel + private lateinit var subscriptionManager: SubscriptionManager + private val disposables: CompositeDisposable = CompositeDisposable() + + private var subscriptionBroadcastReceiver: BroadcastReceiver? = null + + private val groupAdapter = GroupAdapter() + private val feedGroupsSection = Section() + private var feedGroupsCarousel: FeedGroupCarouselItem? = null + private lateinit var importExportItem: FeedImportExportItem + private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem + private val subscriptionsSection = Section() + + @State @JvmField var itemsListState: Parcelable? = null + @State @JvmField var feedGroupsListState: Parcelable? = null + @State @JvmField var importExportItemExpandedState: Boolean? = null + + init { + setHasOptionsMenu(true) + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle + /////////////////////////////////////////////////////////////////////////// + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setupInitialLayout() + } + + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + if (activity != null && isVisibleToUser) { + setTitle(activity.getString(R.string.tab_subscriptions)) + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + subscriptionManager = SubscriptionManager(requireContext()) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_subscription, container, false) + } + + override fun onResume() { + super.onResume() + setupBroadcastReceiver() + } + + override fun onPause() { + super.onPause() + itemsListState = items_list.layoutManager?.onSaveInstanceState() + feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState() + importExportItemExpandedState = importExportItem.isExpanded + + if (subscriptionBroadcastReceiver != null && activity != null) { + LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!) + } + } + + override fun onDestroy() { + super.onDestroy() + disposables.dispose() + } + + ////////////////////////////////////////////////////////////////////////// + // Menu + ////////////////////////////////////////////////////////////////////////// + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + + val supportActionBar = activity.supportActionBar + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(true) + setTitle(getString(R.string.tab_subscriptions)) + } + } + + private fun setupBroadcastReceiver() { + if (activity == null) return + + if (subscriptionBroadcastReceiver != null) { + LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!) + } + + val filters = IntentFilter() + filters.addAction(EXPORT_COMPLETE_ACTION) + filters.addAction(IMPORT_COMPLETE_ACTION) + subscriptionBroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + items_list?.post { + importExportItem.isExpanded = false + importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS) + } + + } + } + + LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters) + } + + private fun onImportFromServiceSelected(serviceId: Int) { + val fragmentManager = fm + NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId) + } + + private fun onImportPreviousSelected() { + startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE) + } + + private fun onExportSelected() { + val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date()) + val exportName = "newpipe_subscriptions_$date.json" + val exportFile = File(Environment.getExternalStorageDirectory(), exportName) + + startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.absolutePath), REQUEST_EXPORT_CODE) + } + + private fun openReorderDialog() { + FeedGroupReorderDialog().show(requireFragmentManager(), null) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (data != null && data.data != null && resultCode == Activity.RESULT_OK) { + if (requestCode == REQUEST_EXPORT_CODE) { + val exportFile = Utils.getFileForUri(data.data!!) + if (!exportFile.parentFile.canWrite() || !exportFile.parentFile.canRead()) { + Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show() + } else { + activity.startService(Intent(activity, SubscriptionsExportService::class.java) + .putExtra(KEY_FILE_PATH, exportFile.absolutePath)) + } + } else if (requestCode == REQUEST_IMPORT_CODE) { + val path = Utils.getFileForUri(data.data!!).absolutePath + ImportConfirmationDialog.show(this, Intent(activity, SubscriptionsImportService::class.java) + .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) + .putExtra(KEY_VALUE, path)) + } + } + } + + ////////////////////////////////////////////////////////////////////////// + // Fragment Views + ////////////////////////////////////////////////////////////////////////// + + private fun setupInitialLayout() { + Section().apply { + val carouselAdapter = GroupAdapter() + + carouselAdapter.add(FeedGroupCardItem(-1, getString(R.string.all), FeedGroupIcon.RSS)) + carouselAdapter.add(feedGroupsSection) + carouselAdapter.add(FeedGroupAddItem()) + + carouselAdapter.setOnItemClickListener { item, _ -> + listenerFeedGroups.selected(item) + } + carouselAdapter.setOnItemLongClickListener { item, _ -> + if (item is FeedGroupCardItem) { + if (item.groupId == FeedGroupEntity.GROUP_ALL_ID) { + return@setOnItemLongClickListener false + } + } + listenerFeedGroups.held(item) + return@setOnItemLongClickListener true + } + + feedGroupsCarousel = FeedGroupCarouselItem(requireContext(), carouselAdapter) + feedGroupsSortMenuItem = HeaderWithMenuItem( + getString(R.string.feed_groups_header_title), + ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_sort), + menuItemOnClickListener = ::openReorderDialog + ) + add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel))) + + groupAdapter.add(this) + } + + subscriptionsSection.setPlaceholder(EmptyPlaceholderItem()) + subscriptionsSection.setHideWhenEmpty(true) + + importExportItem = FeedImportExportItem( + { onImportPreviousSelected() }, + { onImportFromServiceSelected(it) }, + { onExportSelected() }, + importExportItemExpandedState ?: false) + groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection))) + + } + + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + + val shouldUseGridLayout = shouldUseGridLayout() + groupAdapter.spanCount = if (shouldUseGridLayout) getGridSpanCount() else 1 + items_list.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { + spanSizeLookup = groupAdapter.spanSizeLookup + } + items_list.adapter = groupAdapter + + viewModel = ViewModelProviders.of(this).get(SubscriptionViewModel::class.java) + viewModel.stateLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleResult) }) + viewModel.feedGroupsLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleFeedGroups) }) + } + + private fun showLongTapDialog(selectedItem: ChannelInfoItem) { + val commands = arrayOf( + getString(R.string.share), + getString(R.string.unsubscribe) + ) + + val actions = DialogInterface.OnClickListener { _, i -> + when (i) { + 0 -> ShareUtils.shareUrl(requireContext(), selectedItem.name, selectedItem.url) + 1 -> deleteChannel(selectedItem) + } + } + + val bannerView = View.inflate(requireContext(), R.layout.dialog_title, null) + bannerView.isSelected = true + bannerView.itemTitleView.text = selectedItem.name + bannerView.itemAdditionalDetails.visibility = View.GONE + + AlertDialog.Builder(requireContext()) + .setCustomTitle(bannerView) + .setItems(commands, actions) + .create() + .show() + } + + private fun deleteChannel(selectedItem: ChannelInfoItem) { + disposables.add(subscriptionManager.deleteSubscription(selectedItem.serviceId, selectedItem.url).subscribe { + Toast.makeText(requireContext(), getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show() + }) + } + + override fun doInitialLoadLogic() = Unit + override fun startLoading(forceLoad: Boolean) = Unit + + private val listenerFeedGroups = object : OnClickGesture>() { + override fun selected(selectedItem: Item<*>?) { + when (selectedItem) { + is FeedGroupCardItem -> NavigationHelper.openFeedFragment(fm, selectedItem.groupId, selectedItem.name) + is FeedGroupAddItem -> FeedGroupDialog.newInstance().show(fm, null) + } + } + + override fun held(selectedItem: Item<*>?) { + when (selectedItem) { + is FeedGroupCardItem -> FeedGroupDialog.newInstance(selectedItem.groupId).show(fm, null) + } + } + } + + private val listenerChannelItem = object : OnClickGesture() { + override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment(fm, + selectedItem.serviceId, selectedItem.url, selectedItem.name) + + override fun held(selectedItem: ChannelInfoItem) = showLongTapDialog(selectedItem) + } + + override fun handleResult(result: SubscriptionState) { + super.handleResult(result) + + val shouldUseGridLayout = shouldUseGridLayout() + when (result) { + is SubscriptionState.LoadedState -> { + result.subscriptions.forEach { + if (it is ChannelItem) { + it.gesturesListener = listenerChannelItem + it.itemVersion = when { + shouldUseGridLayout -> ChannelItem.ItemVersion.GRID + else -> ChannelItem.ItemVersion.MINI + } + } + } + + subscriptionsSection.update(result.subscriptions) + subscriptionsSection.setHideWhenEmpty(false) + + if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) { + items_list.post { + importExportItem.isExpanded = true + importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS) + } + } + + if (itemsListState != null) { + items_list.layoutManager?.onRestoreInstanceState(itemsListState) + itemsListState = null + } + } + is SubscriptionState.ErrorState -> { + result.error?.let { onError(result.error) } + } + } + } + + private fun handleFeedGroups(groups: List) { + feedGroupsSection.update(groups) + + if (feedGroupsListState != null) { + feedGroupsCarousel?.onRestoreInstanceState(feedGroupsListState) + feedGroupsListState = null + } + + if (groups.size < 2) { + items_list.post { feedGroupsSortMenuItem.notifyChanged(HeaderWithMenuItem.PAYLOAD_HIDE_MENU_ITEM) } + } else { + items_list.post { feedGroupsSortMenuItem.notifyChanged(HeaderWithMenuItem.PAYLOAD_SHOW_MENU_ITEM) } + } + } + + /////////////////////////////////////////////////////////////////////////// + // Contract + /////////////////////////////////////////////////////////////////////////// + + override fun showLoading() { + super.showLoading() + animateView(items_list, false, 100) + } + + override fun hideLoading() { + super.hideLoading() + animateView(items_list, true, 200) + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + /////////////////////////////////////////////////////////////////////////// + + override fun onError(exception: Throwable): Boolean { + if (super.onError(exception)) return true + + onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, "none", "Subscriptions", R.string.general_error) + return true + } + + /////////////////////////////////////////////////////////////////////////// + // Grid Mode + /////////////////////////////////////////////////////////////////////////// + + // TODO: Move these out of this class, as it can be reused + + private fun shouldUseGridLayout(): Boolean { + val listMode = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value)) + + return when (listMode) { + getString(R.string.list_view_mode_auto_key) -> { + val configuration = resources.configuration + + (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)) + } + getString(R.string.list_view_mode_grid_key) -> true + else -> false + } + } + + private fun getGridSpanCount(): Int { + val minWidth = resources.getDimensionPixelSize(R.dimen.channel_item_grid_min_width) + return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt()) + } + + companion object { + private const val REQUEST_EXPORT_CODE = 666 + private const val REQUEST_IMPORT_CODE = 667 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt new file mode 100644 index 000000000..92ab8cb0c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt @@ -0,0 +1,74 @@ +package org.schabi.newpipe.local.subscription + +import android.content.Context +import io.reactivex.Completable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.database.subscription.SubscriptionDAO +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.extractor.feed.FeedInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.local.feed.FeedDatabaseManager + +class SubscriptionManager(context: Context) { + private val database = NewPipeDatabase.getInstance(context) + private val subscriptionTable = database.subscriptionDAO() + private val feedDatabaseManager = FeedDatabaseManager(context) + + fun subscriptionTable(): SubscriptionDAO = subscriptionTable + fun subscriptions() = subscriptionTable.all + + fun upsertAll(infoList: List): List { + val listEntities = subscriptionTable.upsertAll( + infoList.map { SubscriptionEntity.from(it) }) + + database.runInTransaction { + infoList.forEachIndexed { index, info -> + feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems) + } + } + + return listEntities + } + + fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url) + .flatMapCompletable { + Completable.fromRunnable { + it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) + subscriptionTable.update(it) + feedDatabaseManager.upsertAll(it.uid, info.relatedItems) + } + } + + fun updateFromInfo(subscriptionId: Long, info: ListInfo) { + val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId) + + if (info is FeedInfo) { + subscriptionEntity.name = info.name + } else if (info is ChannelInfo) { + subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) + } + + subscriptionTable.update(subscriptionEntity) + } + + fun deleteSubscription(serviceId: Int, url: String): Completable { + return Completable.fromCallable { subscriptionTable.deleteSubscription(serviceId, url) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) { + database.runInTransaction { + val subscriptionId = subscriptionTable.insert(subscriptionEntity) + feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) + } + } + + fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { + subscriptionTable.delete(subscriptionEntity) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java deleted file mode 100644 index 7d6fa5158..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java +++ /dev/null @@ -1,162 +0,0 @@ -package org.schabi.newpipe.local.subscription; - -import android.content.Context; -import androidx.annotation.NonNull; -import android.util.Log; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.database.AppDatabase; -import org.schabi.newpipe.database.subscription.SubscriptionDAO; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -import io.reactivex.Completable; -import io.reactivex.CompletableSource; -import io.reactivex.Flowable; -import io.reactivex.Maybe; -import io.reactivex.Scheduler; -import io.reactivex.functions.Function; -import io.reactivex.schedulers.Schedulers; - -/** - * Subscription Service singleton: - * Provides a basis for channel Subscriptions. - * Provides access to subscription table in database as well as - * up-to-date observations on the subscribed channels - */ -public class SubscriptionService { - - private static volatile SubscriptionService instance; - - public static SubscriptionService getInstance(@NonNull Context context) { - SubscriptionService result = instance; - if (result == null) { - synchronized (SubscriptionService.class) { - result = instance; - if (result == null) { - instance = (result = new SubscriptionService(context)); - } - } - } - - return result; - } - - protected final String TAG = "SubscriptionService@" + Integer.toHexString(hashCode()); - protected static final boolean DEBUG = MainActivity.DEBUG; - private static final int SUBSCRIPTION_DEBOUNCE_INTERVAL = 500; - private static final int SUBSCRIPTION_THREAD_POOL_SIZE = 4; - - private final AppDatabase db; - private final Flowable> subscription; - - private final Scheduler subscriptionScheduler; - - private SubscriptionService(Context context) { - db = NewPipeDatabase.getInstance(context.getApplicationContext()); - subscription = getSubscriptionInfos(); - - final Executor subscriptionExecutor = Executors.newFixedThreadPool(SUBSCRIPTION_THREAD_POOL_SIZE); - subscriptionScheduler = Schedulers.from(subscriptionExecutor); - } - - /** - * Part of subscription observation pipeline - * - * @see SubscriptionService#getSubscription() - */ - private Flowable> getSubscriptionInfos() { - return subscriptionTable().getAll() - // Wait for a period of infrequent updates and return the latest update - .debounce(SUBSCRIPTION_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) - .share() // Share allows multiple subscribers on the same observable - .replay(1) // Replay synchronizes subscribers to the last emitted result - .autoConnect(); - } - - /** - * Provides an observer to the latest update to the subscription table. - *

- * This observer may be subscribed multiple times, where each subscriber obtains - * the latest synchronized changes available, effectively share the same data - * across all subscribers. - *

- * This observer has a debounce cooldown, meaning if multiple updates are observed - * in the cooldown interval, only the latest changes are emitted to the subscribers. - * This reduces the amount of observations caused by frequent updates to the database. - */ - @androidx.annotation.NonNull - public Flowable> getSubscription() { - return subscription; - } - - public Maybe getChannelInfo(final SubscriptionEntity subscriptionEntity) { - if (DEBUG) Log.d(TAG, "getChannelInfo() called with: subscriptionEntity = [" + subscriptionEntity + "]"); - - return Maybe.fromSingle(ExtractorHelper - .getChannelInfo(subscriptionEntity.getServiceId(), subscriptionEntity.getUrl(), false)) - .subscribeOn(subscriptionScheduler); - } - - /** - * Returns the database access interface for subscription table. - */ - public SubscriptionDAO subscriptionTable() { - return db.subscriptionDAO(); - } - - public Completable updateChannelInfo(final ChannelInfo info) { - final Function, CompletableSource> update = new Function, CompletableSource>() { - @Override - public CompletableSource apply(@NonNull List subscriptionEntities) { - if (DEBUG) Log.d(TAG, "updateChannelInfo() called with: subscriptionEntities = [" + subscriptionEntities + "]"); - if (subscriptionEntities.size() == 1) { - SubscriptionEntity subscription = subscriptionEntities.get(0); - - // Subscriber count changes very often, making this check almost unnecessary. - // Consider removing it later. - if (!isSubscriptionUpToDate(info, subscription)) { - subscription.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount()); - - return Completable.fromRunnable(() -> subscriptionTable().update(subscription)); - } - } - - return Completable.complete(); - } - }; - - return subscriptionTable().getSubscription(info.getServiceId(), info.getUrl()) - .firstOrError() - .flatMapCompletable(update); - } - - public List upsertAll(final List infoList) { - final List entityList = new ArrayList<>(); - for (ChannelInfo info : infoList) entityList.add(SubscriptionEntity.from(info)); - - return subscriptionTable().upsertAll(entityList); - } - - private boolean isSubscriptionUpToDate(final ChannelInfo info, final SubscriptionEntity entity) { - return equalsAndNotNull(info.getUrl(), entity.getUrl()) && - info.getServiceId() == entity.getServiceId() && - info.getName().equals(entity.getName()) && - equalsAndNotNull(info.getAvatarUrl(), entity.getAvatarUrl()) && - equalsAndNotNull(info.getDescription(), entity.getDescription()) && - info.getSubscriberCount() == entity.getSubscriberCount(); - } - - private boolean equalsAndNotNull(final Object o1, final Object o2) { - return (o1 != null && o2 != null) - && o1.equals(o2); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt new file mode 100644 index 000000000..6454cc912 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt @@ -0,0 +1,52 @@ +package org.schabi.newpipe.local.subscription + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.xwray.groupie.Group +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipe.local.feed.FeedDatabaseManager +import org.schabi.newpipe.local.subscription.item.ChannelItem +import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem +import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT +import java.util.concurrent.TimeUnit + +class SubscriptionViewModel(application: Application) : AndroidViewModel(application) { + private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application) + private var subscriptionManager = SubscriptionManager(application) + + private val mutableStateLiveData = MutableLiveData() + private val mutableFeedGroupsLiveData = MutableLiveData>() + val stateLiveData: LiveData = mutableStateLiveData + val feedGroupsLiveData: LiveData> = mutableFeedGroupsLiveData + + private var feedGroupItemsDisposable = feedDatabaseManager.groups() + .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) + .map { it.map(::FeedGroupCardItem) } + .subscribeOn(Schedulers.io()) + .subscribe( + { mutableFeedGroupsLiveData.postValue(it) }, + { mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) } + ) + + private var stateItemsDisposable = subscriptionManager.subscriptions() + .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) + .map { it.map { entity -> ChannelItem(entity.toChannelInfoItem(), entity.uid, ChannelItem.ItemVersion.MINI) } } + .subscribeOn(Schedulers.io()) + .subscribe( + { mutableStateLiveData.postValue(SubscriptionState.LoadedState(it)) }, + { mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) } + ) + + override fun onCleared() { + super.onCleared() + stateItemsDisposable.dispose() + feedGroupItemsDisposable.dispose() + } + + sealed class SubscriptionState { + data class LoadedState(val subscriptions: List) : SubscriptionState() + data class ErrorState(val error: Throwable? = null) : SubscriptionState() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt new file mode 100644 index 000000000..24c8d9cb8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt @@ -0,0 +1,35 @@ +package org.schabi.newpipe.local.subscription.decoration + +import android.content.Context +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.schabi.newpipe.R + +class FeedGroupCarouselDecoration(context: Context) : RecyclerView.ItemDecoration() { + + private val marginStartEnd: Int + private val marginTopBottom: Int + private val marginBetweenItems: Int + + init { + with(context.resources) { + marginStartEnd = getDimensionPixelOffset(R.dimen.feed_group_carousel_start_end_margin) + marginTopBottom = getDimensionPixelOffset(R.dimen.feed_group_carousel_top_bottom_margin) + marginBetweenItems = getDimensionPixelOffset(R.dimen.feed_group_carousel_between_items_margin) + } + } + + override fun getItemOffsets(outRect: Rect, child: View, parent: RecyclerView, state: RecyclerView.State) { + val childAdapterPosition = parent.getChildAdapterPosition(child) + val childAdapterCount = parent.adapter?.itemCount ?: 0 + + outRect.set(marginBetweenItems, marginTopBottom, 0, marginTopBottom) + + if (childAdapterPosition == 0) { + outRect.left = marginStartEnd + } else if (childAdapterPosition == childAdapterCount - 1) { + outRect.right = marginStartEnd + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt new file mode 100644 index 000000000..b1fef5671 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt @@ -0,0 +1,356 @@ +package org.schabi.newpipe.local.subscription.dialog + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.os.Parcelable +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.Section +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import icepick.Icepick +import icepick.State +import kotlinx.android.synthetic.main.dialog_feed_group_create.* +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.local.subscription.FeedGroupIcon +import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.* +import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent +import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent +import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem +import org.schabi.newpipe.local.subscription.item.PickerIconItem +import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem +import org.schabi.newpipe.util.ThemeHelper +import java.io.Serializable + +class FeedGroupDialog : DialogFragment() { + private lateinit var viewModel: FeedGroupDialogViewModel + private var groupId: Long = NO_GROUP_SELECTED + private var groupIcon: FeedGroupIcon? = null + private var groupSortOrder: Long = -1 + + sealed class ScreenState : Serializable { + object InitialScreen : ScreenState() + object IconPickerScreen : ScreenState() + object SubscriptionsPickerScreen : ScreenState() + object DeleteScreen : ScreenState() + } + + @State @JvmField var selectedIcon: FeedGroupIcon? = null + @State @JvmField var selectedSubscriptions: HashSet = HashSet() + @State @JvmField var currentScreen: ScreenState = InitialScreen + + @State @JvmField var subscriptionsListState: Parcelable? = null + @State @JvmField var iconsListState: Parcelable? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Icepick.restoreInstanceState(this, savedInstanceState) + + setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())) + groupId = arguments?.getLong(KEY_GROUP_ID, NO_GROUP_SELECTED) ?: NO_GROUP_SELECTED + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.dialog_feed_group_create, container) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return object : Dialog(requireActivity(), theme) { + override fun onBackPressed() { + if (currentScreen !is InitialScreen) { + showScreen(InitialScreen) + } else { + super.onBackPressed() + } + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + iconsListState = icon_selector.layoutManager?.onSaveInstanceState() + subscriptionsListState = subscriptions_selector_list.layoutManager?.onSaveInstanceState() + + Icepick.saveInstanceState(this, outState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProviders.of(this, FeedGroupDialogViewModel.Factory(requireContext(), groupId)) + .get(FeedGroupDialogViewModel::class.java) + + viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup)) + viewModel.subscriptionsLiveData.observe(viewLifecycleOwner, Observer { setupSubscriptionPicker(it.first, it.second) }) + viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer { + when (it) { + ProcessingEvent -> disableInput() + SuccessEvent -> dismiss() + } + }) + + setupIconPicker() + setupListeners() + + showScreen(currentScreen) + } + + /////////////////////////////////////////////////////////////////////////// + // Setup + /////////////////////////////////////////////////////////////////////////// + + private fun setupListeners() { + delete_button.setOnClickListener { showScreen(DeleteScreen) } + + cancel_button.setOnClickListener { + when (currentScreen) { + InitialScreen -> dismiss() + else -> showScreen(InitialScreen) + } + } + + group_name_input_container.error = null + group_name_input.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) {} + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (group_name_input_container.isErrorEnabled && !s.isNullOrBlank()) { + group_name_input_container.error = null + } + } + }) + + confirm_button.setOnClickListener { + when (currentScreen) { + InitialScreen -> handlePositiveButtonInitialScreen() + DeleteScreen -> viewModel.deleteGroup() + else -> showScreen(InitialScreen) + } + } + } + + private fun handlePositiveButtonInitialScreen() { + val name = group_name_input.text.toString().trim() + val icon = selectedIcon ?: groupIcon ?: FeedGroupIcon.ALL + + if (name.isBlank()) { + group_name_input_container.error = getString(R.string.feed_group_dialog_empty_name) + group_name_input.text = null + group_name_input.requestFocus() + return + } else { + group_name_input_container.error = null + } + + if (selectedSubscriptions.isEmpty()) { + Toast.makeText(requireContext(), getString(R.string.feed_group_dialog_empty_selection), Toast.LENGTH_SHORT).show() + return + } + + when (groupId) { + NO_GROUP_SELECTED -> viewModel.createGroup(name, icon, selectedSubscriptions) + else -> viewModel.updateGroup(name, icon, selectedSubscriptions, groupSortOrder) + } + } + + private fun handleGroup(feedGroupEntity: FeedGroupEntity? = null) { + val icon = feedGroupEntity?.icon ?: FeedGroupIcon.ALL + val name = feedGroupEntity?.name ?: "" + groupIcon = feedGroupEntity?.icon + groupSortOrder = feedGroupEntity?.sortOrder ?: -1 + + icon_preview.setImageResource((if (selectedIcon == null) icon else selectedIcon!!).getDrawableRes(requireContext())) + + if (group_name_input.text.isNullOrBlank()) { + group_name_input.setText(name) + } + } + + private fun setupSubscriptionPicker(subscriptions: List, selectedSubscriptions: Set) { + this.selectedSubscriptions.addAll(selectedSubscriptions) + val useGridLayout = subscriptions.isNotEmpty() + + val groupAdapter = GroupAdapter() + groupAdapter.spanCount = if (useGridLayout) 4 else 1 + + val subscriptionsCount = this.selectedSubscriptions.size + val selectedCountText = resources.getQuantityString(R.plurals.feed_group_dialog_selection_count, subscriptionsCount, subscriptionsCount) + selected_subscription_count_view.text = selectedCountText + subscriptions_selector_header_info.text = selectedCountText + + Section().apply { + addAll(subscriptions.map { + val isSelected = this@FeedGroupDialog.selectedSubscriptions.contains(it.uid) + PickerSubscriptionItem(it, isSelected) + }) + setPlaceholder(EmptyPlaceholderItem()) + + groupAdapter.add(this) + } + + subscriptions_selector_list.apply { + layoutManager = if (useGridLayout) { + GridLayoutManager(requireContext(), groupAdapter.spanCount, RecyclerView.VERTICAL, false) + } else { + LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false) + } + + adapter = groupAdapter + + if (subscriptionsListState != null) { + layoutManager?.onRestoreInstanceState(subscriptionsListState) + subscriptionsListState = null + } + } + + groupAdapter.setOnItemClickListener { item, _ -> + when (item) { + is PickerSubscriptionItem -> { + val subscriptionId = item.subscriptionEntity.uid + + val isSelected = if (this.selectedSubscriptions.contains(subscriptionId)) { + this.selectedSubscriptions.remove(subscriptionId) + false + } else { + this.selectedSubscriptions.add(subscriptionId) + true + } + + item.isSelected = isSelected + item.notifyChanged(PickerSubscriptionItem.UPDATE_SELECTED) + + val subscriptionsCount = this.selectedSubscriptions.size + val updateSelectedCountText = resources.getQuantityString(R.plurals.feed_group_dialog_selection_count, subscriptionsCount, subscriptionsCount) + selected_subscription_count_view.text = updateSelectedCountText + subscriptions_selector_header_info.text = updateSelectedCountText + } + } + } + + select_channel_button.setOnClickListener { + subscriptions_selector_list.scrollToPosition(0) + showScreen(SubscriptionsPickerScreen) + } + } + + private fun setupIconPicker() { + val groupAdapter = GroupAdapter() + groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(requireContext(), it) }) + + icon_selector.apply { + layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false) + adapter = groupAdapter + + if (iconsListState != null) { + layoutManager?.onRestoreInstanceState(iconsListState) + iconsListState = null + } + } + + groupAdapter.setOnItemClickListener { item, _ -> + when (item) { + is PickerIconItem -> { + selectedIcon = item.icon + icon_preview.setImageResource(item.iconRes) + + showScreen(InitialScreen) + } + } + } + icon_preview.setOnClickListener { + icon_selector.scrollToPosition(0) + showScreen(IconPickerScreen) + } + + if (groupId == NO_GROUP_SELECTED) { + val icon = selectedIcon ?: FeedGroupIcon.ALL + icon_preview.setImageResource(icon.getDrawableRes(requireContext())) + } + } + + /////////////////////////////////////////////////////////////////////////// + // Screen Selector + /////////////////////////////////////////////////////////////////////////// + + private fun showScreen(screen: ScreenState) { + currentScreen = screen + + options_root.onlyVisibleIn(InitialScreen) + icon_selector.onlyVisibleIn(IconPickerScreen) + subscriptions_selector.onlyVisibleIn(SubscriptionsPickerScreen) + delete_screen_message.onlyVisibleIn(DeleteScreen) + + separator.onlyVisibleIn(SubscriptionsPickerScreen, IconPickerScreen) + cancel_button.onlyVisibleIn(InitialScreen, DeleteScreen) + + confirm_button.setText(when { + currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED -> R.string.create + else -> android.R.string.ok + }) + + delete_button.visibility = when { + currentScreen != InitialScreen -> View.GONE + groupId == NO_GROUP_SELECTED -> View.GONE + else -> View.VISIBLE + } + + if (currentScreen != InitialScreen) hideKeyboard() + } + + private fun View.onlyVisibleIn(vararg screens: ScreenState) { + visibility = when (currentScreen) { + in screens -> View.VISIBLE + else -> View.GONE + } + } + + /////////////////////////////////////////////////////////////////////////// + // Utils + /////////////////////////////////////////////////////////////////////////// + + private fun hideKeyboard() { + val inputMethodManager = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(group_name_input.windowToken, InputMethodManager.RESULT_UNCHANGED_SHOWN) + group_name_input.clearFocus() + } + + private fun disableInput() { + delete_button?.isEnabled = false + confirm_button?.isEnabled = false + cancel_button?.isEnabled = false + isCancelable = false + + hideKeyboard() + } + + companion object { + private const val KEY_GROUP_ID = "KEY_GROUP_ID" + private const val NO_GROUP_SELECTED = -1L + + fun newInstance(groupId: Long = NO_GROUP_SELECTED): FeedGroupDialog { + val dialog = FeedGroupDialog() + + dialog.arguments = Bundle().apply { + putLong(KEY_GROUP_ID, groupId) + } + + return dialog + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt new file mode 100644 index 000000000..bd57a2639 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt @@ -0,0 +1,87 @@ +package org.schabi.newpipe.local.subscription.dialog + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.Completable +import io.reactivex.Flowable +import io.reactivex.disposables.Disposable +import io.reactivex.functions.BiFunction +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.local.feed.FeedDatabaseManager +import org.schabi.newpipe.local.subscription.FeedGroupIcon +import org.schabi.newpipe.local.subscription.SubscriptionManager + + +class FeedGroupDialogViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() { + class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return FeedGroupDialogViewModel(context.applicationContext, groupId) as T + } + } + + private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) + private var subscriptionManager = SubscriptionManager(applicationContext) + + private val mutableGroupLiveData = MutableLiveData() + private val mutableSubscriptionsLiveData = MutableLiveData, Set>>() + private val mutableDialogEventLiveData = MutableLiveData() + val groupLiveData: LiveData = mutableGroupLiveData + val subscriptionsLiveData: LiveData, Set>> = mutableSubscriptionsLiveData + val dialogEventLiveData: LiveData = mutableDialogEventLiveData + + private var actionProcessingDisposable: Disposable? = null + + private var feedGroupDisposable = feedDatabaseManager.getGroup(groupId) + .subscribeOn(Schedulers.io()) + .subscribe(mutableGroupLiveData::postValue) + + private var subscriptionsDisposable = Flowable + .combineLatest(subscriptionManager.subscriptions(), feedDatabaseManager.subscriptionIdsForGroup(groupId), + BiFunction { t1: List, t2: List -> t1 to t2.toSet() }) + .subscribeOn(Schedulers.io()) + .subscribe(mutableSubscriptionsLiveData::postValue) + + override fun onCleared() { + super.onCleared() + actionProcessingDisposable?.dispose() + subscriptionsDisposable.dispose() + feedGroupDisposable.dispose() + } + + fun createGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set) { + doAction(feedDatabaseManager.createGroup(name, selectedIcon) + .flatMapCompletable { + feedDatabaseManager.updateSubscriptionsForGroup(it, selectedSubscriptions.toList()) + }) + } + + fun updateGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set, sortOrder: Long) { + doAction(feedDatabaseManager.updateSubscriptionsForGroup(groupId, selectedSubscriptions.toList()) + .andThen(feedDatabaseManager.updateGroup(FeedGroupEntity(groupId, name, selectedIcon, sortOrder)))) + } + + fun deleteGroup() { + doAction(feedDatabaseManager.deleteGroup(groupId)) + } + + private fun doAction(completable: Completable) { + if (actionProcessingDisposable == null) { + mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent + + actionProcessingDisposable = completable + .subscribeOn(Schedulers.io()) + .subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) } + } + } + + sealed class DialogEvent { + object ProcessingEvent : DialogEvent() + object SuccessEvent : DialogEvent() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt new file mode 100644 index 000000000..17ee89c87 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt @@ -0,0 +1,109 @@ +package org.schabi.newpipe.local.subscription.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.TouchCallback +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import icepick.Icepick +import icepick.State +import kotlinx.android.synthetic.main.dialog_feed_group_reorder.* +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.* +import org.schabi.newpipe.local.subscription.item.FeedGroupReorderItem +import org.schabi.newpipe.util.ThemeHelper +import java.util.* +import kotlin.collections.ArrayList + +class FeedGroupReorderDialog : DialogFragment() { + private lateinit var viewModel: FeedGroupReorderDialogViewModel + + @State @JvmField var groupOrderedIdList = ArrayList() + private val groupAdapter = GroupAdapter() + private val itemTouchHelper = ItemTouchHelper(getItemTouchCallback()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Icepick.restoreInstanceState(this, savedInstanceState) + + setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.dialog_feed_group_reorder, container) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProviders.of(this).get(FeedGroupReorderDialogViewModel::class.java) + viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups)) + viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer { + when (it) { + ProcessingEvent -> disableInput() + SuccessEvent -> dismiss() + } + }) + + feed_groups_list.layoutManager = LinearLayoutManager(requireContext()) + feed_groups_list.adapter = groupAdapter + itemTouchHelper.attachToRecyclerView(feed_groups_list) + + confirm_button.setOnClickListener { + viewModel.updateOrder(groupOrderedIdList) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Icepick.saveInstanceState(this, outState) + } + + private fun handleGroups(list: List) { + val groupList: List + + if (groupOrderedIdList.isEmpty()) { + groupList = list + groupOrderedIdList.addAll(groupList.map { it.uid }) + } else { + groupList = list.sortedBy { groupOrderedIdList.indexOf(it.uid) } + } + + groupAdapter.update(groupList.map { FeedGroupReorderItem(it, itemTouchHelper) }) + } + + private fun disableInput() { + confirm_button?.isEnabled = false + isCancelable = false + } + + private fun getItemTouchCallback(): SimpleCallback { + return object : TouchCallback() { + + override fun onMove(recyclerView: RecyclerView, source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder): Boolean { + val sourceIndex = source.adapterPosition + val targetIndex = target.adapterPosition + + groupAdapter.notifyItemMoved(sourceIndex, targetIndex) + Collections.swap(groupOrderedIdList, sourceIndex, targetIndex) + + return true + } + + override fun isLongPressDragEnabled(): Boolean = false + override fun isItemViewSwipeEnabled(): Boolean = false + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) {} + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt new file mode 100644 index 000000000..8ef5bb55c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt @@ -0,0 +1,52 @@ +package org.schabi.newpipe.local.subscription.dialog + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.reactivex.Completable +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.local.feed.FeedDatabaseManager + +class FeedGroupReorderDialogViewModel(application: Application) : AndroidViewModel(application) { + private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application) + + private val mutableGroupsLiveData = MutableLiveData>() + private val mutableDialogEventLiveData = MutableLiveData() + val groupsLiveData: LiveData> = mutableGroupsLiveData + val dialogEventLiveData: LiveData = mutableDialogEventLiveData + + private var actionProcessingDisposable: Disposable? = null + + private var groupsDisposable = feedDatabaseManager.groups() + .limit(1) + .subscribeOn(Schedulers.io()) + .subscribe(mutableGroupsLiveData::postValue) + + override fun onCleared() { + super.onCleared() + actionProcessingDisposable?.dispose() + groupsDisposable.dispose() + } + + fun updateOrder(groupIdList: List) { + doAction(feedDatabaseManager.updateGroupsOrder(groupIdList)) + } + + private fun doAction(completable: Completable) { + if (actionProcessingDisposable == null) { + mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent + + actionProcessingDisposable = completable + .subscribeOn(Schedulers.io()) + .subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) } + } + } + + sealed class DialogEvent { + object ProcessingEvent : DialogEvent() + object SuccessEvent : DialogEvent() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt new file mode 100644 index 000000000..928f93a47 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt @@ -0,0 +1,65 @@ +package org.schabi.newpipe.local.subscription.item + +import android.content.Context +import com.nostra13.universalimageloader.core.ImageLoader +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import kotlinx.android.synthetic.main.list_channel_item.* +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipe.util.ImageDisplayConstants +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.OnClickGesture + + +class ChannelItem( + private val infoItem: ChannelInfoItem, + private val subscriptionId: Long = -1L, + var itemVersion: ItemVersion = ItemVersion.NORMAL, + var gesturesListener: OnClickGesture? = null +) : Item() { + + override fun getId(): Long = if (subscriptionId == -1L) super.getId() else subscriptionId + + enum class ItemVersion { NORMAL, MINI, GRID } + + override fun getLayout(): Int = when (itemVersion) { + ItemVersion.NORMAL -> R.layout.list_channel_item + ItemVersion.MINI -> R.layout.list_channel_mini_item + ItemVersion.GRID -> R.layout.list_channel_grid_item + } + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.itemTitleView.text = infoItem.name + viewHolder.itemAdditionalDetails.text = getDetailLine(viewHolder.root.context) + if (itemVersion == ItemVersion.NORMAL) viewHolder.itemChannelDescriptionView.text = infoItem.description + + ImageLoader.getInstance().displayImage(infoItem.thumbnailUrl, viewHolder.itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS) + + gesturesListener?.run { + viewHolder.containerView.setOnClickListener { selected(infoItem) } + viewHolder.containerView.setOnLongClickListener { held(infoItem); true } + } + } + + private fun getDetailLine(context: Context): String { + var details = if (infoItem.subscriberCount >= 0) { + Localization.shortSubscriberCount(context, infoItem.subscriberCount) + } else { + context.getString(R.string.subscribers_count_not_available) + } + + if (itemVersion == ItemVersion.NORMAL) { + if (infoItem.streamCount >= 0) { + val formattedVideoAmount = Localization.localizeStreamCount(context, infoItem.streamCount) + details = Localization.concatenateStrings(details, formattedVideoAmount) + } + } + return details + } + + override fun getSpanSize(spanCount: Int, position: Int): Int { + return if (itemVersion == ItemVersion.GRID) 1 else spanCount + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt new file mode 100644 index 000000000..0c651dc69 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt @@ -0,0 +1,10 @@ +package org.schabi.newpipe.local.subscription.item + +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import org.schabi.newpipe.R + +class EmptyPlaceholderItem : Item() { + override fun getLayout(): Int = R.layout.list_empty_view + override fun bind(viewHolder: GroupieViewHolder, position: Int) {} +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt new file mode 100644 index 000000000..309f82bbc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt @@ -0,0 +1,10 @@ +package org.schabi.newpipe.local.subscription.item + +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import org.schabi.newpipe.R + +class FeedGroupAddItem : Item() { + override fun getLayout(): Int = R.layout.feed_group_add_new_item + override fun bind(viewHolder: GroupieViewHolder, position: Int) {} +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt new file mode 100644 index 000000000..a757dc5b3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt @@ -0,0 +1,30 @@ +package org.schabi.newpipe.local.subscription.item + +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import kotlinx.android.synthetic.main.feed_group_card_item.* +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.local.subscription.FeedGroupIcon + +data class FeedGroupCardItem( + val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + val name: String, + val icon: FeedGroupIcon +) : Item() { + constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon) + + override fun getId(): Long { + return when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> super.getId() + else -> groupId + } + } + + override fun getLayout(): Int = R.layout.feed_group_card_item + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.title.text = name + viewHolder.icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt new file mode 100644 index 000000000..bde3c604a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt @@ -0,0 +1,57 @@ +package org.schabi.newpipe.local.subscription.item + +import android.content.Context +import android.os.Parcelable +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import kotlinx.android.synthetic.main.feed_item_carousel.* +import org.schabi.newpipe.R +import org.schabi.newpipe.local.subscription.decoration.FeedGroupCarouselDecoration + +class FeedGroupCarouselItem(context: Context, private val carouselAdapter: GroupAdapter) : Item() { + private val feedGroupCarouselDecoration = FeedGroupCarouselDecoration(context) + + private var linearLayoutManager: LinearLayoutManager? = null + private var listState: Parcelable? = null + + override fun getLayout() = R.layout.feed_item_carousel + + fun onSaveInstanceState(): Parcelable? { + listState = linearLayoutManager?.onSaveInstanceState() + return listState + } + + fun onRestoreInstanceState(state: Parcelable?) { + linearLayoutManager?.onRestoreInstanceState(state) + listState = state + } + + override fun createViewHolder(itemView: View): GroupieViewHolder { + val viewHolder = super.createViewHolder(itemView) + + linearLayoutManager = LinearLayoutManager(itemView.context, RecyclerView.HORIZONTAL, false) + + viewHolder.recycler_view.apply { + layoutManager = linearLayoutManager + adapter = carouselAdapter + addItemDecoration(feedGroupCarouselDecoration) + } + + return viewHolder + } + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.recycler_view.apply { adapter = carouselAdapter } + linearLayoutManager?.onRestoreInstanceState(listState) + } + + override fun unbind(viewHolder: GroupieViewHolder) { + super.unbind(viewHolder) + + listState = linearLayoutManager?.onSaveInstanceState() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt new file mode 100644 index 000000000..cf010af7f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt @@ -0,0 +1,48 @@ +package org.schabi.newpipe.local.subscription.item + +import android.view.MotionEvent +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.ItemTouchHelper.DOWN +import androidx.recyclerview.widget.ItemTouchHelper.UP +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import kotlinx.android.synthetic.main.feed_group_reorder_item.* +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.local.subscription.FeedGroupIcon + +data class FeedGroupReorderItem( + val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + val name: String, + val icon: FeedGroupIcon, + val dragCallback: ItemTouchHelper +) : Item() { + constructor (feedGroupEntity: FeedGroupEntity, dragCallback: ItemTouchHelper) + : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon, dragCallback) + + override fun getId(): Long { + return when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> super.getId() + else -> groupId + } + } + + override fun getLayout(): Int = R.layout.feed_group_reorder_item + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.group_name.text = name + viewHolder.group_icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context)) + viewHolder.handle.setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + dragCallback.startDrag(viewHolder) + return@setOnTouchListener true + } + + false + } + } + + override fun getDragDirs(): Int { + return UP or DOWN + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt new file mode 100644 index 000000000..ab47564ce --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt @@ -0,0 +1,116 @@ +package org.schabi.newpipe.local.subscription.item + +import android.graphics.Color +import android.graphics.PorterDuff +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import kotlinx.android.synthetic.main.feed_import_export_group.* +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.exceptions.ExtractionException +import org.schabi.newpipe.util.AnimationUtils +import org.schabi.newpipe.util.ServiceHelper +import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.views.CollapsibleView + +class FeedImportExportItem( + val onImportPreviousSelected: () -> Unit, + val onImportFromServiceSelected: (Int) -> Unit, + val onExportSelected: () -> Unit, + var isExpanded: Boolean = false +) : Item() { + companion object { + const val REFRESH_EXPANDED_STATUS = 123 + } + + override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) { + if (payloads.contains(REFRESH_EXPANDED_STATUS)) { + viewHolder.import_export_options.apply { if (isExpanded) expand() else collapse() } + return + } + + super.bind(viewHolder, position, payloads) + } + + override fun getLayout(): Int = R.layout.feed_import_export_group + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + if (viewHolder.import_from_options.childCount == 0) setupImportFromItems(viewHolder.import_from_options) + if (viewHolder.export_to_options.childCount == 0) setupExportToItems(viewHolder.export_to_options) + + expandIconListener?.let { viewHolder.import_export_options.removeListener(it) } + expandIconListener = CollapsibleView.StateListener { newState -> + AnimationUtils.animateRotation(viewHolder.import_export_expand_icon, + 250, if (newState == CollapsibleView.COLLAPSED) 0 else 180) + } + + viewHolder.import_export_options.currentState = if (isExpanded) CollapsibleView.EXPANDED else CollapsibleView.COLLAPSED + viewHolder.import_export_expand_icon.rotation = if (isExpanded) 180F else 0F + viewHolder.import_export_options.ready() + + viewHolder.import_export_options.addListener(expandIconListener) + viewHolder.import_export.setOnClickListener { + viewHolder.import_export_options.switchState() + isExpanded = viewHolder.import_export_options.currentState == CollapsibleView.EXPANDED + } + } + + override fun unbind(viewHolder: GroupieViewHolder) { + super.unbind(viewHolder) + expandIconListener?.let { viewHolder.import_export_options.removeListener(it) } + expandIconListener = null + } + + private var expandIconListener: CollapsibleView.StateListener? = null + + private fun addItemView(title: String, @DrawableRes icon: Int, container: ViewGroup): View { + val itemRoot = View.inflate(container.context, R.layout.subscription_import_export_item, null) + val titleView = itemRoot.findViewById(android.R.id.text1) + val iconView = itemRoot.findViewById(android.R.id.icon1) + + titleView.text = title + iconView.setImageResource(icon) + + container.addView(itemRoot) + return itemRoot + } + + private fun setupImportFromItems(listHolder: ViewGroup) { + val previousBackupItem = addItemView(listHolder.context.getString(R.string.previous_export), + ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_backup), listHolder) + previousBackupItem.setOnClickListener { onImportPreviousSelected() } + + val iconColor = if (ThemeHelper.isLightThemeSelected(listHolder.context)) Color.BLACK else Color.WHITE + val services = listHolder.context.resources.getStringArray(R.array.service_list) + for (serviceName in services) { + try { + val service = NewPipe.getService(serviceName) + + val subscriptionExtractor = service.subscriptionExtractor ?: continue + + val supportedSources = subscriptionExtractor.supportedSources + if (supportedSources.isEmpty()) continue + + val itemView = addItemView(serviceName, ServiceHelper.getIcon(service.serviceId), listHolder) + val iconView = itemView.findViewById(android.R.id.icon1) + iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN) + + itemView.setOnClickListener { onImportFromServiceSelected(service.serviceId) } + } catch (e: ExtractionException) { + throw RuntimeException("Services array contains an entry that it's not a valid service name ($serviceName)", e) + } + + } + } + + private fun setupExportToItems(listHolder: ViewGroup) { + val previousBackupItem = addItemView(listHolder.context.getString(R.string.file), + ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_save), listHolder) + previousBackupItem.setOnClickListener { onExportSelected() } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt new file mode 100644 index 000000000..367605f46 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt @@ -0,0 +1,19 @@ +package org.schabi.newpipe.local.subscription.item + +import android.view.View.OnClickListener +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import kotlinx.android.synthetic.main.header_item.* +import org.schabi.newpipe.R + +class HeaderItem(val title: String, private val onClickListener: (() -> Unit)? = null) : Item() { + + override fun getLayout(): Int = R.layout.header_item + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.header_title.text = title + + val listener: OnClickListener? = if (onClickListener != null) OnClickListener { onClickListener.invoke() } else null + viewHolder.root.setOnClickListener(listener) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderWithMenuItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderWithMenuItem.kt new file mode 100644 index 000000000..5ffdfe7c1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderWithMenuItem.kt @@ -0,0 +1,48 @@ +package org.schabi.newpipe.local.subscription.item + +import android.view.View.* +import androidx.annotation.DrawableRes +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import kotlinx.android.synthetic.main.header_with_menu_item.* +import org.schabi.newpipe.R + +class HeaderWithMenuItem( + val title: String, + @DrawableRes val itemIcon: Int = 0, + private val onClickListener: (() -> Unit)? = null, + private val menuItemOnClickListener: (() -> Unit)? = null +) : Item() { + companion object { + const val PAYLOAD_SHOW_MENU_ITEM = 1 + const val PAYLOAD_HIDE_MENU_ITEM = 2 + } + + override fun getLayout(): Int = R.layout.header_with_menu_item + + + override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) { + if (payloads.contains(PAYLOAD_SHOW_MENU_ITEM)) { + viewHolder.header_menu_item.visibility = VISIBLE + return + } else if (payloads.contains(PAYLOAD_HIDE_MENU_ITEM)) { + viewHolder.header_menu_item.visibility = GONE + return + } + + super.bind(viewHolder, position, payloads) + } + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.header_title.text = title + viewHolder.header_menu_item.setImageResource(itemIcon) + + val listener: OnClickListener? = + onClickListener?.let { OnClickListener { onClickListener.invoke() } } + viewHolder.root.setOnClickListener(listener) + + val menuItemListener: OnClickListener? = + menuItemOnClickListener?.let { OnClickListener { menuItemOnClickListener.invoke() } } + viewHolder.header_menu_item.setOnClickListener(menuItemListener) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt new file mode 100644 index 000000000..fedec9880 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt @@ -0,0 +1,19 @@ +package org.schabi.newpipe.local.subscription.item + +import android.content.Context +import androidx.annotation.DrawableRes +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import kotlinx.android.synthetic.main.picker_icon_item.* +import org.schabi.newpipe.R +import org.schabi.newpipe.local.subscription.FeedGroupIcon + +class PickerIconItem(context: Context, val icon: FeedGroupIcon) : Item() { + @DrawableRes val iconRes: Int = icon.getDrawableRes(context) + + override fun getLayout(): Int = R.layout.picker_icon_item + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.icon_view.setImageResource(iconRes) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt new file mode 100644 index 000000000..21c74b09f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt @@ -0,0 +1,51 @@ +package org.schabi.newpipe.local.subscription.item + +import android.view.View +import com.nostra13.universalimageloader.core.DisplayImageOptions +import com.nostra13.universalimageloader.core.ImageLoader +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import kotlinx.android.synthetic.main.picker_subscription_item.* +import org.schabi.newpipe.R +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.util.AnimationUtils +import org.schabi.newpipe.util.AnimationUtils.animateView +import org.schabi.newpipe.util.ImageDisplayConstants + +data class PickerSubscriptionItem(val subscriptionEntity: SubscriptionEntity, var isSelected: Boolean = false) : Item() { + companion object { + const val UPDATE_SELECTED = 123 + + val IMAGE_LOADING_OPTIONS: DisplayImageOptions = ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS + } + + override fun getLayout(): Int = R.layout.picker_subscription_item + + override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) { + if (payloads.contains(UPDATE_SELECTED)) { + animateView(viewHolder.selected_highlight, AnimationUtils.Type.LIGHT_SCALE_AND_ALPHA, isSelected, 150) + return + } + + super.bind(viewHolder, position, payloads) + } + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + ImageLoader.getInstance().displayImage(subscriptionEntity.avatarUrl, viewHolder.thumbnail_view, IMAGE_LOADING_OPTIONS) + + viewHolder.title_view.text = subscriptionEntity.name + viewHolder.selected_highlight.visibility = if (isSelected) View.VISIBLE else View.GONE + } + + override fun unbind(viewHolder: GroupieViewHolder) { + super.unbind(viewHolder) + + viewHolder.selected_highlight.animate().setListener(null).cancel() + viewHolder.selected_highlight.visibility = View.GONE + viewHolder.selected_highlight.alpha = 1F + } + + override fun getId(): Long { + return subscriptionEntity.uid + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java index 6b607cdca..e970ebfa4 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java @@ -34,10 +34,9 @@ import android.widget.Toast; import org.reactivestreams.Publisher; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; +import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; -import org.schabi.newpipe.local.subscription.ImportExportEventListener; -import org.schabi.newpipe.local.subscription.SubscriptionService; import java.io.FileNotFoundException; import java.io.IOException; @@ -57,7 +56,7 @@ public abstract class BaseImportExportService extends Service { protected NotificationManagerCompat notificationManager; protected NotificationCompat.Builder notificationBuilder; - protected SubscriptionService subscriptionService; + protected SubscriptionManager subscriptionManager; protected final CompositeDisposable disposables = new CompositeDisposable(); protected final PublishProcessor notificationUpdater = PublishProcessor.create(); @@ -70,7 +69,7 @@ public abstract class BaseImportExportService extends Service { @Override public void onCreate() { super.onCreate(); - subscriptionService = SubscriptionService.getInstance(this); + subscriptionManager = new SubscriptionManager(this); setupNotification(); } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportEventListener.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java similarity index 87% rename from app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportEventListener.java rename to app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java index 01c0427f3..788073ee5 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java @@ -1,4 +1,4 @@ -package org.schabi.newpipe.local.subscription; +package org.schabi.newpipe.local.subscription.services; public interface ImportExportEventListener { /** diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportJsonHelper.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java similarity index 98% rename from app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportJsonHelper.java rename to app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java index ebfff9fe2..5b5ebf702 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportJsonHelper.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java @@ -17,7 +17,7 @@ * along with this program. If not, see . */ -package org.schabi.newpipe.local.subscription; +package org.schabi.newpipe.local.subscription.services; import androidx.annotation.Nullable; diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java index 31cd4b603..358024574 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java @@ -29,7 +29,6 @@ import org.reactivestreams.Subscription; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.subscription.SubscriptionItem; -import org.schabi.newpipe.local.subscription.ImportExportJsonHelper; import java.io.File; import java.io.FileNotFoundException; @@ -96,7 +95,7 @@ public class SubscriptionsExportService extends BaseImportExportService { private void startExport() { showToast(R.string.export_ongoing); - subscriptionService.subscriptionTable() + subscriptionManager.subscriptionTable() .getAll() .take(1) .map(subscriptionEntities -> { diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java index 62c1dfeb9..0d2f3757f 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java @@ -33,7 +33,6 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.subscription.SubscriptionItem; -import org.schabi.newpipe.local.subscription.ImportExportJsonHelper; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.ExtractorHelper; @@ -180,6 +179,7 @@ public class SubscriptionsImportService extends BaseImportExportService { .observeOn(Schedulers.io()) .doOnNext(getNotificationsConsumer()) + .buffer(BUFFER_COUNT_BEFORE_INSERT) .map(upsertBatch()) @@ -204,6 +204,7 @@ public class SubscriptionsImportService extends BaseImportExportService { @Override public void onError(Throwable error) { + Log.e(TAG, "Got an error!", error); handleError(error); } @@ -242,7 +243,7 @@ public class SubscriptionsImportService extends BaseImportExportService { if (n.isOnNext()) infoList.add(n.getValue()); } - return subscriptionService.upsertAll(infoList); + return subscriptionManager.upsertAll(infoList); }; } diff --git a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java index 9e23d9145..4eaa2a73b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java @@ -30,16 +30,16 @@ import android.content.res.Resources; import android.graphics.Bitmap; import android.os.Build; import android.os.IBinder; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.core.app.NotificationCompat; - import android.preference.PreferenceManager; import android.util.Log; import android.view.View; import android.widget.RemoteViews; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; + import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.source.MediaSource; @@ -341,7 +341,7 @@ public final class BackgroundPlayer extends Service { @Override public void handleIntent(final Intent intent) { super.handleIntent(intent); - + resetNotification(); if (bigNotRemoteView != null) bigNotRemoteView.setProgressBar(R.id.notificationProgressBar, 100, 0, false); @@ -389,7 +389,6 @@ public final class BackgroundPlayer extends Service { @Override public void onPrepared(boolean playWhenReady) { super.onPrepared(playWhenReady); - simpleExoPlayer.setVolume(1f); } @Override @@ -398,6 +397,12 @@ public final class BackgroundPlayer extends Service { updatePlayback(); } + @Override + public void onMuteUnmuteButtonClicked() { + super.onMuteUnmuteButtonClicked(); + updatePlayback(); + } + @Override public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) { updateProgress(currentProgress, duration, bufferPercent); diff --git a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java index 46ca3921d..08fdb9258 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -153,6 +153,8 @@ public abstract class BasePlayer implements public static final String START_PAUSED = "start_paused"; @NonNull public static final String SELECT_ON_APPEND = "select_on_append"; + @NonNull + public static final String IS_MUTED = "is_muted"; /*////////////////////////////////////////////////////////////////////////// // Playback @@ -275,6 +277,7 @@ public abstract class BasePlayer implements final float playbackPitch = intent.getFloatExtra(PLAYBACK_PITCH, getPlaybackPitch()); final boolean playbackSkipSilence = intent.getBooleanExtra(PLAYBACK_SKIP_SILENCE, getPlaybackSkipSilence()); + final boolean isMuted = intent.getBooleanExtra(IS_MUTED, simpleExoPlayer == null ? false : isMuted()); // seek to timestamp if stream is already playing if (simpleExoPlayer != null @@ -283,7 +286,7 @@ public abstract class BasePlayer implements && playQueue.getItem() != null && queue.getItem().getUrl().equals(playQueue.getItem().getUrl()) && queue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET - ) { + ) { simpleExoPlayer.seekTo(playQueue.getIndex(), queue.getItem().getRecoveryPosition()); return; @@ -293,7 +296,7 @@ public abstract class BasePlayer implements stateLoader = recordManager.loadStreamState(item) .observeOn(AndroidSchedulers.mainThread()) .doFinally(() -> initPlayback(queue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence, - /*playOnInit=*/true)) + /*playOnInit=*/true, isMuted)) .subscribe( state -> queue.setRecovery(queue.getIndex(), state.getProgressTime()), error -> { @@ -306,7 +309,7 @@ public abstract class BasePlayer implements } // Good to go... initPlayback(queue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence, - /*playOnInit=*/!intent.getBooleanExtra(START_PAUSED, false)); + /*playOnInit=*/!intent.getBooleanExtra(START_PAUSED, false), isMuted); } protected void initPlayback(@NonNull final PlayQueue queue, @@ -314,7 +317,8 @@ public abstract class BasePlayer implements final float playbackSpeed, final float playbackPitch, final boolean playbackSkipSilence, - final boolean playOnReady) { + final boolean playOnReady, + final boolean isMuted) { destroyPlayer(); initPlayer(playOnReady); setRepeatMode(repeatMode); @@ -327,6 +331,8 @@ public abstract class BasePlayer implements if (playQueueAdapter != null) playQueueAdapter.dispose(); playQueueAdapter = new PlayQueueAdapter(context, playQueue); + + simpleExoPlayer.setVolume(isMuted ? 0 : 1); } public void destroyPlayer() { @@ -532,6 +538,18 @@ public abstract class BasePlayer implements if (simpleExoPlayer == null) return; simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled()); } + /*////////////////////////////////////////////////////////////////////////// + // Mute / Unmute + //////////////////////////////////////////////////////////////////////////*/ + + public void onMuteUnmuteButtonClicked() { + if (DEBUG) Log.d(TAG, "onMuteUnmuteButtonClicled() called"); + simpleExoPlayer.setVolume(isMuted() ? 1 : 0); + } + + public boolean isMuted() { + return simpleExoPlayer.getVolume() == 0; + } /*////////////////////////////////////////////////////////////////////////// // Progress Updates diff --git a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java index f779c7bf9..42759a5ed 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java @@ -34,14 +34,17 @@ import android.os.Bundle; import android.os.Handler; import android.preference.PreferenceManager; import android.provider.Settings; + import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.ActivityCompat; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.ItemTouchHelper; + import android.util.DisplayMetrics; import android.util.Log; import android.util.TypedValue; @@ -116,7 +119,8 @@ public final class MainVideoPlayer extends AppCompatActivity private SharedPreferences defaultPreferences; - @Nullable private PlayerState playerState; + @Nullable + private PlayerState playerState; private boolean isInMultiWindow; private boolean isBackPressed; @@ -130,11 +134,13 @@ public final class MainVideoPlayer extends AppCompatActivity protected void onCreate(@Nullable Bundle savedInstanceState) { assureCorrectAppLanguage(this); super.onCreate(savedInstanceState); - if (DEBUG) Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); + if (DEBUG) + Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); defaultPreferences = PreferenceManager.getDefaultSharedPreferences(this); ThemeHelper.setTheme(this); getWindow().setBackgroundDrawable(new ColorDrawable(Color.BLACK)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) getWindow().setStatusBarColor(Color.BLACK); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) + getWindow().setStatusBarColor(Color.BLACK); setVolumeControlStream(AudioManager.STREAM_MUSIC); WindowManager.LayoutParams lp = getWindow().getAttributes(); @@ -143,7 +149,7 @@ public final class MainVideoPlayer extends AppCompatActivity hideSystemUi(); setContentView(R.layout.activity_main_player); - playerImpl = new VideoPlayerImpl(this); + playerImpl = new VideoPlayerImpl(this); playerImpl.setup(findViewById(android.R.id.content)); if (savedInstanceState != null && savedInstanceState.get(KEY_SAVED_STATE) != null) { @@ -220,7 +226,7 @@ public final class MainVideoPlayer extends AppCompatActivity playerImpl.setPlaybackQuality(playerState.getPlaybackQuality()); playerImpl.initPlayback(playerState.getPlayQueue(), playerState.getRepeatMode(), playerState.getPlaybackSpeed(), playerState.getPlaybackPitch(), - playerState.isPlaybackSkipSilence(), playerState.wasPlaying()); + playerState.isPlaybackSkipSilence(), playerState.wasPlaying(), playerImpl.isMuted()); } } @@ -248,7 +254,7 @@ public final class MainVideoPlayer extends AppCompatActivity if (playerImpl == null) return; playerImpl.setRecovery(); - if(!playerImpl.gotDestroyed()) { + if (!playerImpl.gotDestroyed()) { playerState = createPlayerState(); } StateSaver.tryToSave(isChangingConfigurations(), null, outState, this); @@ -396,6 +402,12 @@ public final class MainVideoPlayer extends AppCompatActivity shuffleButton.setImageAlpha(shuffleAlpha); } + protected void setMuteButton(final ImageButton muteButton, final boolean isMuted) { + muteButton.setImageDrawable(AppCompatResources.getDrawable(getApplicationContext(), + isMuted ? R.drawable.ic_volume_off_white_72dp : R.drawable.ic_volume_up_white_72dp)); + } + + private boolean isInMultiWindow() { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode(); } @@ -448,6 +460,7 @@ public final class MainVideoPlayer extends AppCompatActivity private ImageButton toggleOrientationButton; private ImageButton switchPopupButton; private ImageButton switchBackgroundButton; + private ImageButton muteButton; private RelativeLayout windowRootLayout; private View secondaryControls; @@ -484,6 +497,7 @@ public final class MainVideoPlayer extends AppCompatActivity this.shareButton = rootView.findViewById(R.id.share); this.toggleOrientationButton = rootView.findViewById(R.id.toggleOrientation); this.switchBackgroundButton = rootView.findViewById(R.id.switchBackground); + this.muteButton = rootView.findViewById(R.id.switchMute); this.switchPopupButton = rootView.findViewById(R.id.switchPopup); this.queueLayout = findViewById(R.id.playQueuePanel); @@ -493,7 +507,7 @@ public final class MainVideoPlayer extends AppCompatActivity titleTextView.setSelected(true); channelTextView.setSelected(true); boolean showKodiButton = PreferenceManager.getDefaultSharedPreferences(this.context).getBoolean( - this.context.getString(R.string.show_play_with_kodi_key), false); + this.context.getString(R.string.show_play_with_kodi_key), false); kodiButton.setVisibility(showKodiButton ? View.VISIBLE : View.GONE); getRootView().setKeepScreenOn(true); @@ -535,6 +549,7 @@ public final class MainVideoPlayer extends AppCompatActivity shareButton.setOnClickListener(this); toggleOrientationButton.setOnClickListener(this); switchBackgroundButton.setOnClickListener(this); + muteButton.setOnClickListener(this); switchPopupButton.setOnClickListener(this); getRootView().addOnLayoutChangeListener((view, l, t, r, b, ol, ot, or, ob) -> { @@ -653,7 +668,8 @@ public final class MainVideoPlayer extends AppCompatActivity this.getPlaybackSkipSilence(), this.getPlaybackQuality(), false, - !isPlaying() + !isPlaying(), + isMuted() ); context.startService(intent); @@ -677,7 +693,8 @@ public final class MainVideoPlayer extends AppCompatActivity this.getPlaybackSkipSilence(), this.getPlaybackQuality(), false, - !isPlaying() + !isPlaying(), + isMuted() ); context.startService(intent); @@ -686,6 +703,12 @@ public final class MainVideoPlayer extends AppCompatActivity finish(); } + @Override + public void onMuteUnmuteButtonClicked() { + super.onMuteUnmuteButtonClicked(); + setMuteButton(muteButton, playerImpl.isMuted()); + } + @Override public void onClick(View v) { @@ -723,11 +746,14 @@ public final class MainVideoPlayer extends AppCompatActivity } else if (v.getId() == switchBackgroundButton.getId()) { onPlayBackgroundButtonClicked(); + } else if (v.getId() == muteButton.getId()) { + onMuteUnmuteButtonClicked(); + } else if (v.getId() == closeButton.getId()) { onPlaybackShutdown(); return; } else if (v.getId() == kodiButton.getId()) { - onKodiShare(); + onKodiShare(); } if (getCurrentState() != STATE_COMPLETED) { @@ -770,13 +796,14 @@ public final class MainVideoPlayer extends AppCompatActivity animateView(secondaryControls, SLIDE_AND_ALPHA, !isMoreControlsVisible, DEFAULT_CONTROLS_DURATION); showControls(DEFAULT_CONTROLS_DURATION); + setMuteButton(muteButton, playerImpl.isMuted()); } private void onShareClicked() { // share video at the current time (youtube.com/watch?v=ID&t=SECONDS) ShareUtils.shareUrl(MainVideoPlayer.this, playerImpl.getVideoTitle(), - playerImpl.getVideoUrl() + "&t=" + String.valueOf(playerImpl.getPlaybackSeekBar().getProgress()/1000)); + playerImpl.getVideoUrl() + "&t=" + String.valueOf(playerImpl.getPlaybackSeekBar().getProgress() / 1000)); } private void onScreenRotationClicked() { @@ -1009,7 +1036,7 @@ public final class MainVideoPlayer extends AppCompatActivity @Override public void onSwiped(int index) { - if(index != -1) playQueue.remove(index); + if (index != -1) playQueue.remove(index); } }; } @@ -1074,6 +1101,10 @@ public final class MainVideoPlayer extends AppCompatActivity return repeatButton; } + public ImageButton getMuteButton() { + return muteButton; + } + public ImageButton getPlayPauseButton() { return playPauseButton; } @@ -1088,7 +1119,8 @@ public final class MainVideoPlayer extends AppCompatActivity @Override public boolean onDoubleTap(MotionEvent e) { - if (DEBUG) Log.d(TAG, "onDoubleTap() called with: e = [" + e + "]" + "rawXy = " + e.getRawX() + ", " + e.getRawY() + ", xy = " + e.getX() + ", " + e.getY()); + if (DEBUG) + Log.d(TAG, "onDoubleTap() called with: e = [" + e + "]" + "rawXy = " + e.getRawX() + ", " + e.getRawY() + ", xy = " + e.getX() + ", " + e.getY()); if (e.getX() > playerImpl.getRootView().getWidth() * 2 / 3) { playerImpl.onFastForward(); @@ -1184,7 +1216,8 @@ public final class MainVideoPlayer extends AppCompatActivity layoutParams.screenBrightness = currentProgressPercent; getWindow().setAttributes(layoutParams); - if (DEBUG) Log.d(TAG, "onScroll().brightnessControl, currentBrightness = " + currentProgressPercent); + if (DEBUG) + Log.d(TAG, "onScroll().brightnessControl, currentBrightness = " + currentProgressPercent); final int resId = currentProgressPercent < 0.25 ? R.drawable.ic_brightness_low_white_72dp @@ -1223,7 +1256,8 @@ public final class MainVideoPlayer extends AppCompatActivity @Override public boolean onTouch(View v, MotionEvent event) { //noinspection PointlessBooleanExpression - if (DEBUG && false) Log.d(TAG, "onTouch() called with: v = [" + v + "], event = [" + event + "]"); + if (DEBUG && false) + Log.d(TAG, "onTouch() called with: v = [" + v + "], event = [" + event + "]"); gestureDetector.onTouchEvent(event); if (event.getAction() == MotionEvent.ACTION_UP && isMoving) { isMoving = false; diff --git a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java index fc14e8d51..b7638eda7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java @@ -571,7 +571,8 @@ public final class PopupVideoPlayer extends Service { this.getPlaybackSkipSilence(), this.getPlaybackQuality(), false, - !isPlaying() + !isPlaying(), + isMuted() ); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); @@ -607,6 +608,12 @@ public final class PopupVideoPlayer extends Service { updatePlayback(); } + @Override + public void onMuteUnmuteButtonClicked() { + super.onMuteUnmuteButtonClicked(); + updatePlayback(); + } + @Override public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) { updateProgress(currentProgress, duration, bufferPercent); diff --git a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java index 7aa2be05d..113592b47 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java @@ -3,14 +3,17 @@ package org.schabi.newpipe.player; import android.content.ComponentName; import android.content.Intent; import android.content.ServiceConnection; +import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.IBinder; import android.provider.Settings; + import androidx.appcompat.app.AppCompatActivity; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.appcompat.widget.Toolbar; import androidx.recyclerview.widget.ItemTouchHelper; + import android.util.Log; import android.view.Menu; import android.view.MenuItem; @@ -92,6 +95,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity private TextView playbackSpeedButton; private TextView playbackPitchButton; + private Menu menu; + //////////////////////////////////////////////////////////////////////////// // Abstracts //////////////////////////////////////////////////////////////////////////// @@ -145,8 +150,10 @@ public abstract class ServicePlayerActivity extends AppCompatActivity @Override public boolean onCreateOptionsMenu(Menu menu) { + this.menu = menu; getMenuInflater().inflate(R.menu.menu_play_queue, menu); getMenuInflater().inflate(getPlayerOptionMenuResource(), menu); + onMaybeMuteChanged(); return true; } @@ -162,6 +169,9 @@ public abstract class ServicePlayerActivity extends AppCompatActivity case R.id.action_append_playlist: appendAllToPlaylist(); return true; + case R.id.action_mute: + player.onMuteUnmuteButtonClicked(); + return true; case R.id.action_system_audio: startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS)); return true; @@ -169,8 +179,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity this.player.setRecovery(); getApplicationContext().sendBroadcast(getPlayerShutdownIntent()); getApplicationContext().startActivity( - getSwitchIntent(MainVideoPlayer.class) - .putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying()) + getSwitchIntent(MainVideoPlayer.class) + .putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying()) ); return true; } @@ -194,7 +204,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity this.player.getPlaybackSkipSilence(), null, false, - false + false, + this.player.isMuted() ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying()); } @@ -212,7 +223,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity } private void unbind() { - if(serviceBound) { + if (serviceBound) { unbindService(serviceConnection); serviceBound = false; stopPlayerListener(); @@ -554,6 +565,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity onPlayModeChanged(repeatMode, shuffled); onPlaybackParameterChanged(parameters); onMaybePlaybackAdapterChanged(); + onMaybeMuteChanged(); } @Override @@ -676,4 +688,23 @@ public abstract class ServicePlayerActivity extends AppCompatActivity itemsList.setAdapter(maybeNewAdapter); } } + + private void onMaybeMuteChanged() { + if (menu != null && player != null) { + MenuItem item = menu.findItem(R.id.action_mute); + + //Change the mute-button item in ActionBar + //1) Text change: + item.setTitle(player.isMuted() ? R.string.unmute : R.string.mute); + + //2) Icon change accordingly to current App Theme + item.setIcon(player.isMuted() ? getThemedDrawable(R.attr.volume_off) : getThemedDrawable(R.attr.volume_on)); + } + } + + private Drawable getThemedDrawable(int attribute) { + return getResources().getDrawable( + getTheme().obtainStyledAttributes(R.style.Theme_AppCompat, new int[]{attribute}) + .getResourceId(0, 0)); + } } diff --git a/app/src/main/java/org/schabi/newpipe/report/UserAction.java b/app/src/main/java/org/schabi/newpipe/report/UserAction.java index 2cca9305a..f4f3e31b6 100644 --- a/app/src/main/java/org/schabi/newpipe/report/UserAction.java +++ b/app/src/main/java/org/schabi/newpipe/report/UserAction.java @@ -16,6 +16,7 @@ public enum UserAction { REQUESTED_PLAYLIST("requested playlist"), REQUESTED_KIOSK("requested kiosk"), REQUESTED_COMMENTS("requested comments"), + REQUESTED_FEED("requested feed"), DELETE_FROM_HISTORY("delete from history"), PLAY_STREAM("Play stream"), DOWNLOAD_POSTPROCESSING("download post-processing"), diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java index 03d48ca5b..0be72d0eb 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java @@ -17,6 +17,7 @@ import androidx.preference.Preference; import com.nononsenseapps.filepicker.Utils; import com.nostra13.universalimageloader.core.ImageLoader; +import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.localization.ContentCountry; @@ -168,6 +169,9 @@ public class ContentSettingsFragment extends BasePreferenceFragment { private void exportDatabase(String path) { try { + //checkpoint before export + NewPipeDatabase.checkpoint(); + ZipOutputStream outZip = new ZipOutputStream( new BufferedOutputStream( new FileOutputStream(path))); diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java index e0003ccaa..6c765dc3d 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -23,7 +23,7 @@ package org.schabi.newpipe.settings; import android.content.Context; import android.content.SharedPreferences; import android.os.Environment; -import android.preference.PreferenceManager; +import androidx.preference.PreferenceManager; import androidx.annotation.NonNull; import org.schabi.newpipe.R; diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java index 7064aec33..9ee12facc 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java @@ -18,9 +18,9 @@ import com.nostra13.universalimageloader.core.ImageLoader; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; +import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; -import org.schabi.newpipe.local.subscription.SubscriptionService; import java.util.List; import java.util.Vector; @@ -99,8 +99,8 @@ public class SelectChannelFragment extends DialogFragment { emptyView.setVisibility(View.GONE); - SubscriptionService subscriptionService = SubscriptionService.getInstance(getContext()); - subscriptionService.getSubscription().toObservable() + SubscriptionManager subscriptionManager = new SubscriptionManager(getContext()); + subscriptionManager.subscriptions().toObservable() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(getSubscriptionObserver()); diff --git a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java index d5f46fb22..383cf7f74 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java @@ -6,11 +6,15 @@ import android.os.Build; import android.os.Bundle; import android.provider.Settings; +import android.text.format.DateUtils; +import android.widget.Toast; import androidx.annotation.Nullable; import androidx.preference.ListPreference; import com.google.android.material.snackbar.Snackbar; +import java.util.LinkedList; +import java.util.List; import org.schabi.newpipe.R; import org.schabi.newpipe.util.PermissionHelper; @@ -22,23 +26,7 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment { public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - //initializing R.array.seek_duration_description to display the translation of seconds - Resources res = getResources(); - String[] durationsValues = res.getStringArray(R.array.seek_duration_value); - String[] durationsDescriptions = res.getStringArray(R.array.seek_duration_description); - int currentDurationValue; - for (int i = 0; i < durationsDescriptions.length; i++) { - currentDurationValue = Integer.parseInt(durationsValues[i]) / 1000; - try { - durationsDescriptions[i] = String.format( - res.getQuantityString(R.plurals.dynamic_seek_duration_description, currentDurationValue), - currentDurationValue); - } catch (Resources.NotFoundException ignored) { - //if this happens, the translation is missing, and the english string will be displayed instead - } - } - ListPreference durations = (ListPreference) findPreference(getString(R.string.seek_duration_key)); - durations.setEntries(durationsDescriptions); + updateSeekOptions(); listener = (sharedPreferences, s) -> { @@ -58,10 +46,59 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment { .show(); } + } else if (s.equals(getString(R.string.use_inexact_seek_key))) { + updateSeekOptions(); } }; } + /** + * Update fast-forward/-rewind seek duration options according to language and inexact seek setting. + * Exoplayer can't seek 5 seconds in audio when using inexact seek. + */ + private void updateSeekOptions() { + //initializing R.array.seek_duration_description to display the translation of seconds + final Resources res = getResources(); + final String[] durationsValues = res.getStringArray(R.array.seek_duration_value); + final List displayedDurationValues = new LinkedList<>(); + final List displayedDescriptionValues = new LinkedList<>(); + int currentDurationValue; + final boolean inexactSeek = getPreferenceManager().getSharedPreferences() + .getBoolean(res.getString(R.string.use_inexact_seek_key), false); + + for (String durationsValue : durationsValues) { + currentDurationValue = + Integer.parseInt(durationsValue) / (int) DateUtils.SECOND_IN_MILLIS; + if (inexactSeek && currentDurationValue % 10 == 5) { + continue; + } + + displayedDurationValues.add(durationsValue); + try { + displayedDescriptionValues.add(String.format( + res.getQuantityString(R.plurals.seconds, + currentDurationValue), + currentDurationValue)); + } catch (Resources.NotFoundException ignored) { + //if this happens, the translation is missing, and the english string will be displayed instead + } + } + + final ListPreference durations = (ListPreference) findPreference(getString(R.string.seek_duration_key)); + durations.setEntryValues(displayedDurationValues.toArray(new CharSequence[0])); + durations.setEntries(displayedDescriptionValues.toArray(new CharSequence[0])); + final int selectedDuration = Integer.parseInt(durations.getValue()); + if (inexactSeek && selectedDuration / (int) DateUtils.SECOND_IN_MILLIS % 10 == 5) { + final int newDuration = selectedDuration / (int) DateUtils.SECOND_IN_MILLIS + 5; + durations.setValue(Integer.toString(newDuration * (int) DateUtils.SECOND_IN_MILLIS)); + + Toast toast = Toast + .makeText(getContext(), + getString(R.string.new_seek_duration_toast, newDuration), + Toast.LENGTH_LONG); + toast.show(); + } + } @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt b/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt new file mode 100644 index 000000000..4bc59fcee --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt @@ -0,0 +1,46 @@ +package org.schabi.newpipe.settings.custom + +import android.content.Context +import android.util.AttributeSet +import androidx.preference.ListPreference +import org.schabi.newpipe.util.Localization + +/** + * An extension of a common ListPreference where it sets the duration values to human readable strings. + * + * The values in the entry values array will be interpreted as seconds. If the value of a specific position + * is less than or equals to zero, its original entry title will be used. + * + * If the entry values array have anything other than numbers in it, an exception will be raised. + */ +class DurationListPreference : ListPreference { + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?) : super(context) + + override fun onAttached() { + super.onAttached() + + val originalEntryTitles = entries + val originalEntryValues = entryValues + val newEntryTitles = arrayOfNulls(originalEntryValues.size) + + for (i in originalEntryValues.indices) { + val currentDurationValue: Int + try { + currentDurationValue = (originalEntryValues[i] as String).toInt() + } catch (e: NumberFormatException) { + throw RuntimeException("Invalid number was set in the preference entry values array", e) + } + + if (currentDurationValue <= 0) { + newEntryTitles[i] = originalEntryTitles[i] + } else { + newEntryTitles[i] = Localization.localizeDuration(context, currentDurationValue) + } + } + + entries = newEntryTitles + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java index cba3c4534..cc40298b9 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java @@ -218,7 +218,7 @@ public abstract class Tab { @Override public String getTabName(Context context) { - return context.getString(R.string.fragment_whats_new); + return context.getString(R.string.fragment_feed_title); } @DrawableRes diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java index 42875c364..b1628d954 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java @@ -37,6 +37,7 @@ public class WebMReader { private final static int ID_DefaultDuration = 0x3E383; private final static int ID_FlagLacing = 0x1C; private final static int ID_CodecDelay = 0x16AA; + private final static int ID_SeekPreRoll = 0x16BB; private final static int ID_Cluster = 0x0F43B675; private final static int ID_Timecode = 0x67; @@ -332,6 +333,10 @@ public class WebMReader { break; case ID_CodecDelay: entry.codecDelay = readNumber(elem); + break; + case ID_SeekPreRoll: + entry.seekPreRoll = readNumber(elem); + break; default: break; } @@ -414,8 +419,9 @@ public class WebMReader { public byte[] codecPrivate; public byte[] bMetadata; public TrackKind kind; - public long defaultDuration; - public long codecDelay; + public long defaultDuration = -1; + public long codecDelay = -1; + public long seekPreRoll = -1; } public class Segment { diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java index 8525fabd2..39db33ad0 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java @@ -23,7 +23,10 @@ public class WebMWriter implements Closeable { private final static int BUFFER_SIZE = 8 * 1024; private final static int DEFAULT_TIMECODE_SCALE = 1000000; private final static int INTERV = 100;// 100ms on 1000000us timecode scale - private final static int DEFAULT_CUES_EACH_MS = 5000;// 100ms on 1000000us timecode scale + private final static int DEFAULT_CUES_EACH_MS = 5000;// 5000ms on 1000000us timecode scale + private final static byte CLUSTER_HEADER_SIZE = 8; + private final static int CUE_RESERVE_SIZE = 65535; + private final static byte MINIMUM_EBML_VOID_SIZE = 4; private WebMReader.WebMTrack[] infoTracks; private SharpStream[] sourceTracks; @@ -38,15 +41,18 @@ public class WebMWriter implements Closeable { private Segment[] readersSegment; private Cluster[] readersCluster; - private int[] predefinedDurations; + private ArrayList clustersOffsetsSizes; private byte[] outBuffer; + private ByteBuffer outByteBuffer; public WebMWriter(SharpStream... source) { sourceTracks = source; readers = new WebMReader[sourceTracks.length]; infoTracks = new WebMTrack[sourceTracks.length]; outBuffer = new byte[BUFFER_SIZE]; + outByteBuffer = ByteBuffer.wrap(outBuffer); + clustersOffsetsSizes = new ArrayList<>(256); } public WebMTrack[] getTracksFromSource(int sourceIndex) throws IllegalStateException { @@ -83,11 +89,9 @@ public class WebMWriter implements Closeable { try { readersSegment = new Segment[readers.length]; readersCluster = new Cluster[readers.length]; - predefinedDurations = new int[readers.length]; for (int i = 0; i < readers.length; i++) { infoTracks[i] = readers[i].selectTrack(trackIndex[i]); - predefinedDurations[i] = -1; readersSegment[i] = readers[i].getNextSegment(); } } finally { @@ -118,6 +122,8 @@ public class WebMWriter implements Closeable { readersSegment = null; readersCluster = null; outBuffer = null; + outByteBuffer = null; + clustersOffsetsSizes = null; } public void build(SharpStream out) throws IOException, RuntimeException { @@ -140,7 +146,7 @@ public class WebMWriter implements Closeable { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00// segment content size }); - long baseSegmentOffset = written + listBuffer.get(0).length; + long segmentOffset = written + listBuffer.get(0).length; /* seek head */ listBuffer.add(new byte[]{ @@ -177,20 +183,22 @@ public class WebMWriter implements Closeable { /* tracks */ listBuffer.addAll(makeTracks()); - for (byte[] buff : listBuffer) { - dump(buff, out); - } + dump(listBuffer, out); - // reserve space for Cues element, but is a waste of space (actually is 64 KiB) - // TODO: better Cue maker - long cueReservedOffset = written; - dump(new byte[]{(byte) 0xec, 0x20, (byte) 0xff, (byte) 0xfb}, out); - int reserved = (1024 * 63) - 4; - while (reserved > 0) { - int write = Math.min(reserved, outBuffer.length); - out.write(outBuffer, 0, write); - reserved -= write; - written += write; + // reserve space for Cues element + long cueOffset = written; + make_EBML_void(out, CUE_RESERVE_SIZE, true); + + int[] defaultSampleDuration = new int[infoTracks.length]; + long[] duration = new long[infoTracks.length]; + + for (int i = 0; i < infoTracks.length; i++) { + if (infoTracks[i].defaultDuration < 0) { + defaultSampleDuration[i] = -1;// not available + } else { + defaultSampleDuration[i] = (int) Math.ceil(infoTracks[i].defaultDuration / (float) DEFAULT_TIMECODE_SCALE); + } + duration[i] = -1; } // Select a track for the cue @@ -198,16 +206,8 @@ public class WebMWriter implements Closeable { long nextCueTime = infoTracks[cuesForTrackId].trackType == 1 ? -1 : 0; ArrayList keyFrames = new ArrayList<>(32); - ArrayList clusterOffsets = new ArrayList<>(32); - ArrayList clusterSizes = new ArrayList<>(32); - - long duration = 0; - int durationFromTrackId = 0; - - byte[] bTimecode = makeTimecode(0); - int firstClusterOffset = (int) written; - long currentClusterOffset = makeCluster(out, bTimecode, 0, clusterOffsets, clusterSizes); + long currentClusterOffset = makeCluster(out, 0, 0, true); long baseTimecode = 0; long limitTimecode = -1; @@ -239,8 +239,7 @@ public class WebMWriter implements Closeable { newClusterByTrackId = -1; baseTimecode = bloq.absoluteTimecode; limitTimecode = baseTimecode + INTERV; - bTimecode = makeTimecode(baseTimecode); - currentClusterOffset = makeCluster(out, bTimecode, currentClusterOffset, clusterOffsets, clusterSizes); + currentClusterOffset = makeCluster(out, baseTimecode, currentClusterOffset, true); } if (cuesForTrackId == i) { @@ -248,19 +247,18 @@ public class WebMWriter implements Closeable { if (nextCueTime > -1) { nextCueTime += DEFAULT_CUES_EACH_MS; } - keyFrames.add( - new KeyFrame(baseSegmentOffset, currentClusterOffset - 8, written, bTimecode.length, bloq.absoluteTimecode) - ); + keyFrames.add(new KeyFrame(segmentOffset, currentClusterOffset, written, bloq.absoluteTimecode)); } } writeBlock(out, bloq, baseTimecode); blockWritten++; - if (bloq.absoluteTimecode > duration) { - duration = bloq.absoluteTimecode; - durationFromTrackId = bloq.trackNumber; + if (defaultSampleDuration[i] < 0 && duration[i] >= 0) { + // if the sample duration in unknown, calculate using current_duration - previous_duration + defaultSampleDuration[i] = (int) (bloq.absoluteTimecode - duration[i]); } + duration[i] = bloq.absoluteTimecode; if (limitTimecode < 0) { limitTimecode = bloq.absoluteTimecode + INTERV; @@ -276,55 +274,61 @@ public class WebMWriter implements Closeable { } } - makeCluster(out, null, currentClusterOffset, null, clusterSizes); + makeCluster(out, -1, currentClusterOffset, false); long segmentSize = written - offsetSegmentSizeSet - 7; - /* ---- final step write offsets and sizes ---- */ + /* Segment size */ seekTo(out, offsetSegmentSizeSet); - writeLong(out, segmentSize); + outByteBuffer.putLong(0, segmentSize); + out.write(outBuffer, 1, DataReader.LONG_SIZE - 1); - if (predefinedDurations[durationFromTrackId] > -1) { - duration += predefinedDurations[durationFromTrackId];// this value is full-filled in makeTrackEntry() method - } - seekTo(out, offsetInfoDurationSet); - writeFloat(out, duration); - - firstClusterOffset -= baseSegmentOffset; - seekTo(out, offsetClusterSet); - writeInt(out, firstClusterOffset); - - seekTo(out, cueReservedOffset); - - /* Cue */ - dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out); - - for (KeyFrame keyFrame : keyFrames) { - for (byte[] buffer : makeCuePoint(cuesForTrackId, keyFrame)) { - dump(buffer, out); - if (written >= (cueReservedOffset + 65535 - 16)) { - throw new IOException("Too many Cues"); - } + /* Segment duration */ + long longestDuration = 0; + for (int i = 0; i < duration.length; i++) { + if (defaultSampleDuration[i] > 0) { + duration[i] += defaultSampleDuration[i]; + } + if (duration[i] > longestDuration) { + longestDuration = duration[i]; } } - short cueSize = (short) (written - cueReservedOffset - 7); + seekTo(out, offsetInfoDurationSet); + outByteBuffer.putFloat(0, longestDuration); + dump(outBuffer, DataReader.FLOAT_SIZE, out); - /* EBML Void */ - ByteBuffer voidBuffer = ByteBuffer.allocate(4); - voidBuffer.putShort((short) 0xec20); - voidBuffer.putShort((short) (firstClusterOffset - written - 4)); - dump(voidBuffer.array(), out); + /* first Cluster offset */ + firstClusterOffset -= segmentOffset; + writeInt(out, offsetClusterSet, firstClusterOffset); - seekTo(out, offsetCuesSet); - writeInt(out, (int) (cueReservedOffset - baseSegmentOffset)); + seekTo(out, cueOffset); - seekTo(out, cueReservedOffset + 5); - writeShort(out, cueSize); + /* Cue */ + short cueSize = 0; + dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out);// header size is 7 - for (int i = 0; i < clusterSizes.size(); i++) { - seekTo(out, clusterOffsets.get(i)); - byte[] buffer = ByteBuffer.allocate(4).putInt(clusterSizes.get(i) | 0x10000000).array(); - dump(buffer, out); + for (KeyFrame keyFrame : keyFrames) { + int size = makeCuePoint(cuesForTrackId, keyFrame, outBuffer); + + if ((cueSize + size + 7 + MINIMUM_EBML_VOID_SIZE) > CUE_RESERVE_SIZE) { + break;// no space left + } + + cueSize += size; + dump(outBuffer, size, out); + } + + make_EBML_void(out, CUE_RESERVE_SIZE - cueSize - 7, false); + + seekTo(out, cueOffset + 5); + outByteBuffer.putShort(0, cueSize); + dump(outBuffer, DataReader.SHORT_SIZE, out); + + /* seek head, seek for cues element */ + writeInt(out, offsetCuesSet, (int) (cueOffset - segmentOffset)); + + for (ClusterInfo cluster : clustersOffsetsSizes) { + writeInt(out, cluster.offset, cluster.size | 0x10000000); } } @@ -375,25 +379,10 @@ public class WebMWriter implements Closeable { written = offset; } - private void writeLong(SharpStream stream, long number) throws IOException { - byte[] buffer = ByteBuffer.allocate(DataReader.LONG_SIZE).putLong(number).array(); - stream.write(buffer, 1, buffer.length - 1); - written += buffer.length - 1; - } - - private void writeFloat(SharpStream stream, float number) throws IOException { - byte[] buffer = ByteBuffer.allocate(DataReader.FLOAT_SIZE).putFloat(number).array(); - dump(buffer, stream); - } - - private void writeShort(SharpStream stream, short number) throws IOException { - byte[] buffer = ByteBuffer.allocate(DataReader.SHORT_SIZE).putShort(number).array(); - dump(buffer, stream); - } - - private void writeInt(SharpStream stream, int number) throws IOException { - byte[] buffer = ByteBuffer.allocate(DataReader.INTEGER_SIZE).putInt(number).array(); - dump(buffer, stream); + private void writeInt(SharpStream stream, long offset, int number) throws IOException { + seekTo(stream, offset); + outByteBuffer.putInt(0, number); + dump(outBuffer, DataReader.INTEGER_SIZE, stream); } private void writeBlock(SharpStream stream, Block bloq, long clusterTimecode) throws IOException { @@ -416,47 +405,43 @@ public class WebMWriter implements Closeable { } listBuffer.set(1, encode(blockSize, false)); - for (byte[] buff : listBuffer) { - dump(buff, stream); - } + dump(listBuffer, stream); int read; while ((read = bloq.data.read(outBuffer)) > 0) { - stream.write(outBuffer, 0, read); - written += read; + dump(outBuffer, read, stream); } } - private byte[] makeTimecode(long timecode) { - ByteBuffer buffer = ByteBuffer.allocate(9); - buffer.put((byte) 0xe7); - buffer.put(encode(timecode, true)); + private long makeCluster(SharpStream stream, long timecode, long offset, boolean create) throws IOException { + ClusterInfo cluster; - byte[] res = new byte[buffer.position()]; - System.arraycopy(buffer.array(), 0, res, 0, res.length); - - return res; - } - - private long makeCluster(SharpStream stream, byte[] bTimecode, long startOffset, ArrayList clusterOffsets, ArrayList clusterSizes) throws IOException { - if (startOffset > 0) { - clusterSizes.add((int) (written - startOffset));// size for last offset + if (offset > 0) { + // save the size of the previous cluster (maximum 256 MiB) + cluster = clustersOffsetsSizes.get(clustersOffsetsSizes.size() - 1); + cluster.size = (int) (written - offset - CLUSTER_HEADER_SIZE); } - if (clusterOffsets != null) { + offset = written; + + if (create) { /* cluster */ dump(new byte[]{0x1f, 0x43, (byte) 0xb6, 0x75}, stream); - clusterOffsets.add(written);// warning: max cluster size is 256 MiB - dump(new byte[]{0x10, 0x00, 0x00, 0x00}, stream); - startOffset = written;// size for the this cluster + cluster = new ClusterInfo(); + cluster.offset = written; + clustersOffsetsSizes.add(cluster); - dump(bTimecode, stream); + dump(new byte[]{ + 0x10, 0x00, 0x00, 0x00, + /* timestamp */ + (byte) 0xe7 + }, stream); - return startOffset; + dump(encode(timecode, true), stream); } - return -1; + return offset; } private void makeEBML(SharpStream stream) throws IOException { @@ -509,13 +494,24 @@ public class WebMWriter implements Closeable { buffer.add(new byte[]{(byte) 0x86}); buffer.addAll(encode(track.codecId)); + /* codec delay*/ + if (track.codecDelay >= 0) { + buffer.add(new byte[]{0x56, (byte) 0xAA}); + buffer.add(encode(track.codecDelay, true)); + } + + /* codec seek pre-roll*/ + if (track.seekPreRoll >= 0) { + buffer.add(new byte[]{0x56, (byte) 0xBB}); + buffer.add(encode(track.seekPreRoll, true)); + } + /* type */ buffer.add(new byte[]{(byte) 0x83}); buffer.add(encode(track.trackType, true)); /* default duration */ - if (track.defaultDuration != 0) { - predefinedDurations[internalTrackId] = (int) Math.ceil(track.defaultDuration / (float) DEFAULT_TIMECODE_SCALE); + if (track.defaultDuration >= 0) { buffer.add(new byte[]{0x23, (byte) 0xe3, (byte) 0x83}); buffer.add(encode(track.defaultDuration, true)); } @@ -538,21 +534,29 @@ public class WebMWriter implements Closeable { } - private ArrayList makeCuePoint(int internalTrackId, KeyFrame keyFrame) { - ArrayList buffer = new ArrayList<>(5); + private int makeCuePoint(int internalTrackId, KeyFrame keyFrame, byte[] buffer) { + ArrayList cue = new ArrayList<>(5); /* CuePoint */ - buffer.add(new byte[]{(byte) 0xbb}); - buffer.add(null); + cue.add(new byte[]{(byte) 0xbb}); + cue.add(null); /* CueTime */ - buffer.add(new byte[]{(byte) 0xb3}); - buffer.add(encode(keyFrame.atTimecode, true)); + cue.add(new byte[]{(byte) 0xb3}); + cue.add(encode(keyFrame.duration, true)); /* CueTrackPosition */ - buffer.addAll(makeCueTrackPosition(internalTrackId, keyFrame)); + cue.addAll(makeCueTrackPosition(internalTrackId, keyFrame)); - return lengthFor(buffer); + int size = 0; + lengthFor(cue); + + for (byte[] buff : cue) { + System.arraycopy(buff, 0, buffer, size, buff.length); + size += buff.length; + } + + return size; } private ArrayList makeCueTrackPosition(int internalTrackId, KeyFrame keyFrame) { @@ -568,20 +572,48 @@ public class WebMWriter implements Closeable { /* CueClusterPosition */ buffer.add(new byte[]{(byte) 0xf1}); - buffer.add(encode(keyFrame.atCluster, true)); + buffer.add(encode(keyFrame.clusterPosition, true)); /* CueRelativePosition */ - if (keyFrame.atBlock > 0) { + if (keyFrame.relativePosition > 0) { buffer.add(new byte[]{(byte) 0xf0}); - buffer.add(encode(keyFrame.atBlock, true)); + buffer.add(encode(keyFrame.relativePosition, true)); } return lengthFor(buffer); } + private void make_EBML_void(SharpStream out, int size, boolean wipe) throws IOException { + /* ebml void */ + outByteBuffer.putShort(0, (short) 0xec20); + outByteBuffer.putShort(2, (short) (size - 4)); + + dump(outBuffer, 4, out); + + if (wipe) { + size -= 4; + while (size > 0) { + int write = Math.min(size, outBuffer.length); + dump(outBuffer, write, out); + size -= write; + } + } + } + private void dump(byte[] buffer, SharpStream stream) throws IOException { - stream.write(buffer); - written += buffer.length; + dump(buffer, buffer.length, stream); + } + + private void dump(byte[] buffer, int count, SharpStream stream) throws IOException { + stream.write(buffer, 0, count); + written += count; + } + + private void dump(ArrayList buffers, SharpStream stream) throws IOException { + for (byte[] buffer : buffers) { + stream.write(buffer); + written += buffer.length; + } } private ArrayList lengthFor(ArrayList buffer) { @@ -614,11 +646,11 @@ public class WebMWriter implements Closeable { byte[] buffer = new byte[offset + length]; long marker = (long) Math.floor((length - 1f) / 8f); - float mul = 1; - for (int i = length - 1; i >= 0; i--, mul *= 0x100) { - long b = (long) Math.floor(number / mul); + int shift = 0; + for (int i = length - 1; i >= 0; i--, shift += 8) { + long b = number >>> shift; if (!withLength && i == marker) { - b = b | (0x80 >> (length - 1)); + b = b | (0x80 >>> (length - 1)); } buffer[offset + i] = (byte) b; } @@ -686,17 +718,15 @@ public class WebMWriter implements Closeable { class KeyFrame { - KeyFrame(long segment, long cluster, long block, int bTimecodeLength, long timecode) { - atCluster = cluster - segment; - if ((block - bTimecodeLength) > cluster) { - atBlock = (int) (block - cluster); - } - atTimecode = timecode; + KeyFrame(long segment, long cluster, long block, long timecode) { + clusterPosition = cluster - segment; + relativePosition = (int) (block - cluster - CLUSTER_HEADER_SIZE); + duration = timecode; } - long atCluster; - int atBlock; - long atTimecode; + final long clusterPosition; + final int relativePosition; + final long duration; } class Block { @@ -717,4 +747,11 @@ public class WebMWriter implements Closeable { return String.format("trackNumber=%s isKeyFrame=%S absoluteTimecode=%s", trackNumber, isKeyframe(), absoluteTimecode); } } + + class ClusterInfo { + + long offset; + int size; + } + } diff --git a/app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt b/app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt new file mode 100644 index 000000000..8d24cb04e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt @@ -0,0 +1,6 @@ +package org.schabi.newpipe.util + +/** + * Default duration when using throttle functions across the app, in milliseconds. + */ +const val DEFAULT_THROTTLE_TIMEOUT = 120L diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index 0cebe5af3..cf4477223 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -31,18 +31,23 @@ import org.schabi.newpipe.ReCaptchaActivity; import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; +import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor; +import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.comments.CommentsInfo; import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; import org.schabi.newpipe.extractor.exceptions.ParsingException; import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; +import org.schabi.newpipe.extractor.feed.FeedExtractor; +import org.schabi.newpipe.extractor.feed.FeedInfo; import org.schabi.newpipe.extractor.kiosk.KioskInfo; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.search.SearchInfo; import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; @@ -131,6 +136,22 @@ public final class ExtractorHelper { ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextStreamsUrl)); } + public static Single> getFeedInfoFallbackToChannelInfo(final int serviceId, + final String url) { + final Maybe> maybeFeedInfo = Maybe.fromCallable(() -> { + final StreamingService service = NewPipe.getService(serviceId); + final FeedExtractor feedExtractor = service.getFeedExtractor(url); + + if (feedExtractor == null) { + return null; + } + + return FeedInfo.getInfo(feedExtractor); + }); + + return maybeFeedInfo.switchIfEmpty(getChannelInfo(serviceId, url, true)); + } + public static Single getCommentsInfo(final int serviceId, final String url, boolean forceLoad) { diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java index 47b914bde..9c8fc25b8 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -213,6 +213,42 @@ public class Localization { return output; } + /** + * Localize an amount of seconds into a human readable string. + * + *

The seconds will be converted to the closest whole time unit. + *

For example, 60 seconds would give "1 minute", 119 would also give "1 minute". + * + * @param context used to get plurals resources. + * @param durationInSecs an amount of seconds. + * @return duration in a human readable string. + */ + @NonNull + public static String localizeDuration(Context context, int durationInSecs) { + if (durationInSecs < 0) { + throw new IllegalArgumentException("duration can not be negative"); + } + + final int days = (int) (durationInSecs / (24 * 60 * 60L)); /* greater than a day */ + durationInSecs %= (24 * 60 * 60L); + final int hours = (int) (durationInSecs / (60 * 60L)); /* greater than an hour */ + durationInSecs %= (60 * 60L); + final int minutes = (int) (durationInSecs / 60L); + final int seconds = (int) (durationInSecs % 60L); + + final Resources resources = context.getResources(); + + if (days > 0) { + return resources.getQuantityString(R.plurals.days, days, days); + } else if (hours > 0) { + return resources.getQuantityString(R.plurals.hours, hours, hours); + } else if (minutes > 0) { + return resources.getQuantityString(R.plurals.minutes, minutes, minutes); + } else { + return resources.getQuantityString(R.plurals.seconds, seconds, seconds); + } + } + /*////////////////////////////////////////////////////////////////////////// // Pretty Time //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index a19aa92ae..b6f73dac7 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -23,6 +23,7 @@ import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.RouterActivity; import org.schabi.newpipe.about.AboutActivity; +import org.schabi.newpipe.database.feed.model.FeedGroupEntity; import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; @@ -110,13 +111,15 @@ public class NavigationHelper { final boolean playbackSkipSilence, @Nullable final String playbackQuality, final boolean resumePlayback, - final boolean startPaused) { + final boolean startPaused, + final boolean isMuted) { return getPlayerIntent(context, targetClazz, playQueue, playbackQuality, resumePlayback) .putExtra(BasePlayer.REPEAT_MODE, repeatMode) .putExtra(BasePlayer.PLAYBACK_SPEED, playbackSpeed) .putExtra(BasePlayer.PLAYBACK_PITCH, playbackPitch) .putExtra(BasePlayer.PLAYBACK_SKIP_SILENCE, playbackSkipSilence) - .putExtra(BasePlayer.START_PAUSED, startPaused); + .putExtra(BasePlayer.START_PAUSED, startPaused) + .putExtra(BasePlayer.IS_MUTED, isMuted); } public static void playOnMainPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) { @@ -341,9 +344,13 @@ public class NavigationHelper { .commit(); } - public static void openWhatsNewFragment(FragmentManager fragmentManager) { + public static void openFeedFragment(FragmentManager fragmentManager) { + openFeedFragment(fragmentManager, FeedGroupEntity.GROUP_ALL_ID, null); + } + + public static void openFeedFragment(FragmentManager fragmentManager, long groupId, @Nullable String groupName) { defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, new FeedFragment()) + .replace(R.id.fragment_holder, FeedFragment.newInstance(groupId, groupName)) .addToBackStack(null) .commit(); } diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java index 661aa47c1..bd51919c7 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java @@ -99,6 +99,17 @@ public class ThemeHelper { return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme; } + /** + * Return a min-width dialog theme styled according to the (default) selected theme. + * + * @param context context to get the selected theme + * @return the dialog style (the default one) + */ + @StyleRes + public static int getMinWidthDialogTheme(Context context) { + return isLightThemeSelected(context) ? R.style.LightDialogMinWidthTheme : R.style.DarkDialogMinWidthTheme; + } + /** * Return the selected theme styled according to the serviceId. * diff --git a/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java b/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java new file mode 100644 index 000000000..bbad56c37 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java @@ -0,0 +1,360 @@ +/* THIS FILE WAS MODIFIED, CHANGES ARE DOCUMENTED. */ + +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.schabi.newpipe.util.urlfinder; + +import androidx.annotation.RestrictTo; + +import java.util.regex.Pattern; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX; + +/** + * Commonly used regular expression patterns. + */ +public final class PatternsCompat { + /** + * Regular expression to match all IANA top-level domains. + * + * List accurate as of 2015/11/24. List taken from: + * http://data.iana.org/TLD/tlds-alpha-by-domain.txt + * This pattern is auto-generated by frameworks/ex/common/tools/make-iana-tld-pattern.py + */ + static final String IANA_TOP_LEVEL_DOMAINS = + "(?:" + + "(?:aaa|aarp|abb|abbott|abogado|academy|accenture|accountant|accountants|aco|active" + + "|actor|ads|adult|aeg|aero|afl|agency|aig|airforce|airtel|allfinanz|alsace|amica|amsterdam" + + "|android|apartments|app|apple|aquarelle|aramco|archi|army|arpa|arte|asia|associates" + + "|attorney|auction|audio|auto|autos|axa|azure|a[cdefgilmoqrstuwxz])" + + "|(?:band|bank|bar|barcelona|barclaycard|barclays|bargains|bauhaus|bayern|bbc|bbva" + + "|bcn|beats|beer|bentley|berlin|best|bet|bharti|bible|bid|bike|bing|bingo|bio|biz|black" + + "|blackfriday|bloomberg|blue|bms|bmw|bnl|bnpparibas|boats|bom|bond|boo|boots|boutique" + + "|bradesco|bridgestone|broadway|broker|brother|brussels|budapest|build|builders|business" + + "|buzz|bzh|b[abdefghijmnorstvwyz])" + + "|(?:cab|cafe|cal|camera|camp|cancerresearch|canon|capetown|capital|car|caravan|cards" + + "|care|career|careers|cars|cartier|casa|cash|casino|cat|catering|cba|cbn|ceb|center|ceo" + + "|cern|cfa|cfd|chanel|channel|chat|cheap|chloe|christmas|chrome|church|cipriani|cisco" + + "|citic|city|cityeats|claims|cleaning|click|clinic|clothing|cloud|club|clubmed|coach" + + "|codes|coffee|college|cologne|com|commbank|community|company|computer|comsec|condos" + + "|construction|consulting|contractors|cooking|cool|coop|corsica|country|coupons|courses" + + "|credit|creditcard|creditunion|cricket|crown|crs|cruises|csc|cuisinella|cymru|cyou|c[acdfghiklmnoruvwxyz])" + + "|(?:dabur|dad|dance|date|dating|datsun|day|dclk|deals|degree|delivery|dell|delta" + + "|democrat|dental|dentist|desi|design|dev|diamonds|diet|digital|direct|directory|discount" + + "|dnp|docs|dog|doha|domains|doosan|download|drive|durban|dvag|d[ejkmoz])" + + "|(?:earth|eat|edu|education|email|emerck|energy|engineer|engineering|enterprises" + + "|epson|equipment|erni|esq|estate|eurovision|eus|events|everbank|exchange|expert|exposed" + + "|express|e[cegrstu])" + + "|(?:fage|fail|fairwinds|faith|family|fan|fans|farm|fashion|feedback|ferrero|film" + + "|final|finance|financial|firmdale|fish|fishing|fit|fitness|flights|florist|flowers|flsmidth" + + "|fly|foo|football|forex|forsale|forum|foundation|frl|frogans|fund|furniture|futbol|fyi" + + "|f[ijkmor])" + + "|(?:gal|gallery|game|garden|gbiz|gdn|gea|gent|genting|ggee|gift|gifts|gives|giving" + + "|glass|gle|global|globo|gmail|gmo|gmx|gold|goldpoint|golf|goo|goog|google|gop|gov|grainger" + + "|graphics|gratis|green|gripe|group|gucci|guge|guide|guitars|guru|g[abdefghilmnpqrstuwy])" + + "|(?:hamburg|hangout|haus|healthcare|help|here|hermes|hiphop|hitachi|hiv|hockey|holdings" + + "|holiday|homedepot|homes|honda|horse|host|hosting|hoteles|hotmail|house|how|hsbc|hyundai" + + "|h[kmnrtu])" + + "|(?:ibm|icbc|ice|icu|ifm|iinet|immo|immobilien|industries|infiniti|info|ing|ink|institute" + + "|insure|int|international|investments|ipiranga|irish|ist|istanbul|itau|iwc|i[delmnoqrst])" + + "|(?:jaguar|java|jcb|jetzt|jewelry|jlc|jll|jobs|joburg|jprs|juegos|j[emop])" + + "|(?:kaufen|kddi|kia|kim|kinder|kitchen|kiwi|koeln|komatsu|krd|kred|kyoto|k[eghimnprwyz])" + + "|(?:lacaixa|lancaster|land|landrover|lasalle|lat|latrobe|law|lawyer|lds|lease|leclerc" + + "|legal|lexus|lgbt|liaison|lidl|life|lifestyle|lighting|limited|limo|linde|link|live" + + "|lixil|loan|loans|lol|london|lotte|lotto|love|ltd|ltda|lupin|luxe|luxury|l[abcikrstuvy])" + + "|(?:madrid|maif|maison|man|management|mango|market|marketing|markets|marriott|mba" + + "|media|meet|melbourne|meme|memorial|men|menu|meo|miami|microsoft|mil|mini|mma|mobi|moda" + + "|moe|moi|mom|monash|money|montblanc|mormon|mortgage|moscow|motorcycles|mov|movie|movistar" + + "|mtn|mtpc|mtr|museum|mutuelle|m[acdeghklmnopqrstuvwxyz])" + + "|(?:nadex|nagoya|name|navy|nec|net|netbank|network|neustar|new|news|nexus|ngo|nhk" + + "|nico|ninja|nissan|nokia|nra|nrw|ntt|nyc|n[acefgilopruz])" + + "|(?:obi|office|okinawa|omega|one|ong|onl|online|ooo|oracle|orange|org|organic|osaka" + + "|otsuka|ovh|om)" + + "|(?:page|panerai|paris|partners|parts|party|pet|pharmacy|philips|photo|photography" + + "|photos|physio|piaget|pics|pictet|pictures|ping|pink|pizza|place|play|playstation|plumbing" + + "|plus|pohl|poker|porn|post|praxi|press|pro|prod|productions|prof|properties|property" + + "|protection|pub|p[aefghklmnrstwy])" + + "|(?:qpon|quebec|qa)" + + "|(?:racing|realtor|realty|recipes|red|redstone|rehab|reise|reisen|reit|ren|rent|rentals" + + "|repair|report|republican|rest|restaurant|review|reviews|rich|ricoh|rio|rip|rocher|rocks" + + "|rodeo|rsvp|ruhr|run|rwe|ryukyu|r[eosuw])" + + "|(?:saarland|sakura|sale|samsung|sandvik|sandvikcoromant|sanofi|sap|sapo|sarl|saxo" + + "|sbs|sca|scb|schmidt|scholarships|school|schule|schwarz|science|scor|scot|seat|security" + + "|seek|sener|services|seven|sew|sex|sexy|shiksha|shoes|show|shriram|singles|site|ski" + + "|sky|skype|sncf|soccer|social|software|sohu|solar|solutions|sony|soy|space|spiegel|spreadbetting" + + "|srl|stada|starhub|statoil|stc|stcgroup|stockholm|studio|study|style|sucks|supplies" + + "|supply|support|surf|surgery|suzuki|swatch|swiss|sydney|systems|s[abcdeghijklmnortuvxyz])" + + "|(?:tab|taipei|tatamotors|tatar|tattoo|tax|taxi|team|tech|technology|tel|telefonica" + + "|temasek|tennis|thd|theater|theatre|tickets|tienda|tips|tires|tirol|today|tokyo|tools" + + "|top|toray|toshiba|tours|town|toyota|toys|trade|trading|training|travel|trust|tui|t[cdfghjklmnortvwz])" + + "|(?:ubs|university|uno|uol|u[agksyz])" + + "|(?:vacations|vana|vegas|ventures|versicherung|vet|viajes|video|villas|vin|virgin" + + "|vision|vista|vistaprint|viva|vlaanderen|vodka|vote|voting|voto|voyage|v[aceginu])" + + "|(?:wales|walter|wang|watch|webcam|website|wed|wedding|weir|whoswho|wien|wiki|williamhill" + + "|win|windows|wine|wme|work|works|world|wtc|wtf|w[fs])" + + "|(?:\u03b5\u03bb|\u0431\u0435\u043b|\u0434\u0435\u0442\u0438|\u043a\u043e\u043c|\u043c\u043a\u0434" + + "|\u043c\u043e\u043d|\u043c\u043e\u0441\u043a\u0432\u0430|\u043e\u043d\u043b\u0430\u0439\u043d" + + "|\u043e\u0440\u0433|\u0440\u0443\u0441|\u0440\u0444|\u0441\u0430\u0439\u0442|\u0441\u0440\u0431" + + "|\u0443\u043a\u0440|\u049b\u0430\u0437|\u0570\u0561\u0575|\u05e7\u05d5\u05dd|\u0627\u0631\u0627\u0645\u0643\u0648" + + "|\u0627\u0644\u0627\u0631\u062f\u0646|\u0627\u0644\u062c\u0632\u0627\u0626\u0631|\u0627\u0644\u0633\u0639\u0648\u062f\u064a\u0629" + + "|\u0627\u0644\u0645\u063a\u0631\u0628|\u0627\u0645\u0627\u0631\u0627\u062a|\u0627\u06cc\u0631\u0627\u0646" + + "|\u0628\u0627\u0632\u0627\u0631|\u0628\u06be\u0627\u0631\u062a|\u062a\u0648\u0646\u0633" + + "|\u0633\u0648\u062f\u0627\u0646|\u0633\u0648\u0631\u064a\u0629|\u0634\u0628\u0643\u0629" + + "|\u0639\u0631\u0627\u0642|\u0639\u0645\u0627\u0646|\u0641\u0644\u0633\u0637\u064a\u0646" + + "|\u0642\u0637\u0631|\u0643\u0648\u0645|\u0645\u0635\u0631|\u0645\u0644\u064a\u0633\u064a\u0627" + + "|\u0645\u0648\u0642\u0639|\u0915\u0949\u092e|\u0928\u0947\u091f|\u092d\u093e\u0930\u0924" + + "|\u0938\u0902\u0917\u0920\u0928|\u09ad\u09be\u09b0\u09a4|\u0a2d\u0a3e\u0a30\u0a24|\u0aad\u0abe\u0ab0\u0aa4" + + "|\u0b87\u0ba8\u0bcd\u0ba4\u0bbf\u0baf\u0bbe|\u0b87\u0bb2\u0b99\u0bcd\u0b95\u0bc8|\u0b9a\u0bbf\u0b99\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0bc2\u0bb0\u0bcd" + + "|\u0c2d\u0c3e\u0c30\u0c24\u0c4d|\u0dbd\u0d82\u0d9a\u0dcf|\u0e04\u0e2d\u0e21|\u0e44\u0e17\u0e22" + + "|\u10d2\u10d4|\u307f\u3093\u306a|\u30b0\u30fc\u30b0\u30eb|\u30b3\u30e0|\u4e16\u754c" + + "|\u4e2d\u4fe1|\u4e2d\u56fd|\u4e2d\u570b|\u4e2d\u6587\u7f51|\u4f01\u4e1a|\u4f5b\u5c71" + + "|\u4fe1\u606f|\u5065\u5eb7|\u516b\u5366|\u516c\u53f8|\u516c\u76ca|\u53f0\u6e7e|\u53f0\u7063" + + "|\u5546\u57ce|\u5546\u5e97|\u5546\u6807|\u5728\u7ebf|\u5927\u62ff|\u5a31\u4e50|\u5de5\u884c" + + "|\u5e7f\u4e1c|\u6148\u5584|\u6211\u7231\u4f60|\u624b\u673a|\u653f\u52a1|\u653f\u5e9c" + + "|\u65b0\u52a0\u5761|\u65b0\u95fb|\u65f6\u5c1a|\u673a\u6784|\u6de1\u9a6c\u9521|\u6e38\u620f" + + "|\u70b9\u770b|\u79fb\u52a8|\u7ec4\u7ec7\u673a\u6784|\u7f51\u5740|\u7f51\u5e97|\u7f51\u7edc" + + "|\u8c37\u6b4c|\u96c6\u56e2|\u98de\u5229\u6d66|\u9910\u5385|\u9999\u6e2f|\ub2f7\ub137" + + "|\ub2f7\ucef4|\uc0bc\uc131|\ud55c\uad6d|xbox" + + "|xerox|xin|xn\\-\\-11b4c3d|xn\\-\\-1qqw23a|xn\\-\\-30rr7y|xn\\-\\-3bst00m|xn\\-\\-3ds443g" + + "|xn\\-\\-3e0b707e|xn\\-\\-3pxu8k|xn\\-\\-42c2d9a|xn\\-\\-45brj9c|xn\\-\\-45q11c|xn\\-\\-4gbrim" + + "|xn\\-\\-55qw42g|xn\\-\\-55qx5d|xn\\-\\-6frz82g|xn\\-\\-6qq986b3xl|xn\\-\\-80adxhks" + + "|xn\\-\\-80ao21a|xn\\-\\-80asehdb|xn\\-\\-80aswg|xn\\-\\-90a3ac|xn\\-\\-90ais|xn\\-\\-9dbq2a" + + "|xn\\-\\-9et52u|xn\\-\\-b4w605ferd|xn\\-\\-c1avg|xn\\-\\-c2br7g|xn\\-\\-cg4bki|xn\\-\\-clchc0ea0b2g2a9gcd" + + "|xn\\-\\-czr694b|xn\\-\\-czrs0t|xn\\-\\-czru2d|xn\\-\\-d1acj3b|xn\\-\\-d1alf|xn\\-\\-efvy88h" + + "|xn\\-\\-estv75g|xn\\-\\-fhbei|xn\\-\\-fiq228c5hs|xn\\-\\-fiq64b|xn\\-\\-fiqs8s|xn\\-\\-fiqz9s" + + "|xn\\-\\-fjq720a|xn\\-\\-flw351e|xn\\-\\-fpcrj9c3d|xn\\-\\-fzc2c9e2c|xn\\-\\-gecrj9c" + + "|xn\\-\\-h2brj9c|xn\\-\\-hxt814e|xn\\-\\-i1b6b1a6a2e|xn\\-\\-imr513n|xn\\-\\-io0a7i" + + "|xn\\-\\-j1aef|xn\\-\\-j1amh|xn\\-\\-j6w193g|xn\\-\\-kcrx77d1x4a|xn\\-\\-kprw13d|xn\\-\\-kpry57d" + + "|xn\\-\\-kput3i|xn\\-\\-l1acc|xn\\-\\-lgbbat1ad8j|xn\\-\\-mgb9awbf|xn\\-\\-mgba3a3ejt" + + "|xn\\-\\-mgba3a4f16a|xn\\-\\-mgbaam7a8h|xn\\-\\-mgbab2bd|xn\\-\\-mgbayh7gpa|xn\\-\\-mgbbh1a71e" + + "|xn\\-\\-mgbc0a9azcg|xn\\-\\-mgberp4a5d4ar|xn\\-\\-mgbpl2fh|xn\\-\\-mgbtx2b|xn\\-\\-mgbx4cd0ab" + + "|xn\\-\\-mk1bu44c|xn\\-\\-mxtq1m|xn\\-\\-ngbc5azd|xn\\-\\-node|xn\\-\\-nqv7f|xn\\-\\-nqv7fs00ema" + + "|xn\\-\\-nyqy26a|xn\\-\\-o3cw4h|xn\\-\\-ogbpf8fl|xn\\-\\-p1acf|xn\\-\\-p1ai|xn\\-\\-pgbs0dh" + + "|xn\\-\\-pssy2u|xn\\-\\-q9jyb4c|xn\\-\\-qcka1pmc|xn\\-\\-qxam|xn\\-\\-rhqv96g|xn\\-\\-s9brj9c" + + "|xn\\-\\-ses554g|xn\\-\\-t60b56a|xn\\-\\-tckwe|xn\\-\\-unup4y|xn\\-\\-vermgensberater\\-ctb" + + "|xn\\-\\-vermgensberatung\\-pwb|xn\\-\\-vhquv|xn\\-\\-vuq861b|xn\\-\\-wgbh1c|xn\\-\\-wgbl6a" + + "|xn\\-\\-xhq521b|xn\\-\\-xkc2al3hye2a|xn\\-\\-xkc2dl3a5ee0h|xn\\-\\-y9a3aq|xn\\-\\-yfro4i67o" + + "|xn\\-\\-ygbi2ammx|xn\\-\\-zfr164b|xperia|xxx|xyz)" + + "|(?:yachts|yamaxun|yandex|yodobashi|yoga|yokohama|youtube|y[et])" + + "|(?:zara|zip|zone|zuerich|z[amw]))"; + + public static final Pattern IP_ADDRESS + = Pattern.compile( + "((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4]" + + "[0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]" + + "[0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}" + + "|[1-9][0-9]|[0-9]))"); + + /** + * Valid UCS characters defined in RFC 3987. Excludes space characters. + */ + private static final String UCS_CHAR = "[" + + "\u00A0-\uD7FF" + + "\uF900-\uFDCF" + + "\uFDF0-\uFFEF" + + "\uD800\uDC00-\uD83F\uDFFD" + + "\uD840\uDC00-\uD87F\uDFFD" + + "\uD880\uDC00-\uD8BF\uDFFD" + + "\uD8C0\uDC00-\uD8FF\uDFFD" + + "\uD900\uDC00-\uD93F\uDFFD" + + "\uD940\uDC00-\uD97F\uDFFD" + + "\uD980\uDC00-\uD9BF\uDFFD" + + "\uD9C0\uDC00-\uD9FF\uDFFD" + + "\uDA00\uDC00-\uDA3F\uDFFD" + + "\uDA40\uDC00-\uDA7F\uDFFD" + + "\uDA80\uDC00-\uDABF\uDFFD" + + "\uDAC0\uDC00-\uDAFF\uDFFD" + + "\uDB00\uDC00-\uDB3F\uDFFD" + + "\uDB44\uDC00-\uDB7F\uDFFD" + + "&&[^\u00A0[\u2000-\u200A]\u2028\u2029\u202F\u3000]]"; + + /** + * Valid characters for IRI label defined in RFC 3987. + */ + private static final String LABEL_CHAR = "a-zA-Z0-9" + UCS_CHAR; + + /** + * Valid characters for IRI TLD defined in RFC 3987. + */ + private static final String TLD_CHAR = "a-zA-Z" + UCS_CHAR; + + /** + * RFC 1035 Section 2.3.4 limits the labels to a maximum 63 octets. + */ + private static final String IRI_LABEL = + "[" + LABEL_CHAR + "](?:[" + LABEL_CHAR + "_\\-]{0,61}[" + LABEL_CHAR + "]){0,1}"; + + /** + * RFC 3492 references RFC 1034 and limits Punycode algorithm output to 63 characters. + */ + private static final String PUNYCODE_TLD = "xn\\-\\-[\\w\\-]{0,58}\\w"; + + private static final String TLD = "(" + PUNYCODE_TLD + "|" + "[" + TLD_CHAR + "]{2,63}" +")"; + + private static final String HOST_NAME = "(" + IRI_LABEL + "\\.)+" + TLD; + + public static final Pattern DOMAIN_NAME + = Pattern.compile("(" + HOST_NAME + "|" + IP_ADDRESS + ")"); + + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // CHANGED: Removed rtsp from supported protocols // + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + private static final String PROTOCOL = "(?i:http|https)://"; + + /* A word boundary or end of input. This is to stop foo.sure from matching as foo.su */ + private static final String WORD_BOUNDARY = "(?:\\b|$|^)"; + + private static final String USER_INFO = "(?:[a-zA-Z0-9\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)" + + "\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\$\\-\\_" + + "\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@"; + + private static final String PORT_NUMBER = "\\:\\d{1,5}"; + + private static final String PATH_AND_QUERY = "[/\\?](?:(?:[" + LABEL_CHAR + + ";/\\?:@&=#~" // plus optional query params + + "\\-\\.\\+!\\*'\\(\\),_\\$])|(?:%[a-fA-F0-9]{2}))*"; + + /** + * Regular expression pattern to match most part of RFC 3987 + * Internationalized URLs, aka IRIs. + */ + public static final Pattern WEB_URL = Pattern.compile("(" + + "(" + + "(?:" + PROTOCOL + "(?:" + USER_INFO + ")?" + ")?" + + "(?:" + DOMAIN_NAME + ")" + + "(?:" + PORT_NUMBER + ")?" + + ")" + + "(" + PATH_AND_QUERY + ")?" + + WORD_BOUNDARY + + ")"); + + /** + * Regular expression that matches known TLDs and punycode TLDs + */ + private static final String STRICT_TLD = "(?:" + + IANA_TOP_LEVEL_DOMAINS + "|" + PUNYCODE_TLD + ")"; + + /** + * Regular expression that matches host names using {@link #STRICT_TLD} + */ + private static final String STRICT_HOST_NAME = "(?:(?:" + IRI_LABEL + "\\.)+" + + STRICT_TLD + ")"; + + /** + * Regular expression that matches domain names using either {@link #STRICT_HOST_NAME} or + * {@link #IP_ADDRESS} + */ + private static final Pattern STRICT_DOMAIN_NAME + = Pattern.compile("(?:" + STRICT_HOST_NAME + "|" + IP_ADDRESS + ")"); + + /** + * Regular expression that matches domain names without a TLD + */ + private static final String RELAXED_DOMAIN_NAME = + "(?:" + "(?:" + IRI_LABEL + "(?:\\.(?=\\S))" +"?)+" + "|" + IP_ADDRESS + ")"; + + /** + * Regular expression to match strings that do not start with a supported protocol. The TLDs + * are expected to be one of the known TLDs. + */ + private static final String WEB_URL_WITHOUT_PROTOCOL = "(" + + WORD_BOUNDARY + + "(? implements Handler.Callb if (BuildConfig.DEBUG) Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); - Uri uri; - - if (mission.storage.isDirect()) { - uri = FileProvider.getUriForFile( - mContext, - BuildConfig.APPLICATION_ID + ".provider", - new File(URI.create(mission.storage.getUri().toString())) - ); - } else { - uri = mission.storage.getUri(); - } + Uri uri = resolveShareableUri(mission); Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); @@ -379,11 +369,30 @@ public class MissionAdapter extends Adapter implements Handler.Callb Intent intent = new Intent(Intent.ACTION_SEND); intent.setType(resolveMimeType(mission)); - intent.putExtra(Intent.EXTRA_STREAM, mission.storage.getUri()); + intent.putExtra(Intent.EXTRA_STREAM, resolveShareableUri(mission)); + intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); mContext.startActivity(Intent.createChooser(intent, null)); } + /** + * Returns an Uri which can be shared to other applications. + * + * @see + * https://stackoverflow.com/questions/38200282/android-os-fileuriexposedexception-file-storage-emulated-0-test-txt-exposed + */ + private Uri resolveShareableUri(Mission mission) { + if (mission.storage.isDirect()) { + return FileProvider.getUriForFile( + mContext, + BuildConfig.APPLICATION_ID + ".provider", + new File(URI.create(mission.storage.getUri().toString())) + ); + } else { + return mission.storage.getUri(); + } + } + private static String resolveMimeType(@NonNull Mission mission) { String mimeType; diff --git a/app/src/main/res/drawable/dark_focused_selector.xml b/app/src/main/res/drawable/dark_focused_selector.xml new file mode 100644 index 000000000..102f40d76 --- /dev/null +++ b/app/src/main/res/drawable/dark_focused_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dashed_border_black.xml b/app/src/main/res/drawable/dashed_border_black.xml new file mode 100644 index 000000000..b6bac6252 --- /dev/null +++ b/app/src/main/res/drawable/dashed_border_black.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dashed_border_dark.xml b/app/src/main/res/drawable/dashed_border_dark.xml new file mode 100644 index 000000000..5af152ecc --- /dev/null +++ b/app/src/main/res/drawable/dashed_border_dark.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dashed_border_light.xml b/app/src/main/res/drawable/dashed_border_light.xml new file mode 100644 index 000000000..5d29112bd --- /dev/null +++ b/app/src/main/res/drawable/dashed_border_light.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_asterisk_black_24dp.xml b/app/src/main/res/drawable/ic_asterisk_black_24dp.xml new file mode 100644 index 000000000..fa16cd5e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_asterisk_black_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_asterisk_white_24dp.xml b/app/src/main/res/drawable/ic_asterisk_white_24dp.xml new file mode 100644 index 000000000..bd487cb55 --- /dev/null +++ b/app/src/main/res/drawable/ic_asterisk_white_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_car_black_24dp.xml b/app/src/main/res/drawable/ic_car_black_24dp.xml new file mode 100644 index 000000000..6aa8cdd82 --- /dev/null +++ b/app/src/main/res/drawable/ic_car_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_car_white_24dp.xml b/app/src/main/res/drawable/ic_car_white_24dp.xml new file mode 100644 index 000000000..7ad263933 --- /dev/null +++ b/app/src/main/res/drawable/ic_car_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_computer_black_24dp.xml b/app/src/main/res/drawable/ic_computer_black_24dp.xml new file mode 100644 index 000000000..b03d9c0ce --- /dev/null +++ b/app/src/main/res/drawable/ic_computer_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_computer_white_24dp.xml b/app/src/main/res/drawable/ic_computer_white_24dp.xml new file mode 100644 index 000000000..c4bdad688 --- /dev/null +++ b/app/src/main/res/drawable/ic_computer_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_black_24dp.xml b/app/src/main/res/drawable/ic_edit_black_24dp.xml new file mode 100644 index 000000000..43489826e --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_white_24dp.xml b/app/src/main/res/drawable/ic_edit_white_24dp.xml new file mode 100644 index 000000000..88f94780f --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoticon_black_24dp.xml b/app/src/main/res/drawable/ic_emoticon_black_24dp.xml new file mode 100644 index 000000000..45f489d80 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoticon_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoticon_white_24dp.xml b/app/src/main/res/drawable/ic_emoticon_white_24dp.xml new file mode 100644 index 000000000..89ca90fb5 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoticon_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_explore_black_24dp.xml b/app/src/main/res/drawable/ic_explore_black_24dp.xml new file mode 100644 index 000000000..c898ed9a5 --- /dev/null +++ b/app/src/main/res/drawable/ic_explore_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_explore_white_24dp.xml b/app/src/main/res/drawable/ic_explore_white_24dp.xml new file mode 100644 index 000000000..65f2818a6 --- /dev/null +++ b/app/src/main/res/drawable/ic_explore_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fastfood_black_24dp.xml b/app/src/main/res/drawable/ic_fastfood_black_24dp.xml new file mode 100644 index 000000000..fac047550 --- /dev/null +++ b/app/src/main/res/drawable/ic_fastfood_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fastfood_white_24dp.xml b/app/src/main/res/drawable/ic_fastfood_white_24dp.xml new file mode 100644 index 000000000..39bbee49a --- /dev/null +++ b/app/src/main/res/drawable/ic_fastfood_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fitness_black_24dp.xml b/app/src/main/res/drawable/ic_fitness_black_24dp.xml new file mode 100644 index 000000000..40a1cf9c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_fitness_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fitness_white_24dp.xml b/app/src/main/res/drawable/ic_fitness_white_24dp.xml new file mode 100644 index 000000000..1b2d3b4be --- /dev/null +++ b/app/src/main/res/drawable/ic_fitness_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_heart_black_24dp.xml b/app/src/main/res/drawable/ic_heart_black_24dp.xml new file mode 100644 index 000000000..25cb46e83 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_heart_white_24dp.xml b/app/src/main/res/drawable/ic_heart_white_24dp.xml new file mode 100644 index 000000000..02c6396ee --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_help_black_24dp.xml b/app/src/main/res/drawable/ic_help_black_24dp.xml new file mode 100644 index 000000000..1517747d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_help_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_help_white_24dp.xml b/app/src/main/res/drawable/ic_help_white_24dp.xml new file mode 100644 index 000000000..d813b72b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_help_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_kids_black_24dp.xml b/app/src/main/res/drawable/ic_kids_black_24dp.xml new file mode 100644 index 000000000..d1d8e01e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_kids_black_24dp.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_kids_white_24dp.xml b/app/src/main/res/drawable/ic_kids_white_24dp.xml new file mode 100644 index 000000000..c5dda16c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_kids_white_24dp.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_megaphone_black_24dp.xml b/app/src/main/res/drawable/ic_megaphone_black_24dp.xml new file mode 100644 index 000000000..21622c162 --- /dev/null +++ b/app/src/main/res/drawable/ic_megaphone_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_megaphone_white_24dp.xml b/app/src/main/res/drawable/ic_megaphone_white_24dp.xml new file mode 100644 index 000000000..90e6ff215 --- /dev/null +++ b/app/src/main/res/drawable/ic_megaphone_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_black_24dp.xml b/app/src/main/res/drawable/ic_mic_black_24dp.xml new file mode 100644 index 000000000..25d8951a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_white_24dp.xml b/app/src/main/res/drawable/ic_mic_white_24dp.xml new file mode 100644 index 000000000..36ee9ff81 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_money_black_24dp.xml b/app/src/main/res/drawable/ic_money_black_24dp.xml new file mode 100644 index 000000000..4019c2e46 --- /dev/null +++ b/app/src/main/res/drawable/ic_money_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_money_white_24dp.xml b/app/src/main/res/drawable/ic_money_white_24dp.xml new file mode 100644 index 000000000..2407a2b73 --- /dev/null +++ b/app/src/main/res/drawable/ic_money_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml b/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml new file mode 100644 index 000000000..6009979dd --- /dev/null +++ b/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml b/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml new file mode 100644 index 000000000..b94c29f8f --- /dev/null +++ b/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_movie_black_24dp.xml b/app/src/main/res/drawable/ic_movie_black_24dp.xml new file mode 100644 index 000000000..d70c00f00 --- /dev/null +++ b/app/src/main/res/drawable/ic_movie_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_movie_white_24dp.xml b/app/src/main/res/drawable/ic_movie_white_24dp.xml new file mode 100644 index 000000000..f73e76774 --- /dev/null +++ b/app/src/main/res/drawable/ic_movie_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_music_note_black_24dp.xml b/app/src/main/res/drawable/ic_music_note_black_24dp.xml new file mode 100644 index 000000000..698159295 --- /dev/null +++ b/app/src/main/res/drawable/ic_music_note_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_music_note_white_24dp.xml b/app/src/main/res/drawable/ic_music_note_white_24dp.xml new file mode 100644 index 000000000..1d38e6e22 --- /dev/null +++ b/app/src/main/res/drawable/ic_music_note_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_people_black_24dp.xml b/app/src/main/res/drawable/ic_people_black_24dp.xml new file mode 100644 index 000000000..d0fe31838 --- /dev/null +++ b/app/src/main/res/drawable/ic_people_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_people_white_24dp.xml b/app/src/main/res/drawable/ic_people_white_24dp.xml new file mode 100644 index 000000000..e6fa4c583 --- /dev/null +++ b/app/src/main/res/drawable/ic_people_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_black_24dp.xml b/app/src/main/res/drawable/ic_person_black_24dp.xml new file mode 100644 index 000000000..f0ff6a871 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_white_24dp.xml b/app/src/main/res/drawable/ic_person_white_24dp.xml new file mode 100644 index 000000000..99f299963 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pets_black_24dp.xml b/app/src/main/res/drawable/ic_pets_black_24dp.xml new file mode 100644 index 000000000..b6247bd87 --- /dev/null +++ b/app/src/main/res/drawable/ic_pets_black_24dp.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_pets_white_24dp.xml b/app/src/main/res/drawable/ic_pets_white_24dp.xml new file mode 100644 index 000000000..46724a33d --- /dev/null +++ b/app/src/main/res/drawable/ic_pets_white_24dp.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_radio_black_24dp.xml b/app/src/main/res/drawable/ic_radio_black_24dp.xml new file mode 100644 index 000000000..00da9101f --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_radio_white_24dp.xml b/app/src/main/res/drawable/ic_radio_white_24dp.xml new file mode 100644 index 000000000..df563ec1d --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_black_24dp.xml b/app/src/main/res/drawable/ic_refresh_black_24dp.xml new file mode 100644 index 000000000..8229a9a64 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_white_24dp.xml b/app/src/main/res/drawable/ic_refresh_white_24dp.xml new file mode 100644 index 000000000..a8175c316 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_restaurant_black_24dp.xml b/app/src/main/res/drawable/ic_restaurant_black_24dp.xml new file mode 100644 index 000000000..0a8c6bde9 --- /dev/null +++ b/app/src/main/res/drawable/ic_restaurant_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_restaurant_white_24dp.xml b/app/src/main/res/drawable/ic_restaurant_white_24dp.xml new file mode 100644 index 000000000..c81618bb7 --- /dev/null +++ b/app/src/main/res/drawable/ic_restaurant_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_school_black_24dp.xml b/app/src/main/res/drawable/ic_school_black_24dp.xml new file mode 100644 index 000000000..8f52f0dde --- /dev/null +++ b/app/src/main/res/drawable/ic_school_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_school_white_24dp.xml b/app/src/main/res/drawable/ic_school_white_24dp.xml new file mode 100644 index 000000000..e3888411a --- /dev/null +++ b/app/src/main/res/drawable/ic_school_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml b/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml new file mode 100644 index 000000000..452332095 --- /dev/null +++ b/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml b/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml new file mode 100644 index 000000000..a55bf8a88 --- /dev/null +++ b/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sort_black_24dp.xml b/app/src/main/res/drawable/ic_sort_black_24dp.xml new file mode 100644 index 000000000..fd4c56f0e --- /dev/null +++ b/app/src/main/res/drawable/ic_sort_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sort_white_24dp.xml b/app/src/main/res/drawable/ic_sort_white_24dp.xml new file mode 100644 index 000000000..a0c153ad0 --- /dev/null +++ b/app/src/main/res/drawable/ic_sort_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sports_black_24dp.xml b/app/src/main/res/drawable/ic_sports_black_24dp.xml new file mode 100644 index 000000000..5a54580c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_sports_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sports_white_24dp.xml b/app/src/main/res/drawable/ic_sports_white_24dp.xml new file mode 100644 index 000000000..611852728 --- /dev/null +++ b/app/src/main/res/drawable/ic_sports_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stars_black_24dp.xml b/app/src/main/res/drawable/ic_stars_black_24dp.xml new file mode 100644 index 000000000..66a89110e --- /dev/null +++ b/app/src/main/res/drawable/ic_stars_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stars_white_24dp.xml b/app/src/main/res/drawable/ic_stars_white_24dp.xml new file mode 100644 index 000000000..2de1fd808 --- /dev/null +++ b/app/src/main/res/drawable/ic_stars_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sunny_black_24dp.xml b/app/src/main/res/drawable/ic_sunny_black_24dp.xml new file mode 100644 index 000000000..fee59df13 --- /dev/null +++ b/app/src/main/res/drawable/ic_sunny_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sunny_white_24dp.xml b/app/src/main/res/drawable/ic_sunny_white_24dp.xml new file mode 100644 index 000000000..c6cb469ef --- /dev/null +++ b/app/src/main/res/drawable/ic_sunny_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_telescope_black_24dp.xml b/app/src/main/res/drawable/ic_telescope_black_24dp.xml new file mode 100644 index 000000000..9c6132ecc --- /dev/null +++ b/app/src/main/res/drawable/ic_telescope_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_telescope_white_24dp.xml b/app/src/main/res/drawable/ic_telescope_white_24dp.xml new file mode 100644 index 000000000..ea870fd87 --- /dev/null +++ b/app/src/main/res/drawable/ic_telescope_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_trending_up_black_24dp.xml b/app/src/main/res/drawable/ic_trending_up_black_24dp.xml new file mode 100644 index 000000000..706af95a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_trending_up_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_trending_up_white_24dp.xml b/app/src/main/res/drawable/ic_trending_up_white_24dp.xml new file mode 100644 index 000000000..403674223 --- /dev/null +++ b/app/src/main/res/drawable/ic_trending_up_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_videogame_black_24dp.xml b/app/src/main/res/drawable/ic_videogame_black_24dp.xml new file mode 100644 index 000000000..df872c96c --- /dev/null +++ b/app/src/main/res/drawable/ic_videogame_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_videogame_white_24dp.xml b/app/src/main/res/drawable/ic_videogame_white_24dp.xml new file mode 100644 index 000000000..593e49e14 --- /dev/null +++ b/app/src/main/res/drawable/ic_videogame_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_off_gray_24dp.xml b/app/src/main/res/drawable/ic_volume_off_gray_24dp.xml new file mode 100644 index 000000000..ade6bfec2 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_off_gray_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_up_black_24dp.xml b/app/src/main/res/drawable/ic_volume_up_black_24dp.xml new file mode 100644 index 000000000..bb0c74ba1 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_up_white_24dp.xml b/app/src/main/res/drawable/ic_volume_up_white_24dp.xml new file mode 100644 index 000000000..271540946 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_watch_later_black_24dp.xml b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml new file mode 100644 index 000000000..5a1b9ac74 --- /dev/null +++ b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_watch_later_white_24dp.xml b/app/src/main/res/drawable/ic_watch_later_white_24dp.xml new file mode 100644 index 000000000..f9fffbc43 --- /dev/null +++ b/app/src/main/res/drawable/ic_watch_later_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_work_black_24dp.xml b/app/src/main/res/drawable/ic_work_black_24dp.xml new file mode 100644 index 000000000..2668f2c43 --- /dev/null +++ b/app/src/main/res/drawable/ic_work_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_work_white_24dp.xml b/app/src/main/res/drawable/ic_work_white_24dp.xml new file mode 100644 index 000000000..8a1db7828 --- /dev/null +++ b/app/src/main/res/drawable/ic_work_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_world_black_24dp.xml b/app/src/main/res/drawable/ic_world_black_24dp.xml new file mode 100644 index 000000000..48785e7d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_world_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_world_white_24dp.xml b/app/src/main/res/drawable/ic_world_white_24dp.xml new file mode 100644 index 000000000..01583e467 --- /dev/null +++ b/app/src/main/res/drawable/ic_world_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/light_focused_selector.xml b/app/src/main/res/drawable/light_focused_selector.xml new file mode 100644 index 000000000..102f40d76 --- /dev/null +++ b/app/src/main/res/drawable/light_focused_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-large-land/activity_main_player.xml b/app/src/main/res/layout-large-land/activity_main_player.xml index 8e11b99f3..1499eec36 100644 --- a/app/src/main/res/layout-large-land/activity_main_player.xml +++ b/app/src/main/res/layout-large-land/activity_main_player.xml @@ -291,7 +291,7 @@ android:layout_height="wrap_content" android:layout_marginLeft="8dp" android:layout_marginRight="8dp" - android:layout_toLeftOf="@id/switchBackground" + android:layout_toLeftOf="@id/switchMute" android:layout_toRightOf="@id/resizeTextView" android:gravity="center|left" android:minHeight="35dp" @@ -389,6 +389,23 @@ android:background="?attr/selectableItemBackground" android:contentDescription="@string/switch_to_background" tools:ignore="RtlHardcoded"/> + + + + + + + + + + + + + + + + + + + + + + +