diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 157de064..5c12c5b3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10901 - versionName = "1.9.1-beta.1" + versionCode = 10902 + versionName = "1.9.1-beta.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/app/schemas/dev.typetype.android.data.database.TypeTypeDatabase/24.json b/app/schemas/dev.typetype.android.data.database.TypeTypeDatabase/24.json new file mode 100644 index 00000000..790ddbae --- /dev/null +++ b/app/schemas/dev.typetype.android.data.database.TypeTypeDatabase/24.json @@ -0,0 +1,2291 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "6b946f3c1c6c852d9b6276710462a356", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `displayName` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, `tagline` TEXT, `version` TEXT NOT NULL DEFAULT '', `revision` TEXT NOT NULL DEFAULT '', `apiVersion` INTEGER NOT NULL DEFAULT 0, `logoUrl` TEXT, `bannerUrl` TEXT, `supportedServicesCsv` TEXT NOT NULL DEFAULT '', `minAndroidClientVersion` TEXT, `registrationAllowed` INTEGER NOT NULL DEFAULT 0, `guestAllowed` INTEGER NOT NULL DEFAULT 0, `localLoginEnabled` INTEGER NOT NULL DEFAULT 1, `oidcEnabled` INTEGER NOT NULL DEFAULT 0, `oidcProviderName` TEXT, `oidcAutoRedirect` INTEGER NOT NULL DEFAULT 0, `youtubeRemoteLoginSupported` INTEGER NOT NULL DEFAULT 0, `youtubeRemoteLoginEnabled` INTEGER NOT NULL DEFAULT 0, `youtubeRemoteLoginReady` INTEGER NOT NULL DEFAULT 0, `youtubeRemoteLoginUnavailableReason` TEXT, `rssEnabled` INTEGER NOT NULL DEFAULT 0, `rssMaxFeedsPerUser` INTEGER NOT NULL DEFAULT 0, `rssMaxItems` INTEGER NOT NULL DEFAULT 0, `rssMinimumPollMinutes` INTEGER NOT NULL DEFAULT 0, `rssRateLimitPerMinute` INTEGER NOT NULL DEFAULT 0, `pushEnabled` INTEGER NOT NULL DEFAULT 0, `pushProvider` TEXT NOT NULL DEFAULT 'unifiedpush', `pushEventTypesCsv` TEXT NOT NULL DEFAULT '', `pushMaxDevicesPerAccount` INTEGER NOT NULL DEFAULT 0, `recommenderInstalled` INTEGER NOT NULL DEFAULT 0, `recommenderEnabled` INTEGER NOT NULL DEFAULT 0, `recommenderExperimental` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "apiVersion", + "columnName": "apiVersion", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "logoUrl", + "columnName": "logoUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "supportedServicesCsv", + "columnName": "supportedServicesCsv", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "minAndroidClientVersion", + "columnName": "minAndroidClientVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "registrationAllowed", + "columnName": "registrationAllowed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "guestAllowed", + "columnName": "guestAllowed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "localLoginEnabled", + "columnName": "localLoginEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "oidcEnabled", + "columnName": "oidcEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "oidcProviderName", + "columnName": "oidcProviderName", + "affinity": "TEXT" + }, + { + "fieldPath": "oidcAutoRedirect", + "columnName": "oidcAutoRedirect", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "youtubeRemoteLoginSupported", + "columnName": "youtubeRemoteLoginSupported", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "youtubeRemoteLoginEnabled", + "columnName": "youtubeRemoteLoginEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "youtubeRemoteLoginReady", + "columnName": "youtubeRemoteLoginReady", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "youtubeRemoteLoginUnavailableReason", + "columnName": "youtubeRemoteLoginUnavailableReason", + "affinity": "TEXT" + }, + { + "fieldPath": "rssEnabled", + "columnName": "rssEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssMaxFeedsPerUser", + "columnName": "rssMaxFeedsPerUser", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssMaxItems", + "columnName": "rssMaxItems", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssMinimumPollMinutes", + "columnName": "rssMinimumPollMinutes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssRateLimitPerMinute", + "columnName": "rssRateLimitPerMinute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pushEnabled", + "columnName": "pushEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pushProvider", + "columnName": "pushProvider", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'unifiedpush'" + }, + { + "fieldPath": "pushEventTypesCsv", + "columnName": "pushEventTypesCsv", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "pushMaxDevicesPerAccount", + "columnName": "pushMaxDevicesPerAccount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "recommenderInstalled", + "columnName": "recommenderInstalled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "recommenderEnabled", + "columnName": "recommenderEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "recommenderExperimental", + "columnName": "recommenderExperimental", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `favoritedAtMillis` INTEGER NOT NULL, `title` TEXT NOT NULL DEFAULT '', `thumbnailUrl` TEXT NOT NULL DEFAULT '', `durationSeconds` INTEGER NOT NULL DEFAULT 0, `channelName` TEXT NOT NULL DEFAULT '', `channelUrl` TEXT NOT NULL DEFAULT '', `channelAvatarUrl` TEXT NOT NULL DEFAULT '', `viewCount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`serverId`, `accountId`, `videoUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "favoritedAtMillis", + "columnName": "favoritedAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelAvatarUrl", + "columnName": "channelAvatarUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "videoUrl" + ] + }, + "indices": [ + { + "name": "index_favorites_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `id` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `channelName` TEXT NOT NULL, `channelUrl` TEXT NOT NULL DEFAULT '', `channelAvatarUrl` TEXT NOT NULL DEFAULT '', `durationSeconds` INTEGER NOT NULL, `progressSeconds` INTEGER NOT NULL, `watchedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `id`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelAvatarUrl", + "columnName": "channelAvatarUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressSeconds", + "columnName": "progressSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "watchedAtMillis", + "columnName": "watchedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "id" + ] + }, + "indices": [ + { + "name": "index_history_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_history_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + }, + { + "name": "index_history_serverId_accountId_url", + "unique": false, + "columnNames": [ + "serverId", + "accountId", + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_history_serverId_accountId_url` ON `${TABLE_NAME}` (`serverId`, `accountId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "watch_later", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `addedAtMillis` INTEGER NOT NULL, `channelName` TEXT NOT NULL DEFAULT '', `channelUrl` TEXT NOT NULL DEFAULT '', `channelAvatarUrl` TEXT NOT NULL DEFAULT '', `viewCount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`serverId`, `accountId`, `url`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAtMillis", + "columnName": "addedAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelAvatarUrl", + "columnName": "channelAvatarUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "url" + ] + }, + "indices": [ + { + "name": "index_watch_later_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_watch_later_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `createdAtMillis` INTEGER NOT NULL, `videoCount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`cacheKey`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoCount", + "columnName": "videoCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "cacheKey" + ] + }, + "indices": [ + { + "name": "index_playlists_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + }, + { + "name": "index_playlists_serverId_accountId_id", + "unique": true, + "columnNames": [ + "serverId", + "accountId", + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_serverId_accountId_id` ON `${TABLE_NAME}` (`serverId`, `accountId`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "playlist_videos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistCacheKey` TEXT NOT NULL, `playlistId` TEXT NOT NULL, `id` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `position` INTEGER NOT NULL, `channelName` TEXT NOT NULL DEFAULT '', `channelUrl` TEXT NOT NULL DEFAULT '', `channelAvatarUrl` TEXT NOT NULL DEFAULT '', `viewCount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`playlistCacheKey`, `id`), FOREIGN KEY(`playlistCacheKey`) REFERENCES `playlists`(`cacheKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistCacheKey", + "columnName": "playlistCacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelAvatarUrl", + "columnName": "channelAvatarUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlistCacheKey", + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_videos_playlistCacheKey", + "unique": false, + "columnNames": [ + "playlistCacheKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_videos_playlistCacheKey` ON `${TABLE_NAME}` (`playlistCacheKey`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistCacheKey" + ], + "referencedColumns": [ + "cacheKey" + ] + } + ] + }, + { + "tableName": "video_meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `channelName` TEXT NOT NULL, `channelUrl` TEXT NOT NULL, `channelAvatarUrl` TEXT NOT NULL, `viewCount` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `videoUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelAvatarUrl", + "columnName": "channelAvatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "videoUrl" + ] + }, + "indices": [ + { + "name": "index_video_meta_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_video_meta_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `publicUsername` TEXT, `role` TEXT, `avatarUrl` TEXT, `avatarType` TEXT, `avatarCode` TEXT, `isGuest` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `sessionGeneration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`serverId`, `accountId`), FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicUsername", + "columnName": "publicUsername", + "affinity": "TEXT" + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarType", + "columnName": "avatarType", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarCode", + "columnName": "avatarCode", + "affinity": "TEXT" + }, + { + "fieldPath": "isGuest", + "columnName": "isGuest", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUsedAt", + "columnName": "lastUsedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionGeneration", + "columnName": "sessionGeneration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId" + ] + }, + "indices": [ + { + "name": "index_accounts_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sessionGeneration` INTEGER NOT NULL DEFAULT 0, `requestId` TEXT NOT NULL, `workId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `quality` TEXT NOT NULL, `serverJobId` TEXT, `systemDownloadId` INTEGER, `fileName` TEXT, `status` TEXT NOT NULL, `progressPercent` INTEGER, `stage` TEXT, `errorMessage` TEXT, `cached` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `requestId`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionGeneration", + "columnName": "sessionGeneration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "requestId", + "columnName": "requestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workId", + "columnName": "workId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverJobId", + "columnName": "serverJobId", + "affinity": "TEXT" + }, + { + "fieldPath": "systemDownloadId", + "columnName": "systemDownloadId", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progressPercent", + "columnName": "progressPercent", + "affinity": "INTEGER" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT" + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "cached", + "columnName": "cached", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "requestId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + }, + { + "name": "index_downloads_workId", + "unique": true, + "columnNames": [ + "workId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_workId` ON `${TABLE_NAME}` (`workId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "progress_outbox", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `positionMillis` INTEGER NOT NULL, `sessionGeneration` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `videoUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "positionMillis", + "columnName": "positionMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionGeneration", + "columnName": "sessionGeneration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "videoUrl" + ] + }, + "indices": [ + { + "name": "index_progress_outbox_serverId_accountId_sessionGeneration", + "unique": false, + "columnNames": [ + "serverId", + "accountId", + "sessionGeneration" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_progress_outbox_serverId_accountId_sessionGeneration` ON `${TABLE_NAME}` (`serverId`, `accountId`, `sessionGeneration`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "library_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `collection` TEXT NOT NULL, `refreshGeneration` INTEGER NOT NULL, `lastAttemptAtMillis` INTEGER NOT NULL, `lastSuccessAtMillis` INTEGER, `lastFailureAtMillis` INTEGER, `failureCode` TEXT, `failureStatusCode` INTEGER, `requestId` TEXT, PRIMARY KEY(`serverId`, `accountId`, `collection`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "collection", + "columnName": "collection", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshGeneration", + "columnName": "refreshGeneration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMillis", + "columnName": "lastAttemptAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSuccessAtMillis", + "columnName": "lastSuccessAtMillis", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastFailureAtMillis", + "columnName": "lastFailureAtMillis", + "affinity": "INTEGER" + }, + { + "fieldPath": "failureCode", + "columnName": "failureCode", + "affinity": "TEXT" + }, + { + "fieldPath": "failureStatusCode", + "columnName": "failureStatusCode", + "affinity": "INTEGER" + }, + { + "fieldPath": "requestId", + "columnName": "requestId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "collection" + ] + }, + "indices": [ + { + "name": "index_library_sync_state_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_sync_state_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "library_mutation_outbox", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `mutationKey` TEXT NOT NULL, `collection` TEXT NOT NULL, `kind` TEXT NOT NULL, `targetId` TEXT NOT NULL, `parentId` TEXT, `desiredPresent` INTEGER NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `channelName` TEXT NOT NULL, `channelUrl` TEXT NOT NULL, `channelAvatarUrl` TEXT NOT NULL, `viewCount` INTEGER NOT NULL, `sessionGeneration` INTEGER NOT NULL, `mutationVersion` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAtMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, `lastAttemptAtMillis` INTEGER, `attemptCount` INTEGER NOT NULL, `failureCode` TEXT, `failureStatusCode` INTEGER, `requestId` TEXT, PRIMARY KEY(`serverId`, `accountId`, `mutationKey`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mutationKey", + "columnName": "mutationKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "collection", + "columnName": "collection", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT" + }, + { + "fieldPath": "desiredPresent", + "columnName": "desiredPresent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelAvatarUrl", + "columnName": "channelAvatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionGeneration", + "columnName": "sessionGeneration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mutationVersion", + "columnName": "mutationVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMillis", + "columnName": "lastAttemptAtMillis", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failureCode", + "columnName": "failureCode", + "affinity": "TEXT" + }, + { + "fieldPath": "failureStatusCode", + "columnName": "failureStatusCode", + "affinity": "INTEGER" + }, + { + "fieldPath": "requestId", + "columnName": "requestId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "mutationKey" + ] + }, + "indices": [ + { + "name": "index_library_mutation_outbox_serverId_accountId_sessionGeneration_state", + "unique": false, + "columnNames": [ + "serverId", + "accountId", + "sessionGeneration", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_mutation_outbox_serverId_accountId_sessionGeneration_state` ON `${TABLE_NAME}` (`serverId`, `accountId`, `sessionGeneration`, `state`)" + }, + { + "name": "index_library_mutation_outbox_serverId_accountId_collection", + "unique": false, + "columnNames": [ + "serverId", + "accountId", + "collection" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_mutation_outbox_serverId_accountId_collection` ON `${TABLE_NAME}` (`serverId`, `accountId`, `collection`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `channelUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `avatarUrl` TEXT NOT NULL, `subscribedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `channelUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscribedAtMillis", + "columnName": "subscribedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "channelUrl" + ] + }, + "indices": [ + { + "name": "index_subscriptions_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subscriptions_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "playback_resume", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `positionMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "positionMillis", + "columnName": "positionMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId" + ] + }, + "indices": [ + { + "name": "index_playback_resume_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_resume_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "playback_queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `position` INTEGER NOT NULL, `currentIndex` INTEGER NOT NULL, `queueTitle` TEXT NOT NULL, `repeatMode` TEXT NOT NULL DEFAULT 'Off', `videoUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `channelName` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `position`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentIndex", + "columnName": "currentIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "queueTitle", + "columnName": "queueTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repeatMode", + "columnName": "repeatMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'Off'" + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "position" + ] + }, + "indices": [ + { + "name": "index_playback_queue_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_queue_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "saved_public_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `id` TEXT NOT NULL, `publicPlaylistId` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `uploaderName` TEXT NOT NULL, `streamCount` INTEGER NOT NULL, `playlistType` TEXT NOT NULL, `savedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `id`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicPlaylistId", + "columnName": "publicPlaylistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderName", + "columnName": "uploaderName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "streamCount", + "columnName": "streamCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistType", + "columnName": "playlistType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "savedAtMillis", + "columnName": "savedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "id" + ] + }, + "indices": [ + { + "name": "index_saved_public_playlists_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_saved_public_playlists_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + }, + { + "name": "index_saved_public_playlists_serverId_accountId_url", + "unique": true, + "columnNames": [ + "serverId", + "accountId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_saved_public_playlists_serverId_accountId_url` ON `${TABLE_NAME}` (`serverId`, `accountId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "feed_videos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `feed` TEXT NOT NULL, `position` INTEGER NOT NULL, `videoUrl` TEXT NOT NULL, `videoId` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `uploaderName` TEXT NOT NULL, `uploaderUrl` TEXT NOT NULL, `uploaderAvatarUrl` TEXT NOT NULL, `uploaderVerified` INTEGER NOT NULL, `durationSeconds` INTEGER NOT NULL, `isLive` INTEGER NOT NULL, `viewCount` INTEGER NOT NULL, `uploadedAtMillis` INTEGER NOT NULL, `isShortFormContent` INTEGER NOT NULL, `shortDescription` TEXT, `publishedAtMillis` INTEGER, `isPostLive` INTEGER NOT NULL, `isLiveContent` INTEGER NOT NULL, `requiresMembership` INTEGER NOT NULL, `savedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `feed`, `videoUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feed", + "columnName": "feed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderName", + "columnName": "uploaderName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderAvatarUrl", + "columnName": "uploaderAvatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderVerified", + "columnName": "uploaderVerified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLive", + "columnName": "isLive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadedAtMillis", + "columnName": "uploadedAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShortFormContent", + "columnName": "isShortFormContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "publishedAtMillis", + "columnName": "publishedAtMillis", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPostLive", + "columnName": "isPostLive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLiveContent", + "columnName": "isLiveContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresMembership", + "columnName": "requiresMembership", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAtMillis", + "columnName": "savedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "feed", + "videoUrl" + ] + }, + "indices": [ + { + "name": "index_feed_videos_serverId_accountId_feed", + "unique": false, + "columnNames": [ + "serverId", + "accountId", + "feed" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_videos_serverId_accountId_feed` ON `${TABLE_NAME}` (`serverId`, `accountId`, `feed`)" + }, + { + "name": "index_feed_videos_serverId_accountId_feed_position", + "unique": true, + "columnNames": [ + "serverId", + "accountId", + "feed", + "position" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feed_videos_serverId_accountId_feed_position` ON `${TABLE_NAME}` (`serverId`, `accountId`, `feed`, `position`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "youtube_takeout_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sessionGeneration` INTEGER NOT NULL, `requestId` TEXT NOT NULL, `workId` TEXT NOT NULL, `documentUri` TEXT NOT NULL, `displayName` TEXT NOT NULL, `sizeBytes` INTEGER, `serverJobId` TEXT, `status` TEXT NOT NULL, `phase` TEXT, `progressPercent` INTEGER, `previewSubscriptions` INTEGER, `previewPlaylists` INTEGER, `previewPlaylistItems` INTEGER, `previewFavorites` INTEGER, `previewWatchLater` INTEGER, `previewHistory` INTEGER, `importedCount` INTEGER, `skippedCount` INTEGER, `failedCount` INTEGER, `warningCount` INTEGER NOT NULL, `errorCount` INTEGER NOT NULL, `failureCode` TEXT, `failureRequestId` TEXT, `collectionsRefreshed` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `requestId`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionGeneration", + "columnName": "sessionGeneration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "requestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workId", + "columnName": "workId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentUri", + "columnName": "documentUri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverJobId", + "columnName": "serverJobId", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "TEXT" + }, + { + "fieldPath": "progressPercent", + "columnName": "progressPercent", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewSubscriptions", + "columnName": "previewSubscriptions", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewPlaylists", + "columnName": "previewPlaylists", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewPlaylistItems", + "columnName": "previewPlaylistItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewFavorites", + "columnName": "previewFavorites", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewWatchLater", + "columnName": "previewWatchLater", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewHistory", + "columnName": "previewHistory", + "affinity": "INTEGER" + }, + { + "fieldPath": "importedCount", + "columnName": "importedCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "skippedCount", + "columnName": "skippedCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "failedCount", + "columnName": "failedCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "warningCount", + "columnName": "warningCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "errorCount", + "columnName": "errorCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failureCode", + "columnName": "failureCode", + "affinity": "TEXT" + }, + { + "fieldPath": "failureRequestId", + "columnName": "failureRequestId", + "affinity": "TEXT" + }, + { + "fieldPath": "collectionsRefreshed", + "columnName": "collectionsRefreshed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "requestId" + ] + }, + "indices": [ + { + "name": "index_youtube_takeout_imports_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_youtube_takeout_imports_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + }, + { + "name": "index_youtube_takeout_imports_workId", + "unique": true, + "columnNames": [ + "workId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_youtube_takeout_imports_workId` ON `${TABLE_NAME}` (`workId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + } + ], + "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, '6b946f3c1c6c852d9b6276710462a356')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrationTest.kt b/app/src/androidTest/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrationTest.kt index 467bed83..305e7427 100644 --- a/app/src/androidTest/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrationTest.kt @@ -205,6 +205,36 @@ class TypeTypeDatabaseMigrationTest { } } + @Test + fun migratesVersion23WithRecommendationCapabilityDefaults() { + helper.createDatabase(DATABASE_NAME, 23).apply { + execSQL( + "INSERT INTO servers(id, baseUrl, displayName, addedAt) " + + "VALUES('instance-a', 'https://example.test/api', 'TypeType', 1)", + ) + close() + } + + val migrated = helper.runMigrationsAndValidate( + DATABASE_NAME, + 24, + true, + TypeTypeDatabaseMigrations.MIGRATION_23_24, + ) + + migrated.query( + "SELECT displayName, recommenderInstalled, recommenderEnabled, " + + "recommenderExperimental FROM servers WHERE id = 'instance-a'", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("TypeType", cursor.getString(0)) + assertEquals(0, cursor.getInt(1)) + assertEquals(0, cursor.getInt(2)) + assertEquals(0, cursor.getInt(3)) + } + migrated.close() + } + @Test fun migratesVersion13WithDurableScopedLibraryMutations() { helper.createDatabase(DATABASE_NAME, 13).apply { @@ -286,7 +316,7 @@ class TypeTypeDatabaseMigrationTest { private companion object { const val DATABASE_NAME = "migration-test.db" const val FIRST_RETAINED_VERSION = 1 - const val CURRENT_VERSION = 23 + const val CURRENT_VERSION = 24 const val DRAFT_VERSION_10_SERVERS = "CREATE TABLE `servers` (" + "`id` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `displayName` TEXT NOT NULL, " + diff --git a/app/src/androidTest/java/dev/typetype/android/feature/channel/ChannelScreenStateTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/channel/ChannelScreenStateTest.kt index 30dc22af..1c7892b5 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/channel/ChannelScreenStateTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/channel/ChannelScreenStateTest.kt @@ -1,5 +1,6 @@ package dev.typetype.android.feature.channel +import android.content.Context import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -14,6 +15,8 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import dev.typetype.android.R import dev.typetype.android.core.ui.components.LocalAnimatedStatePlayback import dev.typetype.android.core.ui.theme.TypeTypeTheme import dev.typetype.android.domain.channel.Channel @@ -68,6 +71,23 @@ class ChannelScreenStateTest { composeRule.onNodeWithText("Channel video").assertIsDisplayed() } + @Test + fun subscribedChannelShowsNotificationBellWhenPushIsAvailable() { + showContent( + ChannelState( + isLoading = false, + channel = channel(), + isSubscribed = true, + notificationsAvailable = true, + ), + ) + + composeRule.onNodeWithContentDescription( + ApplicationProvider.getApplicationContext() + .getString(R.string.channel_notifications_enable), + ).assertIsDisplayed() + } + @Test fun discoveryUsesCompactSectionAndSortMenus() { val actions = mutableListOf() diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt index bf6d75e4..23a86d42 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt @@ -53,6 +53,16 @@ class PlayerContentLayoutComposeTest { assertNodeCount(PLAYER_TWO_PANE_LAYOUT_TAG, 0) } + @Test + fun detailsTrackTheVideoBottomDuringPullDownTransition() { + assertDetailsTrackVideoBottom(width = 400.dp, height = 600.dp) + } + + @Test + fun tabletDetailsTrackTheVideoBottomDuringPullDownTransition() { + assertDetailsTrackVideoBottom(width = 1280.dp, height = 800.dp) + } + @Test fun portraitTabletKeepsDetailsBelowThePlayer() { setLayout(width = 1067.dp, height = 1440.dp) @@ -153,6 +163,31 @@ class PlayerContentLayoutComposeTest { } } + private fun assertDetailsTrackVideoBottom(width: Dp, height: Dp) { + var progress by mutableStateOf(0f) + composeRule.setContent { + MaterialTheme { + Box(Modifier.requiredWidth(width).requiredHeight(height)) { + PlayerContentLayout( + isFullscreen = false, + hostTransitionProgress = { progress }, + modifier = Modifier.fillMaxSize(), + viewport = { Box(it.testTag(VIEWPORT_TAG)) }, + details = { Box(it.height(300.dp).testTag(DETAILS_TAG)) }, + recommendations = { Box(it.testTag(RECOMMENDATIONS_TAG)) }, + ) + } + } + } + + composeRule.runOnIdle { progress = 0.5f } + composeRule.waitForIdle() + + val viewport = bounds(VIEWPORT_TAG) + val details = bounds(DETAILS_TAG) + assertEquals("viewport=$viewport details=$details", viewport.bottom, details.top, 2f) + } + private fun bounds(tag: String) = composeRule .onNodeWithTag(tag) .fetchSemanticsNode() diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzoneIntegrationTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzoneIntegrationTest.kt new file mode 100644 index 00000000..19affedc --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzoneIntegrationTest.kt @@ -0,0 +1,100 @@ +package dev.typetype.android.feature.player.components + +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.click +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.typetype.android.feature.player.state.PlayerGestureState +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FullscreenGestureDeadzoneIntegrationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun edgeTouchesPassThroughAndCenterGesturesRemainActive() { + val player = GestureTestPlayer(Looper.getMainLooper()) + val tapCount = AtomicInteger() + composeRule.setContent { + PlayerGestureLayer( + player = player, + state = remember { PlayerGestureState() }, + onSingleTap = { tapCount.incrementAndGet() }, + onAdjustBrightness = {}, + onAdjustVolume = {}, + isFullscreen = true, + config = PlayerGestureConfig(swipeSeekEnabled = true), + modifier = Modifier.size(300.dp, 180.dp).testTag(GESTURE_TAG), + ) + } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + click(Offset(center.x, 8f)) + doubleClick(Offset(center.x, height - 8f)) + swipe(Offset(40f, 8f), Offset(width - 40f, 8f), 300L) + swipe(Offset(40f, height - 8f), Offset(width - 40f, height - 8f), 300L) + } + composeRule.runOnIdle { + assertEquals(0, tapCount.get()) + assertEquals(20_000L, player.currentPosition) + } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + click(center) + swipe(Offset(40f, center.y), Offset(width - 40f, center.y), 300L) + } + composeRule.runOnIdle { + assertEquals(1, tapCount.get()) + assertTrue(player.currentPosition > 20_000L) + player.release() + } + } + + @Test + fun fullscreenExitIgnoresTopAndBottomEdgeSwipes() { + val exitCount = AtomicInteger() + composeRule.setContent { + PlayerFullscreenExitGestureBox( + enabled = true, + onGestureFeedback = {}, + onExitFullscreen = { exitCount.incrementAndGet() }, + modifier = Modifier.size(300.dp, 180.dp).testTag(GESTURE_TAG), + ) { Box(Modifier.fillMaxSize()) } + } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + swipe(Offset(center.x, 8f), Offset(center.x, height - 8f), 300L) + swipe(Offset(center.x, height - 8f), Offset(center.x, 8f), 300L) + } + composeRule.runOnIdle { assertEquals(0, exitCount.get()) } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + swipe(center, Offset(center.x, height - 8f), 300L) + } + composeRule.runOnIdle { assertEquals(1, exitCount.get()) } + } + + private companion object { + const val GESTURE_TAG = "fullscreen_gesture_deadzone" + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlaybackFailureOverlayTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlaybackFailureOverlayTest.kt index 72ffdbd3..0359c8e3 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlaybackFailureOverlayTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlaybackFailureOverlayTest.kt @@ -19,7 +19,7 @@ class PlaybackFailureOverlayTest { val composeRule = createComposeRule() @Test - fun mediaDeliveryFailureRemainsActionableAtLargeFont() { + fun unrecognizedMediaContainerRemainsActionableAtLargeFont() { val retried = AtomicBoolean(false) val backedOut = AtomicBoolean(false) composeRule.setContent { @@ -30,9 +30,9 @@ class PlaybackFailureOverlayTest { TypeTypeTheme { PlaybackFailureOverlay( error = PlaybackException( - "HTTP failure", + "Unrecognized input format", null, - PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, + PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED, ), onRetry = { retried.set(true) }, onOpenAccounts = {}, diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt index 0b6584d5..8236c057 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt @@ -25,14 +25,10 @@ import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.captureToImage -import androidx.compose.ui.graphics.asAndroidBitmap import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.ViewCompat import androidx.core.graphics.Insets -import android.graphics.Bitmap -import java.io.File import dev.typetype.android.R import dev.typetype.android.domain.stream.StreamStoryboard import dev.typetype.android.feature.player.PlayerNavigationAvailability @@ -84,10 +80,8 @@ class PlayerControlsLayoutTest { dispatchStatusBarInsets(40) composeRule.waitForIdle() val visibleTop = backButtonTopInViewport() - captureControls("status-bars-visible.png") dispatchStatusBarInsets(0) composeRule.waitForIdle() - captureControls("status-bars-hidden.png") assertEquals(visibleTop, backButtonTopInViewport(), 1f) } @@ -155,14 +149,6 @@ class PlayerControlsLayoutTest { return back.top - viewport.top } - private fun captureControls(name: String) { - val bitmap = composeRule.onNodeWithTag(PLAYER_CONTROLS_VIEWPORT_TAG) - .captureToImage().asAndroidBitmap() - File(composeRule.activity.noBackupFilesDir, name).outputStream().use { - bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) - } - } - @Test fun tabletControlsHaveLargerTargetsWithoutOverlapping() { assumeTrue( diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt index 9eefa667..ee96f1f0 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt @@ -93,6 +93,33 @@ class ContentSettingsScreenTest { composeRule.onNodeWithText("Hide subscription live streams").assertIsDisplayed() } + @Test + fun recommenderControlAppearsOnlyWhenTheInstanceAdvertisesIt() { + val action = AtomicReference() + show( + ContentSettingsState( + isLoading = false, + recommenderAvailable = true, + recommenderExperimental = true, + ), + ) { action.set(it) } + + scrollTo("Use this instance’s recommender") + composeRule.onNodeWithText("Use this instance’s recommender").assertIsDisplayed() + composeRule.onNodeWithText("Experimental service; enabling it may change your home feed") + .assertIsDisplayed() + composeRule.onNodeWithText("Use this instance’s recommender").performClick() + + assertEquals(ContentSettingsAction.SetRecommenderEnabled(true), action.get()) + } + + @Test + fun recommenderControlIsHiddenWhenTheInstanceDoesNotAdvertiseIt() { + show(ContentSettingsState(isLoading = false)) + + composeRule.onNodeWithText("Use this instance’s recommender").assertDoesNotExist() + } + private fun show( state: ContentSettingsState, onAction: (ContentSettingsAction) -> Unit = {}, diff --git a/app/src/androidTest/java/dev/typetype/android/feature/setup/addserver/AddServerScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/setup/addserver/AddServerScreenTest.kt index 9ef3cbe2..41dffedf 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/setup/addserver/AddServerScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/setup/addserver/AddServerScreenTest.kt @@ -6,7 +6,9 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import dev.typetype.android.core.ui.theme.TypeTypeTheme import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import org.junit.Assert.assertTrue +import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -36,4 +38,23 @@ class AddServerScreenTest { assertTrue(settingsOpened.get()) } + + @Test + fun remoteHttpRequiresExplicitUnencryptedTransportOptIn() { + val action = AtomicReference() + composeRule.setContent { + TypeTypeTheme { + AddServerScreen( + state = AddServerState(url = "http://video.example"), + onAction = action::set, + onConnect = {}, + onOpenAppSettings = {}, + ) + } + } + + composeRule.onNodeWithText("Allow unencrypted HTTP").assertIsDisplayed().performClick() + + assertEquals(AddServerAction.OnAllowCleartextHttpChange(true), action.get()) + } } diff --git a/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt b/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt new file mode 100644 index 00000000..6e9e9a12 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt @@ -0,0 +1,67 @@ +package dev.typetype.android.services.push + +import android.Manifest +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.typetype.android.domain.push.PushPayload +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PushNotifierAndroidTest { + @Test + fun subscriptionPayloadBecomesVisibleNotification() { + val context = ApplicationProvider.getApplicationContext() + grantNotificationPermission(context) + val manager = requireNotNull(context.getSystemService(NotificationManager::class.java)) + val payload = PushPayload( + eventType = "subscription_new_video", + serviceId = 0, + serviceName = "YouTube", + eventId = "push-notifier-test", + videoId = "push-notifier-video", + videoUrl = "https://www.youtube.com/watch?v=push-notifier-video", + channelId = "channel-test", + channelName = "Notification test channel", + channelAvatarUrl = "", + instanceId = "instance-test", + accountId = "account-test", + publishedAt = 1_760_000_000_000, + title = "Notification delivery smoke test", + ) + val notificationId = subscriptionNotificationId(payload.videoId, payload.videoUrl, payload.eventId) + + try { + PushNotifier(context).notify(payload) + val notification = manager.activeNotifications + .firstOrNull { record -> record.id == notificationId } + ?.notification + + assertNotNull(notification) + assertEquals( + payload.channelName, + notification?.extras?.getString(Notification.EXTRA_TITLE), + ) + assertEquals( + payload.title, + notification?.extras?.getCharSequence(Notification.EXTRA_TEXT)?.toString(), + ) + } finally { + manager.cancel(notificationId) + } + } + + private fun grantNotificationPermission(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + InstrumentationRegistry.getInstrumentation().uiAutomation + .grantRuntimePermission(context.packageName, Manifest.permission.POST_NOTIFICATIONS) + } + } +} diff --git a/app/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt b/app/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt new file mode 100644 index 00000000..6da500f8 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt @@ -0,0 +1,19 @@ +package dev.typetype.android.core.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.typetype.android.data.recommendations.RemoteRecommenderSettingsRepository +import dev.typetype.android.domain.recommendations.RecommenderSettingsRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RecommenderRepositoryModule { + @Binds + @Singleton + abstract fun bindRecommenderSettingsRepository( + implementation: RemoteRecommenderSettingsRepository, + ): RecommenderSettingsRepository +} diff --git a/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabase.kt b/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabase.kt index e11aadf9..3d1dee70 100644 --- a/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabase.kt +++ b/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabase.kt @@ -60,7 +60,7 @@ import dev.typetype.android.data.subscriptions.SubscriptionEntity FeedVideoEntity::class, YoutubeTakeoutImportEntity::class, ], - version = 23, + version = 24, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), diff --git a/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrations.kt b/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrations.kt index d34d7fc3..596c843f 100644 --- a/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrations.kt +++ b/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrations.kt @@ -174,6 +174,23 @@ object TypeTypeDatabaseMigrations { } } + val MIGRATION_23_24 = object : Migration(23, 24) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `servers` ADD COLUMN `recommenderInstalled` " + + "INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL( + "ALTER TABLE `servers` ADD COLUMN `recommenderEnabled` " + + "INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL( + "ALTER TABLE `servers` ADD COLUMN `recommenderExperimental` " + + "INTEGER NOT NULL DEFAULT 0", + ) + } + } + val ALL = arrayOf( MIGRATION_7_8, MIGRATION_11_12, @@ -187,5 +204,6 @@ object TypeTypeDatabaseMigrations { MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, + MIGRATION_23_24, ) } diff --git a/app/src/main/java/dev/typetype/android/data/diagnostics/NetworkRouteClassifier.kt b/app/src/main/java/dev/typetype/android/data/diagnostics/NetworkRouteClassifier.kt index 39280c15..08eb949f 100644 --- a/app/src/main/java/dev/typetype/android/data/diagnostics/NetworkRouteClassifier.kt +++ b/app/src/main/java/dev/typetype/android/data/diagnostics/NetworkRouteClassifier.kt @@ -16,6 +16,7 @@ object NetworkRouteClassifier { "health", "history", "instance", + "media", "playlists", "profile", "progress", diff --git a/app/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt index 7871e13c..702b37bd 100644 --- a/app/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt +++ b/app/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt @@ -1,6 +1,7 @@ package dev.typetype.android.data.feed import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.account.AccountScope import dev.typetype.android.data.network.TypeTypeApiHolder import dev.typetype.android.data.network.dto.toDomainVideo import dev.typetype.android.data.network.requireSuccessfulResponse @@ -10,6 +11,9 @@ import dev.typetype.android.domain.feed.SubscriptionsPage import dev.typetype.android.domain.feed.ShortsContinuation import dev.typetype.android.domain.feed.ShortsPage import dev.typetype.android.domain.feed.Video +import dev.typetype.android.domain.server.Server +import dev.typetype.android.domain.server.ServerCapabilitiesRepository +import dev.typetype.android.domain.server.ServerRepository import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CancellationException @@ -20,6 +24,8 @@ import kotlinx.coroutines.withContext class HomeFeedRepositoryImpl @Inject constructor( private val apiHolder: TypeTypeApiHolder, private val activeAccountScope: ActiveAccountScope, + private val serverRepository: ServerRepository, + private val serverCapabilitiesRepository: ServerCapabilitiesRepository, private val feedVideoDao: FeedVideoDao, private val subscriptionFeedDiagnostics: SubscriptionFeedDiagnostics, ) : HomeFeedRepository { @@ -50,6 +56,15 @@ class HomeFeedRepositoryImpl @Inject constructor( limit: Int, ): Result = feedResult { val scope = activeAccountScope.require() + val server = if (scope.accountId.startsWith(GUEST_ACCOUNT_PREFIX)) { + null + } else { + serverForRecommendationRequest(scope, refresh = cursor == null) + } + activeAccountScope.verify(scope) + if (!scope.canUseRecommendations(server)) { + return@feedResult HomeRecommendationsPage(emptyList(), null) + } val api = apiHolder.require(scope) val response = withContext(Dispatchers.IO) { api.homeRecommendations(intent = "auto", cursor = cursor, limit = limit) @@ -101,8 +116,16 @@ class HomeFeedRepositoryImpl @Inject constructor( ): Result = feedResult { val scope = activeAccountScope.require() val api = apiHolder.require(scope) + val recommenderServer = if ( + scope.accountId.startsWith(GUEST_ACCOUNT_PREFIX) || + continuation is ShortsContinuation.Subscriptions + ) { + null + } else { + serverForRecommendationRequest(scope, refresh = continuation == null) + } val page = when { - scope.accountId.startsWith("guest:") -> loadDiscoveryShorts( + scope.accountId.startsWith(GUEST_ACCOUNT_PREFIX) -> loadDiscoveryShorts( api = api, nextPage = (continuation as? ShortsContinuation.Discovery)?.nextPage, service = service, @@ -113,6 +136,12 @@ class HomeFeedRepositoryImpl @Inject constructor( service = service, limit = limit, ).normalized(limit) + !scope.canUseRecommendations(recommenderServer) -> loadSubscriptionShorts( + api = api, + page = 0, + service = service, + limit = limit, + ).normalized(limit) else -> loadRecommendationShorts( api = api, cursor = (continuation as? ShortsContinuation.Recommendations)?.cursor, @@ -131,6 +160,16 @@ class HomeFeedRepositoryImpl @Inject constructor( page } + private suspend fun serverForRecommendationRequest(scope: AccountScope, refresh: Boolean): Server { + val cached = serverRepository.getServer(scope.serverId) + ?: error("Instance not found") + return if (refresh) { + serverCapabilitiesRepository.refresh(scope.serverId).getOrDefault(cached) + } else { + cached + } + } + private suspend fun loadCachedFeed(feed: String): List