diff --git a/README.md b/README.md index b50c2215..fb384ee3 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,6 @@ Play Services. TypeType Android uses the TypeType Server selected during setup for extraction, playback sessions, recommendations, synchronization, and downloads. -The Android TV client is part of this repository in the `tv` module. It is a -separate native TV application that uses the same TypeType SDK and server -contracts as the mobile client. - ## Screenshots | Welcome | Add an instance | Home | @@ -56,19 +52,6 @@ contracts as the mobile client. sleep timer. - Download supported videos and import existing data. -## Android TV - -Build the TV client with the included Gradle wrapper: - -```sh -./gradlew :tv:assembleDebug -``` - -The TV module targets Android TV devices from API 23 onward and keeps its TV -navigation, focus behavior, layouts, and playback presentation independent from -the mobile UI. Local builds automatically use a sibling `TypeType-SDK` -checkout; set `TYPETYPE_SDK_PATH` when the SDK is stored elsewhere. - ## Install ### F-Droid diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72c76786..c1f2121f 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 = 10900 - versionName = "1.9.0" + versionCode = 11002 + versionName = "1.10.0" 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/core/ui/components/RetryingImageTest.kt b/app/src/androidTest/java/dev/typetype/android/core/ui/components/RetryingImageTest.kt new file mode 100644 index 00000000..a0bd2325 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/core/ui/components/RetryingImageTest.kt @@ -0,0 +1,56 @@ +package dev.typetype.android.core.ui.components + +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import coil3.request.ImageRequest +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class RetryingImageTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun failedImageRecoversWithoutChangingItsUrlOrLeavingTheScreen() { + val file = File.createTempFile("retry-image", ".png", rule.activity.cacheDir) + file.delete() + val errors = AtomicInteger() + val loaded = AtomicBoolean() + val request = ImageRequest.Builder(rule.activity).data(file).build() + try { + rule.setContent { + RetryingImage( + request = request, + contentDescription = "Test image", + contentScale = ContentScale.Crop, + modifier = Modifier.size(48.dp), + onLoaded = { success -> + if (success) { + loaded.set(true) + } else { + errors.incrementAndGet() + val bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888) + file.outputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + bitmap.recycle() + } + }, + ) + } + rule.waitUntil(10_000) { loaded.get() } + assertEquals(1, errors.get()) + } finally { + file.delete() + } + } +} 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/PlayerPlaceholderLayerTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerPlaceholderLayerTest.kt new file mode 100644 index 00000000..76b2e867 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerPlaceholderLayerTest.kt @@ -0,0 +1,46 @@ +package dev.typetype.android.feature.player + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class PlayerPlaceholderLayerTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun errorArtworkDisappearsWhenCollapsedAndReturnsWhenExpanded() { + val progress = mutableFloatStateOf(0f) + rule.setContent { + Box(Modifier.size(120.dp).background(Color.Blue).testTag("viewport")) { + PlayerPlaceholderLayer({ progress.floatValue }) { + Box(Modifier.size(120.dp).background(Color.Red)) + } + } + } + assertCenter(Color.Red) + rule.runOnIdle { progress.floatValue = 1f } + assertCenter(Color.Blue) + rule.runOnIdle { progress.floatValue = 0f } + assertCenter(Color.Red) + } + + private fun assertCenter(expected: Color) { + rule.waitForIdle() + val pixels = rule.onNodeWithTag("viewport").captureToImage().toPixelMap() + assertEquals(expected, pixels[pixels.width / 2, pixels.height / 2]) + } +} 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/PublicVodSeekMeasurementTest.kt b/app/src/androidTest/java/dev/typetype/android/services/PublicVodSeekMeasurementTest.kt new file mode 100644 index 00000000..2984c094 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/services/PublicVodSeekMeasurementTest.kt @@ -0,0 +1,81 @@ +package dev.typetype.android.services + +import android.content.ComponentName +import android.content.Intent +import android.net.Uri +import android.os.SystemClock +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import androidx.test.platform.app.InstrumentationRegistry +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.abs +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +@UnstableApi +class PublicVodSeekMeasurementTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + + @Test + fun measurePublicVodSeekReadinessWithPinnedTracks() { + assumeTrue(InstrumentationRegistry.getArguments().getString("publicVodSeekMeasurement") == "true") + val context = instrumentation.targetContext + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=tXH5EjM_96c")) + .setPackage(context.packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + val controller = MediaController.Builder( + context, + SessionToken(context, ComponentName(context, PlaybackService::class.java)), + ).buildAsync().get(10, TimeUnit.SECONDS) + try { + val deadline = SystemClock.elapsedRealtime() + 40_000L + while (SystemClock.elapsedRealtime() < deadline && read { controller.currentTracks.groups.isEmpty() }) { + Thread.sleep(50L) + } + val tracks = read { + controller.currentTracks.groups.flatMap { group -> + (0 until group.length).filter(group::isTrackSelected).map { index -> + val format = group.getTrackFormat(index) + "${format.id}:${format.sampleMimeType}" + } + } + } + println("Selected public VOD tracks: $tracks") + assertTrue("Expected video itag 299, got $tracks", tracks.any { it.startsWith("299:") }) + assertTrue("Expected audio itag 140, got $tracks", tracks.any { it.startsWith("140:") }) + for (target in listOf(999_108L, 225_191L)) { + val started = SystemClock.elapsedRealtime() + instrumentation.runOnMainSync { + controller.play() + controller.seekTo(target) + } + var ready = false + while (SystemClock.elapsedRealtime() - started < 45_000L) { + ready = read { + controller.playerError == null && controller.isPlaying && + controller.playbackState == Player.STATE_READY && + abs(controller.currentPosition - target) < 4_000L + } + if (ready) break + Thread.sleep(25L) + } + println("Android controller ready: targetMs=$target delayMs=${SystemClock.elapsedRealtime() - started}") + assertTrue("Seek did not recover: $target", ready) + } + } finally { + instrumentation.runOnMainSync { controller.release() } + } + } + + private fun read(block: () -> T): T { + val result = AtomicReference>() + instrumentation.runOnMainSync { result.set(runCatching(block)) } + return requireNotNull(result.get()).getOrThrow() + } +} 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/core/ui/components/ChannelAvatar.kt b/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt index ef0efa8d..b89aad21 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt @@ -10,7 +10,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -23,7 +22,6 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import dev.typetype.android.core.ui.share.LocalServerBaseUrl @@ -40,8 +38,7 @@ fun ChannelAvatar( ) { val serverBaseUrl = LocalServerBaseUrl.current val context = LocalPlatformContext.current - var attempt by remember(avatarUrl) { mutableIntStateOf(0) } - var loaded by remember(avatarUrl, attempt) { mutableStateOf(false) } + var loaded by remember(serverBaseUrl, avatarUrl) { mutableStateOf(false) } val hasSource = avatarUrl.isNotBlank() var missingExpired by remember(avatarUrl) { mutableStateOf(false) } @@ -73,16 +70,13 @@ fun ChannelAvatar( ) } if (hasSource) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(avatarRetryUrl(buildImageUrl(serverBaseUrl, avatarUrl), attempt)) + RetryingImage( + request = ImageRequest.Builder(context) + .data(buildImageUrl(serverBaseUrl, avatarUrl)) .build(), contentDescription = null, contentScale = ContentScale.Crop, - onSuccess = { loaded = true }, - onError = { - if (attempt < MAX_AVATAR_RETRIES) attempt += 1 - }, + onLoaded = { loaded = it }, modifier = Modifier.fillMaxSize(), ) } @@ -102,5 +96,4 @@ internal fun avatarInitial(name: String): String = name.trim().firstOrNull()?.uppercase() ?: "?" private const val AVATAR_RETRY_PARAM = "_tt_avatar_retry" -private const val MAX_AVATAR_RETRIES = 2 private const val MISSING_AVATAR_GRACE_MILLIS = 1_500L diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/RetryingImage.kt b/app/src/main/java/dev/typetype/android/core/ui/components/RetryingImage.kt new file mode 100644 index 00000000..fd2dae2e --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/components/RetryingImage.kt @@ -0,0 +1,49 @@ +package dev.typetype.android.core.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import kotlinx.coroutines.delay + +@Composable +internal fun RetryingImage( + request: ImageRequest, + contentDescription: String?, + contentScale: ContentScale, + modifier: Modifier, + onLoaded: (Boolean) -> Unit, +) { + var attempt by remember(request.data) { mutableIntStateOf(0) } + var failed by remember(request.data, attempt) { mutableStateOf(false) } + LaunchedEffect(request.data, attempt, failed) { + if (failed) { + delay(imageRetryDelayMillis(attempt)) + attempt += 1 + } + } + key(request.data, attempt) { + AsyncImage( + model = request, + contentDescription = contentDescription, + contentScale = contentScale, + onSuccess = { onLoaded(true) }, + onError = { + onLoaded(false) + failed = true + }, + modifier = modifier, + ) + } +} + +internal fun imageRetryDelayMillis(attempt: Int): Long = + 1_000L shl attempt.coerceIn(0, 5) diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt b/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt index c3b84609..c48434be 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale -import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.crossfade @@ -28,22 +27,21 @@ fun SkeletonImage( ) { val serverBaseUrl = LocalServerBaseUrl.current val context = LocalPlatformContext.current - var loaded by remember(imageUrl) { mutableStateOf(false) } + var loaded by remember(serverBaseUrl, imageUrl) { mutableStateOf(false) } val hasSource = imageUrl.isNotBlank() Box(modifier = modifier.clip(shape)) { if (hasSource && !loaded) { TypeTypeSkeleton(modifier = Modifier.matchParentSize(), shape = shape) } if (hasSource) { - AsyncImage( - model = ImageRequest.Builder(context) + RetryingImage( + request = ImageRequest.Builder(context) .data(buildImageUrl(serverBaseUrl, imageUrl)) .apply { if (crossfadeMillis > 0) crossfade(crossfadeMillis) } .build(), contentDescription = contentDescription, contentScale = contentScale, - onSuccess = { loaded = true }, - onError = { loaded = false }, + onLoaded = { loaded = it }, modifier = Modifier.matchParentSize(), ) } 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