diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json new file mode 100644 index 000000000..aced06c0a --- /dev/null +++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json @@ -0,0 +1,730 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "7591e8039faa74d8c0517dc867af9d3e", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatar_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subscriberCount", + "columnName": "subscriber_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "notificationMode", + "columnName": "notification_mode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_subscriptions_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "creationDate", + "columnName": "creation_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_history_search", + "unique": false, + "columnNames": [ + "search" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "streams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "streamType", + "columnName": "stream_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploader_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "viewCount", + "columnName": "view_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "textualUploadDate", + "columnName": "textual_upload_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "upload_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isUploadDateApproximation", + "columnName": "is_upload_date_approximation", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_streams_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "stream_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessDate", + "columnName": "access_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatCount", + "columnName": "repeat_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "stream_id", + "access_date" + ] + }, + "indices": [ + { + "name": "index_stream_history_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "stream_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressMillis", + "columnName": "progress_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "stream_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL, `display_index` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isThumbnailPermanent", + "columnName": "is_thumbnail_permanent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailStreamId", + "columnName": "thumbnail_stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayIndex", + "columnName": "display_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist_stream_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "playlistUid", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "join_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "join_index" + ] + }, + "indices": [ + { + "name": "index_playlist_stream_join_playlist_id_join_index", + "unique": true, + "columnNames": [ + "playlist_id", + "join_index" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" + }, + { + "name": "index_playlist_stream_join_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "remote_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `display_index` INTEGER NOT NULL, `stream_count` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayIndex", + "columnName": "display_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamCount", + "columnName": "stream_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_remote_playlists_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "stream_id", + "subscription_id" + ] + }, + "indices": [ + { + "name": "index_feed_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_feed_group_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed_group_subscription_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "feedGroupId", + "columnName": "group_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "group_id", + "subscription_id" + ] + }, + "indices": [ + { + "name": "index_feed_group_subscription_join_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "feed_group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "group_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_last_updated", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscription_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "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, '7591e8039faa74d8c0517dc867af9d3e')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt index 65f41d8fa..a34cfece6 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt @@ -13,6 +13,8 @@ import org.junit.Assert.assertNull import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.schabi.newpipe.database.playlist.model.PlaylistEntity +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.stream.StreamType @@ -22,13 +24,17 @@ class DatabaseMigrationTest { 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 const val DEFAULT_NAME = "Test Name" private val DEFAULT_TYPE = StreamType.VIDEO_STREAM private const val DEFAULT_DURATION = 480L private const val DEFAULT_UPLOADER_NAME = "Uploader Test" private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg" - private const val DEFAULT_SECOND_SERVICE_ID = 0 + private const val DEFAULT_SECOND_SERVICE_ID = 1 private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc" + + private const val DEFAULT_THIRD_SERVICE_ID = 2 + private const val DEFAULT_THIRD_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" } @get:Rule @@ -115,6 +121,13 @@ class DatabaseMigrationTest { Migrations.MIGRATION_7_8 ) + testHelper.runMigrationsAndValidate( + AppDatabase.DATABASE_NAME, + Migrations.DB_VER_9, + true, + Migrations.MIGRATION_8_9 + ) + val migratedDatabaseV3 = getMigratedDatabase() val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst() @@ -198,6 +211,11 @@ class DatabaseMigrationTest { true, Migrations.MIGRATION_7_8 ) + testHelper.runMigrationsAndValidate( + AppDatabase.DATABASE_NAME, Migrations.DB_VER_9, + true, Migrations.MIGRATION_8_9 + ) + val migratedDatabaseV8 = getMigratedDatabase() val listFromDB = migratedDatabaseV8.searchHistoryDAO().all.blockingFirst() @@ -207,6 +225,94 @@ class DatabaseMigrationTest { assertNotEquals(listFromDB[0].serviceId, listFromDB[1].serviceId) } + @Test + fun migrateDatabaseFrom8to9() { + val databaseInV8 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_8) + + val localUid1: Long + val localUid2: Long + val remoteUid1: Long + val remoteUid2: Long + databaseInV8.run { + localUid1 = insert( + "playlists", SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("name", DEFAULT_NAME + "1") + put("is_thumbnail_permanent", false) + put("thumbnail_stream_id", -1) + } + ) + localUid2 = insert( + "playlists", SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("name", DEFAULT_NAME + "2") + put("is_thumbnail_permanent", false) + put("thumbnail_stream_id", -1) + } + ) + delete( + "playlists", "uid = ?", + Array(1) { localUid1 } + ) + remoteUid1 = insert( + "remote_playlists", SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("service_id", DEFAULT_SERVICE_ID) + put("url", DEFAULT_URL) + } + ) + remoteUid2 = insert( + "remote_playlists", SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("service_id", DEFAULT_SECOND_SERVICE_ID) + put("url", DEFAULT_SECOND_URL) + } + ) + delete( + "remote_playlists", "uid = ?", + Array(1) { remoteUid2 } + ) + close() + } + + testHelper.runMigrationsAndValidate( + AppDatabase.DATABASE_NAME, + Migrations.DB_VER_9, + true, + Migrations.MIGRATION_8_9 + ) + + val migratedDatabaseV9 = getMigratedDatabase() + var localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst() + var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst() + + assertEquals(1, localListFromDB.size) + assertEquals(localUid2, localListFromDB[0].uid) + assertEquals(-1, localListFromDB[0].displayIndex) + assertEquals(1, remoteListFromDB.size) + assertEquals(remoteUid1, remoteListFromDB[0].uid) + assertEquals(-1, remoteListFromDB[0].displayIndex) + + val localUid3 = migratedDatabaseV9.playlistDAO().insert( + PlaylistEntity(DEFAULT_NAME + "3", false, -1, -1) + ) + val remoteUid3 = migratedDatabaseV9.playlistRemoteDAO().insert( + PlaylistRemoteEntity( + DEFAULT_THIRD_SERVICE_ID, DEFAULT_NAME, DEFAULT_THIRD_URL, + DEFAULT_THUMBNAIL, DEFAULT_UPLOADER_NAME, -1, 10 + ) + ) + + localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst() + remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst() + assertEquals(2, localListFromDB.size) + assertEquals(localUid3, localListFromDB[1].uid) + assertEquals(-1, localListFromDB[1].displayIndex) + assertEquals(2, remoteListFromDB.size) + assertEquals(remoteUid3, remoteListFromDB[1].uid) + assertEquals(-1, remoteListFromDB[1].displayIndex) + } + private fun getMigratedDatabase(): AppDatabase { val database: AppDatabase = Room.databaseBuilder( ApplicationProvider.getApplicationContext(), diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java index c4f9feba7..21c5354f4 100644 --- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java @@ -8,6 +8,7 @@ import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5; import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6; import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7; import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8; +import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9; import android.content.Context; import android.database.Cursor; @@ -28,7 +29,7 @@ public final class NewPipeDatabase { return Room .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME) .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, - MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8) + MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9) .build(); } 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 d03823e66..04d93a238 100644 --- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java @@ -1,6 +1,6 @@ package org.schabi.newpipe.database; -import static org.schabi.newpipe.database.Migrations.DB_VER_8; +import static org.schabi.newpipe.database.Migrations.DB_VER_9; import androidx.room.Database; import androidx.room.RoomDatabase; @@ -38,7 +38,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity; FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, FeedLastUpdatedEntity.class }, - version = DB_VER_8 + version = DB_VER_9 ) public abstract class AppDatabase extends RoomDatabase { public static final String DATABASE_NAME = "newpipe.db"; 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 4b1a34dd6..c9f630869 100644 --- a/app/src/main/java/org/schabi/newpipe/database/Migrations.java +++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.java @@ -26,6 +26,7 @@ public final class Migrations { public static final int DB_VER_6 = 6; public static final int DB_VER_7 = 7; public static final int DB_VER_8 = 8; + public static final int DB_VER_9 = 9; private static final String TAG = Migrations.class.getName(); public static final boolean DEBUG = MainActivity.DEBUG; @@ -187,7 +188,7 @@ public final class Migrations { @Override public void migrate(@NonNull final SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " - + "INTEGER NOT NULL DEFAULT 0"); + + "INTEGER NOT NULL DEFAULT 0"); } }; @@ -245,6 +246,62 @@ public final class Migrations { } }; + public static final Migration MIGRATION_8_9 = new Migration(DB_VER_8, DB_VER_9) { + @Override + public void migrate(@NonNull final SupportSQLiteDatabase database) { + try { + database.beginTransaction(); + + // Update playlists. + // Create a temp table to initialize display_index. + database.execSQL("CREATE TABLE `playlists_tmp` " + + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, " + + "`thumbnail_stream_id` INTEGER NOT NULL, " + + "`display_index` INTEGER NOT NULL)"); + database.execSQL("INSERT INTO `playlists_tmp` " + + "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " + + "`display_index`) " + + "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " + + "-1 " + + "FROM `playlists`"); + + // Replace the old table, note that this also removes the index on the name which + // we don't need anymore. + database.execSQL("DROP TABLE `playlists`"); + database.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`"); + + + // Update remote_playlists. + // Create a temp table to initialize display_index. + database.execSQL("CREATE TABLE `remote_playlists_tmp` " + + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " + + "`thumbnail_url` TEXT, `uploader` TEXT, " + + "`display_index` INTEGER NOT NULL," + + "`stream_count` INTEGER)"); + database.execSQL("INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, " + + "`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, " + + "`stream_count`)" + + "SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, " + + "-1, `stream_count` FROM `remote_playlists`"); + + // Replace the old table, note that this also removes the index on the name which + // we don't need anymore. + database.execSQL("DROP TABLE `remote_playlists`"); + database.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`"); + + // Create index on the new table. + database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " + + "ON `remote_playlists` (`service_id`, `url`)"); + + database.setTransactionSuccessful(); + } finally { + database.endTransaction(); + } + } + }; + private Migrations() { } } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java index 0fcb4ced4..3be85e6e1 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java @@ -13,12 +13,17 @@ public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry { @ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED) public final long timesStreamIsContained; + @SuppressWarnings("checkstyle:ParameterNumber") public PlaylistDuplicatesEntry(final long uid, final String name, final String thumbnailUrl, + final boolean isThumbnailPermanent, + final long thumbnailStreamId, + final long displayIndex, final long streamCount, final long timesStreamIsContained) { - super(uid, name, thumbnailUrl, streamCount); + super(uid, name, thumbnailUrl, isThumbnailPermanent, thumbnailStreamId, displayIndex, + streamCount); this.timesStreamIsContained = timesStreamIsContained; } } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java index 695f9ec5a..072c49e2c 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java @@ -1,22 +1,13 @@ package org.schabi.newpipe.database.playlist; import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; - -import java.util.Comparator; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; public interface PlaylistLocalItem extends LocalItem { String getOrderingName(); - static List merge( - final List localPlaylists, - final List remotePlaylists) { - return Stream.concat(localPlaylists.stream(), remotePlaylists.stream()) - .sorted(Comparator.comparing(PlaylistLocalItem::getOrderingName, - Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))) - .collect(Collectors.toList()); - } + long getDisplayIndex(); + + long getUid(); + + void setDisplayIndex(long displayIndex); } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java index a13894030..03a1e1e30 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java @@ -2,27 +2,40 @@ package org.schabi.newpipe.database.playlist; import androidx.room.ColumnInfo; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL; public class PlaylistMetadataEntry implements PlaylistLocalItem { public static final String PLAYLIST_STREAM_COUNT = "streamCount"; @ColumnInfo(name = PLAYLIST_ID) - public final long uid; + private final long uid; @ColumnInfo(name = PLAYLIST_NAME) public final String name; + @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT) + private final boolean isThumbnailPermanent; + @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID) + private final long thumbnailStreamId; @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL) public final String thumbnailUrl; + @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX) + private long displayIndex; @ColumnInfo(name = PLAYLIST_STREAM_COUNT) public final long streamCount; public PlaylistMetadataEntry(final long uid, final String name, final String thumbnailUrl, - final long streamCount) { + final boolean isThumbnailPermanent, final long thumbnailStreamId, + final long displayIndex, final long streamCount) { this.uid = uid; this.name = name; this.thumbnailUrl = thumbnailUrl; + this.isThumbnailPermanent = isThumbnailPermanent; + this.thumbnailStreamId = thumbnailStreamId; + this.displayIndex = displayIndex; this.streamCount = streamCount; } @@ -35,4 +48,27 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem { public String getOrderingName() { return name; } + + public boolean isThumbnailPermanent() { + return isThumbnailPermanent; + } + + public long getThumbnailStreamId() { + return thumbnailStreamId; + } + + @Override + public long getDisplayIndex() { + return displayIndex; + } + + @Override + public long getUid() { + return uid; + } + + @Override + public void setDisplayIndex(final long displayIndex) { + this.displayIndex = displayIndex; + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java index 70aaa3b2d..d8071e0af 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java @@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.dao; import androidx.room.Dao; import androidx.room.Query; +import androidx.room.Transaction; import org.schabi.newpipe.database.BasicDAO; import org.schabi.newpipe.database.playlist.model.PlaylistEntity; @@ -36,4 +37,17 @@ public interface PlaylistDAO extends BasicDAO { @Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE) Flowable getCount(); + + @Transaction + default long upsertPlaylist(final PlaylistEntity playlist) { + final long playlistId = playlist.getUid(); + + if (playlistId == -1) { + // This situation is probably impossible. + return insert(playlist); + } else { + update(playlist); + return playlistId; + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java index 6bb849428..8ab8a2afd 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java @@ -11,6 +11,7 @@ import java.util.List; import io.reactivex.rxjava3.core.Flowable; +import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_DISPLAY_INDEX; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE; @@ -31,10 +32,18 @@ public interface PlaylistRemoteDAO extends BasicDAO { + " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") Flowable> listByService(int serviceId); + @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " + + REMOTE_PLAYLIST_ID + " = :playlistId") + Flowable> getPlaylist(long playlistId); + @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " + REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") Flowable> getPlaylist(long serviceId, String url); + @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + + " ORDER BY " + REMOTE_PLAYLIST_DISPLAY_INDEX) + Flowable> getPlaylists(); + @Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " + REMOTE_PLAYLIST_URL + " = :url " + "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java index 8aa1a0c8c..85b891770 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java @@ -18,10 +18,12 @@ import io.reactivex.rxjava3.core.Flowable; import static org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry.PLAYLIST_TIMES_STREAM_IS_CONTAINED; import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL; import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX; @@ -91,7 +93,9 @@ public interface PlaylistStreamDAO extends BasicDAO { Flowable> getOrderedStreamsOf(long playlistId); @Transaction - @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + "," + @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + + PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", " + + PLAYLIST_DISPLAY_INDEX + ", " + " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = " + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'" @@ -105,7 +109,7 @@ public interface PlaylistStreamDAO extends BasicDAO { + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE + " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID + " GROUP BY " + PLAYLIST_ID - + " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC") + + " ORDER BY " + PLAYLIST_DISPLAY_INDEX) Flowable> getPlaylistMetadata(); @RewriteQueriesToDropUnusedColumns @@ -126,8 +130,9 @@ public interface PlaylistStreamDAO extends BasicDAO { Flowable> getStreamsWithoutDuplicates(long playlistId); @Transaction - @Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", " - + PLAYLIST_NAME + ", " + @Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + + PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", " + + PLAYLIST_DISPLAY_INDEX + ", " + " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = " + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'" @@ -149,6 +154,6 @@ public interface PlaylistStreamDAO extends BasicDAO { + " AND :streamUrl = :streamUrl" + " GROUP BY " + JOIN_PLAYLIST_ID - + " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC") + + " ORDER BY " + PLAYLIST_DISPLAY_INDEX) Flowable> getPlaylistDuplicatesMetadata(String streamUrl); } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java index efb7278fd..e0c1a06b7 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java @@ -2,16 +2,15 @@ package org.schabi.newpipe.database.playlist.model; import androidx.room.ColumnInfo; import androidx.room.Entity; -import androidx.room.Index; +import androidx.room.Ignore; import androidx.room.PrimaryKey; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; import org.schabi.newpipe.R; +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; -@Entity(tableName = PLAYLIST_TABLE, - indices = {@Index(value = {PLAYLIST_NAME})}) +@Entity(tableName = PLAYLIST_TABLE) public class PlaylistEntity { public static final String DEFAULT_THUMBNAIL = "drawable://" @@ -22,6 +21,7 @@ public class PlaylistEntity { public static final String PLAYLIST_ID = "uid"; public static final String PLAYLIST_NAME = "name"; public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; + public static final String PLAYLIST_DISPLAY_INDEX = "display_index"; public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent"; public static final String PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id"; @@ -38,11 +38,24 @@ public class PlaylistEntity { @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID) private long thumbnailStreamId; + @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX) + private long displayIndex; + public PlaylistEntity(final String name, final boolean isThumbnailPermanent, - final long thumbnailStreamId) { + final long thumbnailStreamId, final long displayIndex) { this.name = name; this.isThumbnailPermanent = isThumbnailPermanent; this.thumbnailStreamId = thumbnailStreamId; + this.displayIndex = displayIndex; + } + + @Ignore + public PlaylistEntity(final PlaylistMetadataEntry item) { + this.uid = item.getUid(); + this.name = item.name; + this.isThumbnailPermanent = item.isThumbnailPermanent(); + this.thumbnailStreamId = item.getThumbnailStreamId(); + this.displayIndex = item.getDisplayIndex(); } public long getUid() { @@ -77,4 +90,11 @@ public class PlaylistEntity { this.isThumbnailPermanent = isThumbnailSet; } + public long getDisplayIndex() { + return displayIndex; + } + + public void setDisplayIndex(final long displayIndex) { + this.displayIndex = displayIndex; + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java index 7c6b4a8b0..60027a057 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java @@ -21,7 +21,6 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.RE @Entity(tableName = REMOTE_PLAYLIST_TABLE, indices = { - @Index(value = {REMOTE_PLAYLIST_NAME}), @Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true) }) public class PlaylistRemoteEntity implements PlaylistLocalItem { @@ -32,6 +31,7 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem { public static final String REMOTE_PLAYLIST_URL = "url"; public static final String REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; public static final String REMOTE_PLAYLIST_UPLOADER_NAME = "uploader"; + public static final String REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index"; public static final String REMOTE_PLAYLIST_STREAM_COUNT = "stream_count"; @PrimaryKey(autoGenerate = true) @@ -53,6 +53,9 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem { @ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME) private String uploader; + @ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX) + private long displayIndex = -1; // Make sure the new item is on the top + @ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT) private Long streamCount; @@ -67,6 +70,19 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem { this.streamCount = streamCount; } + @Ignore + public PlaylistRemoteEntity(final int serviceId, final String name, final String url, + final String thumbnailUrl, final String uploader, + final long displayIndex, final Long streamCount) { + this.serviceId = serviceId; + this.name = name; + this.url = url; + this.thumbnailUrl = thumbnailUrl; + this.uploader = uploader; + this.displayIndex = displayIndex; + this.streamCount = streamCount; + } + @Ignore public PlaylistRemoteEntity(final PlaylistInfo info) { this(info.getServiceId(), info.getName(), info.getUrl(), @@ -93,6 +109,7 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem { && TextUtils.equals(getUploader(), info.getUploaderName()); } + @Override public long getUid() { return uid; } @@ -141,6 +158,16 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem { this.uploader = uploader; } + @Override + public long getDisplayIndex() { + return displayIndex; + } + + @Override + public void setDisplayIndex(final long displayIndex) { + this.displayIndex = displayIndex; + } + public Long getStreamCount() { return streamCount; } 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 d50f0b0d8..52a41d38f 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java @@ -220,7 +220,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte public void commitPlaylistTabs() { pagerAdapter.getLocalPlaylistFragments() .stream() - .forEach(LocalPlaylistFragment::commitChanges); + .forEach(LocalPlaylistFragment::saveImmediate); } private void updateTabLayoutPosition() { @@ -282,7 +282,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte * Keep reference to LocalPlaylistFragments, because their data can be modified by the user * during runtime and changes are not committed immediately. However, in some cases, * the changes need to be committed immediately by calling - * {@link LocalPlaylistFragment#commitChanges()}. + * {@link LocalPlaylistFragment#saveImmediate()}. * The fragments are removed when {@link LocalPlaylistFragment#onDestroy()} is called. */ private final List localPlaylistFragments = new ArrayList<>(); 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 bcdeb7ef7..95b54f65a 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 @@ -482,7 +482,7 @@ public final class VideoDetailFragment // commit previous pending changes to database if (fragment instanceof LocalPlaylistFragment) { - ((LocalPlaylistFragment) fragment).commitChanges(); + ((LocalPlaylistFragment) fragment).saveImmediate(); } else if (fragment instanceof MainFragment) { ((MainFragment) fragment).commitPlaylistTabs(); } diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java index b9409cb9d..b33619dea 100644 --- a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java @@ -14,6 +14,7 @@ import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.info_list.ItemViewMode; import org.schabi.newpipe.local.history.HistoryRecordManager; +import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder; import org.schabi.newpipe.local.holder.LocalItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder; @@ -24,6 +25,7 @@ import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder; import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder; import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder; import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder; +import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder; import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder; import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder; import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder; @@ -73,10 +75,12 @@ public class LocalItemListAdapter extends RecyclerView.Adapter localItems; @@ -87,6 +91,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter, Void> { +public final class BookmarkFragment extends BaseLocalListFragment, Void> + implements DebounceSavable { + + private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; @State - protected Parcelable itemsListState; + Parcelable itemsListState; private Subscription databaseSubscription; private CompositeDisposable disposables = new CompositeDisposable(); private LocalPlaylistManager localPlaylistManager; private RemotePlaylistManager remotePlaylistManager; + private ItemTouchHelper itemTouchHelper; + + /* Have the bookmarked playlists been fully loaded from db */ + private AtomicBoolean isLoadingComplete; + + /* Gives enough time to avoid interrupting user sorting operations */ + @Nullable + private DebounceSaver debounceSaver; + + private List> deletedItems; /////////////////////////////////////////////////////////////////////////// // Fragment LifeCycle - Creation @@ -65,6 +86,11 @@ public final class BookmarkFragment extends BaseLocalListFragment(); } @Nullable @@ -91,10 +117,20 @@ public final class BookmarkFragment extends BaseLocalListFragment() { @Override public void selected(final LocalItem selectedItem) { @@ -102,7 +138,7 @@ public final class BookmarkFragment extends BaseLocalListFragment> getPlaylistsSubscriber() { - return new Subscriber>() { + return new Subscriber<>() { @Override public void onSubscribe(final Subscription s) { showLoading(); + isLoadingComplete.set(false); + if (databaseSubscription != null) { databaseSubscription.cancel(); } @@ -196,7 +258,10 @@ public final class BookmarkFragment extends BaseLocalListFragment subscriptions) { - handleResult(subscriptions); + if (debounceSaver == null || !debounceSaver.getIsModified()) { + handleResult(subscriptions); + isLoadingComplete.set(true); + } if (databaseSubscription != null) { databaseSubscription.request(1); } @@ -209,7 +274,8 @@ public final class BookmarkFragment extends BaseLocalListFragment { /*Do nothing on success*/ }, throwable -> showError( + new ErrorInfo(throwable, + UserAction.REQUESTED_BOOKMARK, + "Changing playlist name"))); + disposables.add(disposable); + } + + private void deleteItem(final PlaylistLocalItem item) { + if (itemListAdapter == null) { + return; + } + itemListAdapter.removeItem(item); + + if (item instanceof PlaylistMetadataEntry) { + deletedItems.add(new Pair<>(item.getUid(), + LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM)); + } else if (item instanceof PlaylistRemoteEntity) { + deletedItems.add(new Pair<>(item.getUid(), + LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM)); + } + + if (debounceSaver != null) { + debounceSaver.setHasChangesToSave(); + saveImmediate(); + } + } + + @Override + public void saveImmediate() { + if (itemListAdapter == null) { + return; + } + + // List must be loaded and modified in order to save + if (isLoadingComplete == null || debounceSaver == null + || !isLoadingComplete.get() || !debounceSaver.getIsModified()) { + return; + } + + final List items = itemListAdapter.getItemsList(); + final List localItemsUpdate = new ArrayList<>(); + final List localItemsDeleteUid = new ArrayList<>(); + final List remoteItemsUpdate = new ArrayList<>(); + final List remoteItemsDeleteUid = new ArrayList<>(); + + // Calculate display index + for (int i = 0; i < items.size(); i++) { + final LocalItem item = items.get(i); + + if (item instanceof PlaylistMetadataEntry + && ((PlaylistMetadataEntry) item).getDisplayIndex() != i) { + ((PlaylistMetadataEntry) item).setDisplayIndex(i); + localItemsUpdate.add((PlaylistMetadataEntry) item); + } else if (item instanceof PlaylistRemoteEntity + && ((PlaylistRemoteEntity) item).getDisplayIndex() != i) { + ((PlaylistRemoteEntity) item).setDisplayIndex(i); + remoteItemsUpdate.add((PlaylistRemoteEntity) item); + } + } + + // Find deleted items + for (final Pair item : deletedItems) { + if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM)) { + localItemsDeleteUid.add(item.first); + } else if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM)) { + remoteItemsDeleteUid.add(item.first); + } + } + + deletedItems.clear(); + + // 1. Update local playlists + // 2. Update remote playlists + // 3. Set NoChangesToSave + disposables.add(localPlaylistManager.updatePlaylists(localItemsUpdate, localItemsDeleteUid) + .mergeWith(remotePlaylistManager.updatePlaylists( + remoteItemsUpdate, remoteItemsDeleteUid)) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> { + if (debounceSaver != null) { + debounceSaver.setNoChangesToSave(); + } + }, + throwable -> showError(new ErrorInfo(throwable, + UserAction.REQUESTED_BOOKMARK, "Saving playlist")) + )); + + } + + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { + // if adding grid layout, also include ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT + // with an `if (shouldUseGridLayout()) ...` + return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, + ItemTouchHelper.ACTION_STATE_IDLE) { + @Override + public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, + final int viewSize, + final int viewSizeOutOfBounds, + final int totalSize, + final long msSinceStartScroll) { + final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, + viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll); + final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY, + Math.abs(standardSpeed)); + return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); + } + + @Override + public boolean onMove(@NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder source, + @NonNull final RecyclerView.ViewHolder target) { + + // Allow swap LocalBookmarkPlaylistItemHolder and RemoteBookmarkPlaylistItemHolder. + if (itemListAdapter == null + || source.getItemViewType() != target.getItemViewType() + && !( + ( + (source instanceof LocalBookmarkPlaylistItemHolder) + || (source instanceof RemoteBookmarkPlaylistItemHolder) + ) + && ( + (target instanceof LocalBookmarkPlaylistItemHolder) + || (target instanceof RemoteBookmarkPlaylistItemHolder) + )) + ) { + return false; + } + + final int sourceIndex = source.getBindingAdapterPosition(); + final int targetIndex = target.getBindingAdapterPosition(); + final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex); + if (isSwapped && debounceSaver != null) { + debounceSaver.setHasChangesToSave(); + } + return isSwapped; + } + + @Override + public boolean isLongPressDragEnabled() { + return false; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return false; + } + + @Override + public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, + final int swipeDir) { + // Do nothing. + } + }; + } + /////////////////////////////////////////////////////////////////////////// // Utils /////////////////////////////////////////////////////////////////////////// private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) { - showDeleteDialog(item.getName(), remotePlaylistManager.deletePlaylist(item.getUid())); + showDeleteDialog(item.getName(), item); } private void showLocalDialog(final PlaylistMetadataEntry selectedItem) { @@ -257,7 +494,7 @@ public final class BookmarkFragment extends BaseLocalListFragment items = new ArrayList<>(); items.add(rename); @@ -270,13 +507,12 @@ public final class BookmarkFragment extends BaseLocalListFragment changeLocalPlaylistName( - selectedItem.uid, + selectedItem.getUid(), dialogBinding.dialogEditText.getText().toString())) .setNegativeButton(R.string.cancel, null) .show(); } - private void showDeleteDialog(final String name, final Single deleteReactor) { + private void showDeleteDialog(final String name, final PlaylistLocalItem item) { if (activity == null || disposables == null) { return; } @@ -313,35 +549,8 @@ public final class BookmarkFragment extends BaseLocalListFragment - disposables.add(deleteReactor - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ignored -> { /*Do nothing on success*/ }, throwable -> - showError(new ErrorInfo(throwable, - UserAction.REQUESTED_BOOKMARK, - "Deleting playlist"))))) + .setPositiveButton(R.string.delete, (dialog, i) -> deleteItem(item)) .setNegativeButton(R.string.cancel, null) .show(); } - - private void changeLocalPlaylistName(final long id, final String name) { - if (localPlaylistManager == null) { - return; - } - - if (DEBUG) { - Log.d(TAG, "Updating playlist id=[" + id + "] " - + "with new name=[" + name + "] items"); - } - - localPlaylistManager.renamePlaylist(id, name); - final Disposable disposable = localPlaylistManager.renamePlaylist(id, name) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError( - new ErrorInfo(throwable, - UserAction.REQUESTED_BOOKMARK, - "Changing playlist name"))); - disposables.add(disposable); - } } - diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java new file mode 100644 index 000000000..25eb2f652 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java @@ -0,0 +1,95 @@ +package org.schabi.newpipe.local.bookmark; + +import org.schabi.newpipe.database.playlist.PlaylistLocalItem; +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.local.playlist.LocalPlaylistManager; +import org.schabi.newpipe.local.playlist.RemotePlaylistManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import io.reactivex.rxjava3.core.Flowable; + +/** + * Takes care of remote and local playlists at once, hence "merged". + */ +public final class MergedPlaylistManager { + + private MergedPlaylistManager() { + } + + public static Flowable> getMergedOrderedPlaylists( + final LocalPlaylistManager localPlaylistManager, + final RemotePlaylistManager remotePlaylistManager) { + return Flowable.combineLatest( + localPlaylistManager.getPlaylists(), + remotePlaylistManager.getPlaylists(), + MergedPlaylistManager::merge + ); + } + + /** + * Merge localPlaylists and remotePlaylists by the display index. + * If two items have the same display index, sort them in {@code CASE_INSENSITIVE_ORDER}. + * + * @param localPlaylists local playlists, already sorted by display index + * @param remotePlaylists remote playlists, already sorted by display index + * @return merged playlists + */ + public static List merge( + final List localPlaylists, + final List remotePlaylists) { + + // This algorithm is similar to the merge operation in merge sort. + final List result = new ArrayList<>( + localPlaylists.size() + remotePlaylists.size()); + final List itemsWithSameIndex = new ArrayList<>(); + + int i = 0; + int j = 0; + while (i < localPlaylists.size()) { + while (j < remotePlaylists.size()) { + if (remotePlaylists.get(j).getDisplayIndex() + <= localPlaylists.get(i).getDisplayIndex()) { + addItem(result, remotePlaylists.get(j), itemsWithSameIndex); + j++; + } else { + break; + } + } + addItem(result, localPlaylists.get(i), itemsWithSameIndex); + i++; + } + while (j < remotePlaylists.size()) { + addItem(result, remotePlaylists.get(j), itemsWithSameIndex); + j++; + } + addItemsWithSameIndex(result, itemsWithSameIndex); + + return result; + } + + private static void addItem(final List result, + final PlaylistLocalItem item, + final List itemsWithSameIndex) { + if (!itemsWithSameIndex.isEmpty() + && itemsWithSameIndex.get(0).getDisplayIndex() != item.getDisplayIndex()) { + // The new item has a different display index, add previous items with same + // index to the result. + addItemsWithSameIndex(result, itemsWithSameIndex); + itemsWithSameIndex.clear(); + } + itemsWithSameIndex.add(item); + } + + private static void addItemsWithSameIndex(final List result, + final List itemsWithSameIndex) { + Collections.sort(itemsWithSameIndex, + Comparator.comparing(PlaylistLocalItem::getOrderingName, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))); + result.addAll(itemsWithSameIndex); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java index b503c4e05..e7f73079f 100644 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java @@ -155,14 +155,14 @@ public final class PlaylistAppendDialog extends PlaylistDialog { final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT); - playlistDisposables.add(manager.appendToPlaylist(playlist.uid, streams) + playlistDisposables.add(manager.appendToPlaylist(playlist.getUid(), streams) .observeOn(AndroidSchedulers.mainThread()) .subscribe(ignored -> { successToast.show(); if (playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) { playlistDisposables.add(manager - .changePlaylistThumbnail(playlist.uid, streams.get(0).getUid(), + .changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(), false) .observeOn(AndroidSchedulers.mainThread()) .subscribe(ignore -> successToast.show())); diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java new file mode 100644 index 000000000..16130009b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java @@ -0,0 +1,54 @@ +package org.schabi.newpipe.local.holder; + +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipe.local.LocalItemBuilder; +import org.schabi.newpipe.local.history.HistoryRecordManager; + +import java.time.format.DateTimeFormatter; + +public class LocalBookmarkPlaylistItemHolder extends LocalPlaylistItemHolder { + private final View itemHandleView; + + public LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, + final ViewGroup parent) { + this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent); + } + + LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + itemHandleView = itemView.findViewById(R.id.itemHandle); + } + + @Override + public void updateFromItem(final LocalItem localItem, + final HistoryRecordManager historyRecordManager, + final DateTimeFormatter dateTimeFormatter) { + if (!(localItem instanceof PlaylistMetadataEntry)) { + return; + } + final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem; + + itemHandleView.setOnTouchListener(getOnTouchListener(item)); + + super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); + } + + private View.OnTouchListener getOnTouchListener(final PlaylistMetadataEntry item) { + return (view, motionEvent) -> { + view.performClick(); + if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null + && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { + itemBuilder.getOnItemSelectedListener().drag(item, + LocalBookmarkPlaylistItemHolder.this); + } + return false; + }; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java new file mode 100644 index 000000000..6d61d1e08 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java @@ -0,0 +1,54 @@ +package org.schabi.newpipe.local.holder; + +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.local.LocalItemBuilder; +import org.schabi.newpipe.local.history.HistoryRecordManager; + +import java.time.format.DateTimeFormatter; + +public class RemoteBookmarkPlaylistItemHolder extends RemotePlaylistItemHolder { + private final View itemHandleView; + + public RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, + final ViewGroup parent) { + this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent); + } + + RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + itemHandleView = itemView.findViewById(R.id.itemHandle); + } + + @Override + public void updateFromItem(final LocalItem localItem, + final HistoryRecordManager historyRecordManager, + final DateTimeFormatter dateTimeFormatter) { + if (!(localItem instanceof PlaylistRemoteEntity)) { + return; + } + final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem; + + itemHandleView.setOnTouchListener(getOnTouchListener(item)); + + super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); + } + + private View.OnTouchListener getOnTouchListener(final PlaylistRemoteEntity item) { + return (view, motionEvent) -> { + view.performClick(); + if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null + && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { + itemBuilder.getOnItemSelectedListener().drag(item, + RemoteBookmarkPlaylistItemHolder.this); + } + return false; + }; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java index d14c1a231..765732063 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java @@ -14,6 +14,7 @@ import org.schabi.newpipe.util.ServiceHelper; import java.time.format.DateTimeFormatter; public class RemotePlaylistItemHolder extends PlaylistItemHolder { + public RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, parent); 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 c4fe3b896..e2d0f5986 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 @@ -49,6 +49,8 @@ import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.debounce.DebounceSavable; +import org.schabi.newpipe.util.debounce.DebounceSaver; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; @@ -58,7 +60,6 @@ import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; @@ -68,12 +69,10 @@ import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.PublishSubject; public class LocalPlaylistFragment extends BaseLocalListFragment, Void> - implements PlaylistControlViewHolder { - /** Save the list 10 seconds after the last change occurred. */ - private static final long SAVE_DEBOUNCE_MILLIS = 10000; + implements PlaylistControlViewHolder, DebounceSavable { + private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; @State protected Long playlistId; @@ -90,13 +89,12 @@ public class LocalPlaylistFragment extends BaseLocalListFragment debouncedSaveSignal; private CompositeDisposable disposables; /** Whether the playlist has been fully loaded from db. */ private AtomicBoolean isLoadingComplete; - /** Whether the playlist has been modified (e.g. items reordered or deleted) */ - private AtomicBoolean isModified; + /** Used to debounce saving playlist edits to disk. */ + private DebounceSaver debounceSaver; /** Flag to prevent simultaneous rewrites of the playlist. */ private boolean isRewritingPlaylist = false; @@ -121,12 +119,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragmentCommit changes immediately if the playlist has been modified.

- * Delete operations and other modifications will be committed to ensure that the database - * is up to date, e.g. when the user adds the just deleted stream from another fragment. - */ - public void commitChanges() { - if (isModified != null && isModified.get()) { - saveImmediate(); - } - } - @Override protected void initListeners() { super.initListeners(); @@ -243,10 +229,13 @@ public class LocalPlaylistFragment extends BaseLocalListFragment streams) { // Skip handling the result after it has been modified - if (isModified == null || !isModified.get()) { + if (debounceSaver == null || !debounceSaver.getIsModified()) { handleResult(streams); isLoadingComplete.set(true); } @@ -495,7 +483,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment saveImmediate(), throwable -> - showError(new ErrorInfo(throwable, UserAction.SOMETHING_ELSE, - "Debounced saver"))); - } - - private void saveImmediate() { + /** + *

Commit changes immediately if the playlist has been modified.

+ * Delete operations and other modifications will be committed to ensure that the database + * is up to date, e.g. when the user adds the just deleted stream from another fragment. + */ + @Override + public void saveImmediate() { if (playlistManager == null || itemListAdapter == null) { return; } // List must be loaded and modified in order to save - if (isLoadingComplete == null || isModified == null - || !isLoadingComplete.get() || !isModified.get()) { - Log.w(TAG, "Attempting to save playlist when local playlist " - + "is not loaded or not modified: playlist id=[" + playlistId + "]"); + if (isLoadingComplete == null || debounceSaver == null + || !isLoadingComplete.get() || !debounceSaver.getIsModified()) { return; } @@ -740,8 +710,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment { - if (isModified != null) { - isModified.set(false); + if (debounceSaver != null) { + debounceSaver.setNoChangesToSave(); } }, throwable -> showError(new ErrorInfo(throwable, @@ -784,7 +754,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment database.runInTransaction(() -> { final List streamIds = streamTable.upsertAll(streams); final PlaylistEntity newPlaylist = new PlaylistEntity(name, false, - streamIds.get(0)); + streamIds.get(0), -1); return insertJoinEntities(playlistTable.insert(newPlaylist), streamIds, 0); @@ -89,8 +91,20 @@ public class LocalPlaylistManager { })).subscribeOn(Schedulers.io()); } - public Flowable> getPlaylists() { - return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io()); + public Completable updatePlaylists(final List updateItems, + final List deletedItems) { + final List items = new ArrayList<>(updateItems.size()); + for (final PlaylistMetadataEntry item : updateItems) { + items.add(new PlaylistEntity(item)); + } + return Completable.fromRunnable(() -> database.runInTransaction(() -> { + for (final Long uid : deletedItems) { + playlistTable.deletePlaylist(uid); + } + for (final PlaylistEntity item : items) { + playlistTable.upsertPlaylist(item); + } + })).subscribeOn(Schedulers.io()); } public Flowable> getDistinctPlaylistStreams(final long playlistId) { @@ -110,13 +124,12 @@ public class LocalPlaylistManager { .subscribeOn(Schedulers.io()); } - public Flowable> getPlaylistStreams(final long playlistId) { - return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io()); + public Flowable> getPlaylists() { + return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io()); } - public Single deletePlaylist(final long playlistId) { - return Single.fromCallable(() -> playlistTable.deletePlaylist(playlistId)) - .subscribeOn(Schedulers.io()); + public Flowable> getPlaylistStreams(final long playlistId) { + return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io()); } public Maybe renamePlaylist(final long playlistId, final String name) { diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java index 5221139e3..4cc51f752 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java @@ -7,20 +7,23 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import java.util.List; +import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; public class RemotePlaylistManager { + private final AppDatabase database; private final PlaylistRemoteDAO playlistRemoteTable; public RemotePlaylistManager(final AppDatabase db) { + database = db; playlistRemoteTable = db.playlistRemoteDAO(); } public Flowable> getPlaylists() { - return playlistRemoteTable.getAll().subscribeOn(Schedulers.io()); + return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io()); } public Flowable> getPlaylist(final PlaylistInfo info) { @@ -33,6 +36,18 @@ public class RemotePlaylistManager { .subscribeOn(Schedulers.io()); } + public Completable updatePlaylists(final List updateItems, + final List deletedItems) { + return Completable.fromRunnable(() -> database.runInTransaction(() -> { + for (final Long uid: deletedItems) { + playlistRemoteTable.deletePlaylist(uid); + } + for (final PlaylistRemoteEntity item: updateItems) { + playlistRemoteTable.upsert(item); + } + })).subscribeOn(Schedulers.io()); + } + public Single onBookmark(final PlaylistInfo playlistInfo) { return Single.fromCallable(() -> { final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo); diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java index 147d20a36..36abef9e5 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.settings; +import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists; + import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -31,7 +33,6 @@ import java.util.List; import java.util.Vector; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.disposables.Disposable; public class SelectPlaylistFragment extends DialogFragment { @@ -90,8 +91,7 @@ public class SelectPlaylistFragment extends DialogFragment { final LocalPlaylistManager localPlaylistManager = new LocalPlaylistManager(database); final RemotePlaylistManager remotePlaylistManager = new RemotePlaylistManager(database); - disposable = Flowable.combineLatest(localPlaylistManager.getPlaylists(), - remotePlaylistManager.getPlaylists(), PlaylistLocalItem::merge) + disposable = getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager) .observeOn(AndroidSchedulers.mainThread()) .subscribe(this::displayPlaylists, this::onError); } @@ -118,7 +118,7 @@ public class SelectPlaylistFragment extends DialogFragment { if (selectedItem instanceof PlaylistMetadataEntry) { final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); - onSelectedListener.onLocalPlaylistSelected(entry.uid, entry.name); + onSelectedListener.onLocalPlaylistSelected(entry.getUid(), entry.name); } else if (selectedItem instanceof PlaylistRemoteEntity) { final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); diff --git a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java new file mode 100644 index 000000000..acc515dd6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java @@ -0,0 +1,15 @@ +package org.schabi.newpipe.util.debounce; + +import org.schabi.newpipe.error.ErrorInfo; + +public interface DebounceSavable { + + /** + * Execute operations to save the data.
+ * Must set {@link DebounceSaver#setIsModified(boolean)} false in this method manually + * after the data has been saved. + */ + void saveImmediate(); + + void showError(ErrorInfo errorInfo); +} diff --git a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java new file mode 100644 index 000000000..5bd5cdd55 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java @@ -0,0 +1,81 @@ +package org.schabi.newpipe.util.debounce; + +import org.schabi.newpipe.error.ErrorInfo; +import org.schabi.newpipe.error.UserAction; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.subjects.PublishSubject; + +public class DebounceSaver { + + private final long saveDebounceMillis; + + private final PublishSubject debouncedSaveSignal; + + private final DebounceSavable debounceSavable; + + // Has the object been modified + private final AtomicBoolean isModified; + + // Default 10 seconds + private static final long DEFAULT_SAVE_DEBOUNCE_MILLIS = 10000; + + + /** + * Creates a new {@code DebounceSaver}. + * + * @param saveDebounceMillis Save the object milliseconds later after the last change + * occurred. + * @param debounceSavable The object containing data to be saved. + */ + public DebounceSaver(final long saveDebounceMillis, final DebounceSavable debounceSavable) { + this.saveDebounceMillis = saveDebounceMillis; + debouncedSaveSignal = PublishSubject.create(); + this.debounceSavable = debounceSavable; + this.isModified = new AtomicBoolean(); + } + + /** + * Creates a new {@code DebounceSaver}. Save the object 10 seconds later after the last change + * occurred. + * + * @param debounceSavable The object containing data to be saved. + */ + public DebounceSaver(final DebounceSavable debounceSavable) { + this(DEFAULT_SAVE_DEBOUNCE_MILLIS, debounceSavable); + } + + public boolean getIsModified() { + return isModified.get(); + } + + public void setNoChangesToSave() { + isModified.set(false); + } + + public PublishSubject getDebouncedSaveSignal() { + return debouncedSaveSignal; + } + + public Disposable getDebouncedSaver() { + return debouncedSaveSignal + .debounce(saveDebounceMillis, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> debounceSavable.saveImmediate(), throwable -> + debounceSavable.showError(new ErrorInfo(throwable, + UserAction.SOMETHING_ELSE, "Debounced saver"))); + } + + public void setHasChangesToSave() { + if (isModified == null || debouncedSaveSignal == null) { + return; + } + + isModified.set(true); + debouncedSaveSignal.onNext(System.currentTimeMillis()); + } +} diff --git a/app/src/main/res/layout/list_playlist_bookmark_item.xml b/app/src/main/res/layout/list_playlist_bookmark_item.xml new file mode 100644 index 000000000..6aabd4d07 --- /dev/null +++ b/app/src/main/res/layout/list_playlist_bookmark_item.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + diff --git a/app/src/test/java/org/schabi/newpipe/database/playlist/PlaylistLocalItemTest.java b/app/src/test/java/org/schabi/newpipe/database/playlist/PlaylistLocalItemTest.java new file mode 100644 index 000000000..847c54aa8 --- /dev/null +++ b/app/src/test/java/org/schabi/newpipe/database/playlist/PlaylistLocalItemTest.java @@ -0,0 +1,106 @@ +package org.schabi.newpipe.database.playlist; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.local.bookmark.MergedPlaylistManager; + +import java.util.ArrayList; +import java.util.List; + +public class PlaylistLocalItemTest { + @Test + public void emptyPlaylists() { + final List localPlaylists = new ArrayList<>(); + final List remotePlaylists = new ArrayList<>(); + final List mergedPlaylists = + MergedPlaylistManager.merge(localPlaylists, remotePlaylists); + + assertEquals(0, mergedPlaylists.size()); + } + + @Test + public void onlyLocalPlaylists() { + final List localPlaylists = new ArrayList<>(); + final List remotePlaylists = new ArrayList<>(); + localPlaylists.add(new PlaylistMetadataEntry(1, "name1", "", false, -1, 0, 1)); + localPlaylists.add(new PlaylistMetadataEntry(2, "name2", "", false, -1, 1, 1)); + localPlaylists.add(new PlaylistMetadataEntry(3, "name3", "", false, -1, 3, 1)); + final List mergedPlaylists = + MergedPlaylistManager.merge(localPlaylists, remotePlaylists); + + assertEquals(3, mergedPlaylists.size()); + assertEquals(0, mergedPlaylists.get(0).getDisplayIndex()); + assertEquals(1, mergedPlaylists.get(1).getDisplayIndex()); + assertEquals(3, mergedPlaylists.get(2).getDisplayIndex()); + } + + @Test + public void onlyRemotePlaylists() { + final List localPlaylists = new ArrayList<>(); + final List remotePlaylists = new ArrayList<>(); + remotePlaylists.add(new PlaylistRemoteEntity( + 1, "name1", "url1", "", "", 1, 1L)); + remotePlaylists.add(new PlaylistRemoteEntity( + 2, "name2", "url2", "", "", 2, 1L)); + remotePlaylists.add(new PlaylistRemoteEntity( + 3, "name3", "url3", "", "", 4, 1L)); + final List mergedPlaylists = + MergedPlaylistManager.merge(localPlaylists, remotePlaylists); + + assertEquals(3, mergedPlaylists.size()); + assertEquals(1, mergedPlaylists.get(0).getDisplayIndex()); + assertEquals(2, mergedPlaylists.get(1).getDisplayIndex()); + assertEquals(4, mergedPlaylists.get(2).getDisplayIndex()); + } + + @Test + public void sameIndexWithDifferentName() { + final List localPlaylists = new ArrayList<>(); + final List remotePlaylists = new ArrayList<>(); + localPlaylists.add(new PlaylistMetadataEntry(1, "name1", "", false, -1, 0, 1)); + localPlaylists.add(new PlaylistMetadataEntry(2, "name2", "", false, -1, 1, 1)); + remotePlaylists.add(new PlaylistRemoteEntity( + 1, "name3", "url1", "", "", 0, 1L)); + remotePlaylists.add(new PlaylistRemoteEntity( + 2, "name4", "url2", "", "", 1, 1L)); + final List mergedPlaylists = + MergedPlaylistManager.merge(localPlaylists, remotePlaylists); + + assertEquals(4, mergedPlaylists.size()); + assertTrue(mergedPlaylists.get(0) instanceof PlaylistMetadataEntry); + assertEquals("name1", ((PlaylistMetadataEntry) mergedPlaylists.get(0)).name); + assertTrue(mergedPlaylists.get(1) instanceof PlaylistRemoteEntity); + assertEquals("name3", ((PlaylistRemoteEntity) mergedPlaylists.get(1)).getName()); + assertTrue(mergedPlaylists.get(2) instanceof PlaylistMetadataEntry); + assertEquals("name2", ((PlaylistMetadataEntry) mergedPlaylists.get(2)).name); + assertTrue(mergedPlaylists.get(3) instanceof PlaylistRemoteEntity); + assertEquals("name4", ((PlaylistRemoteEntity) mergedPlaylists.get(3)).getName()); + } + + @Test + public void sameNameWithDifferentIndex() { + final List localPlaylists = new ArrayList<>(); + final List remotePlaylists = new ArrayList<>(); + localPlaylists.add(new PlaylistMetadataEntry(1, "name1", "", false, -1, 1, 1)); + localPlaylists.add(new PlaylistMetadataEntry(2, "name2", "", false, -1, 3, 1)); + remotePlaylists.add(new PlaylistRemoteEntity( + 1, "name1", "url1", "", "", 0, 1L)); + remotePlaylists.add(new PlaylistRemoteEntity( + 2, "name2", "url2", "", "", 2, 1L)); + final List mergedPlaylists = + MergedPlaylistManager.merge(localPlaylists, remotePlaylists); + + assertEquals(4, mergedPlaylists.size()); + assertTrue(mergedPlaylists.get(0) instanceof PlaylistRemoteEntity); + assertEquals("name1", ((PlaylistRemoteEntity) mergedPlaylists.get(0)).getName()); + assertTrue(mergedPlaylists.get(1) instanceof PlaylistMetadataEntry); + assertEquals("name1", ((PlaylistMetadataEntry) mergedPlaylists.get(1)).name); + assertTrue(mergedPlaylists.get(2) instanceof PlaylistRemoteEntity); + assertEquals("name2", ((PlaylistRemoteEntity) mergedPlaylists.get(2)).getName()); + assertTrue(mergedPlaylists.get(3) instanceof PlaylistMetadataEntry); + assertEquals("name2", ((PlaylistMetadataEntry) mergedPlaylists.get(3)).name); + } +}