diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9f1b1a616..78c2392ff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,7 +29,7 @@ android { minSdk = 21 targetSdk = 34 versionCode = 121 - versionName = "2.32.1" + versionName = "2.32.2" } buildTypes { @@ -91,8 +91,6 @@ dependencies { implementation(libs.gson) implementation(libs.apollo.runtime) - implementation(libs.fetch) - implementation(libs.fetch.okhttp) implementation(libs.okio) implementation(libs.open.m3u8) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index b2fdb23e8..1f20e7aea 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -25,8 +25,4 @@ public static *** v(...); } --keep class com.woxthebox.draglistview.** { *; } - -# fetch okhttp --dontwarn okhttp3.internal.Util --dontwarn okhttp3.internal.annotations.EverythingIsNonNull \ No newline at end of file +-keep class com.woxthebox.draglistview.** { *; } \ No newline at end of file diff --git a/app/schemas/com.github.andreyasadchy.xtra.db.AppDatabase/25.json b/app/schemas/com.github.andreyasadchy.xtra.db.AppDatabase/25.json new file mode 100644 index 000000000..09a71f090 --- /dev/null +++ b/app/schemas/com.github.andreyasadchy.xtra.db.AppDatabase/25.json @@ -0,0 +1,626 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "b1693e351e0836c4b8b73459dcf8eb25", + "entities": [ + { + "tableName": "videos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT, `source_url` TEXT, `source_start_position` INTEGER, `name` TEXT, `channel_id` TEXT, `channel_login` TEXT, `channel_name` TEXT, `channel_logo` TEXT, `thumbnail` TEXT, `gameId` TEXT, `gameSlug` TEXT, `gameName` TEXT, `duration` INTEGER, `upload_date` INTEGER, `download_date` INTEGER, `last_watch_position` INTEGER, `progress` INTEGER NOT NULL, `max_progress` INTEGER NOT NULL, `bytes` INTEGER NOT NULL, `downloadPath` TEXT, `fromTime` INTEGER, `toTime` INTEGER, `status` INTEGER NOT NULL, `type` TEXT, `videoId` TEXT, `clipId` TEXT, `quality` TEXT, `downloadChat` INTEGER NOT NULL, `downloadChatEmotes` INTEGER NOT NULL, `chatProgress` INTEGER NOT NULL, `maxChatProgress` INTEGER NOT NULL, `chatBytes` INTEGER NOT NULL, `chatOffsetSeconds` INTEGER NOT NULL, `chatUrl` TEXT, `playlistToFile` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceUrl", + "columnName": "source_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceStartPosition", + "columnName": "source_start_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "channelId", + "columnName": "channel_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "channelLogin", + "columnName": "channel_login", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "channelName", + "columnName": "channel_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "channelLogo", + "columnName": "channel_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gameId", + "columnName": "gameId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gameSlug", + "columnName": "gameSlug", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gameName", + "columnName": "gameName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "upload_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "downloadDate", + "columnName": "download_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastWatchPosition", + "columnName": "last_watch_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxProgress", + "columnName": "max_progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytes", + "columnName": "bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadPath", + "columnName": "downloadPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fromTime", + "columnName": "fromTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toTime", + "columnName": "toTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clipId", + "columnName": "clipId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadChat", + "columnName": "downloadChat", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadChatEmotes", + "columnName": "downloadChatEmotes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatProgress", + "columnName": "chatProgress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChatProgress", + "columnName": "maxChatProgress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatBytes", + "columnName": "chatBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatOffsetSeconds", + "columnName": "chatOffsetSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatUrl", + "columnName": "chatUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "playlistToFile", + "columnName": "playlistToFile", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recent_emotes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `used_at` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usedAt", + "columnName": "used_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "video_positions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "local_follows", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `userLogin` TEXT, `userName` TEXT, `channelLogo` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userLogin", + "columnName": "userLogin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "channelLogo", + "columnName": "channelLogo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "local_follows_games", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gameId` TEXT, `gameSlug` TEXT, `gameName` TEXT, `boxArt` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "gameId", + "columnName": "gameId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gameSlug", + "columnName": "gameSlug", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gameName", + "columnName": "gameName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "boxArt", + "columnName": "boxArt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT, `userId` TEXT, `userLogin` TEXT, `userName` TEXT, `userType` TEXT, `userBroadcasterType` TEXT, `userLogo` TEXT, `gameId` TEXT, `gameSlug` TEXT, `gameName` TEXT, `title` TEXT, `createdAt` TEXT, `thumbnail` TEXT, `type` TEXT, `duration` TEXT, `animatedPreviewURL` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userLogin", + "columnName": "userLogin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userType", + "columnName": "userType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userBroadcasterType", + "columnName": "userBroadcasterType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userLogo", + "columnName": "userLogo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gameId", + "columnName": "gameId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gameSlug", + "columnName": "gameSlug", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gameName", + "columnName": "gameName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "animatedPreviewURL", + "columnName": "animatedPreviewURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "vod_bookmark_ignored_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, PRIMARY KEY(`user_id`))", + "fields": [ + { + "fieldPath": "user_id", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sort_channel", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `saveSort` INTEGER, `videoSort` TEXT, `videoType` TEXT, `clipPeriod` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saveSort", + "columnName": "saveSort", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "videoSort", + "columnName": "videoSort", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoType", + "columnName": "videoType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clipPeriod", + "columnName": "clipPeriod", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sort_game", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `saveSort` INTEGER, `videoSort` TEXT, `videoPeriod` TEXT, `videoType` TEXT, `videoLanguageIndex` INTEGER, `clipPeriod` TEXT, `clipLanguageIndex` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saveSort", + "columnName": "saveSort", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "videoSort", + "columnName": "videoSort", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoPeriod", + "columnName": "videoPeriod", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoType", + "columnName": "videoType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoLanguageIndex", + "columnName": "videoLanguageIndex", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "clipPeriod", + "columnName": "clipPeriod", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clipLanguageIndex", + "columnName": "clipLanguageIndex", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b1693e351e0836c4b8b73459dcf8eb25')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/db/AppDatabase.kt b/app/src/main/java/com/github/andreyasadchy/xtra/db/AppDatabase.kt index 0532fd2ae..0186ea22c 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/db/AppDatabase.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/db/AppDatabase.kt @@ -8,16 +8,14 @@ import com.github.andreyasadchy.xtra.model.offline.Bookmark import com.github.andreyasadchy.xtra.model.offline.LocalFollowChannel import com.github.andreyasadchy.xtra.model.offline.LocalFollowGame import com.github.andreyasadchy.xtra.model.offline.OfflineVideo -import com.github.andreyasadchy.xtra.model.offline.Request import com.github.andreyasadchy.xtra.model.offline.SortChannel import com.github.andreyasadchy.xtra.model.offline.SortGame import com.github.andreyasadchy.xtra.model.offline.VodBookmarkIgnoredUser -@Database(entities = [OfflineVideo::class, Request::class, RecentEmote::class, VideoPosition::class, LocalFollowChannel::class, LocalFollowGame::class, Bookmark::class, VodBookmarkIgnoredUser::class, SortChannel::class, SortGame::class], version = 24) +@Database(entities = [OfflineVideo::class, RecentEmote::class, VideoPosition::class, LocalFollowChannel::class, LocalFollowGame::class, Bookmark::class, VodBookmarkIgnoredUser::class, SortChannel::class, SortGame::class], version = 25) abstract class AppDatabase : RoomDatabase() { abstract fun videos(): VideosDao - abstract fun requests(): RequestsDao abstract fun recentEmotes(): RecentEmotesDao abstract fun videoPositions(): VideoPositionsDao abstract fun localFollowsChannel(): LocalFollowsChannelDao diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/db/RequestsDao.kt b/app/src/main/java/com/github/andreyasadchy/xtra/db/RequestsDao.kt deleted file mode 100644 index a84c20538..000000000 --- a/app/src/main/java/com/github/andreyasadchy/xtra/db/RequestsDao.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.github.andreyasadchy.xtra.db - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.Query -import com.github.andreyasadchy.xtra.model.offline.Request - -@Dao -interface RequestsDao { - - @Query("SELECT * FROM requests") - suspend fun getAll(): List - - @Insert - suspend fun insert(request: Request) - - @Delete - suspend fun delete(request: Request) -} diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/di/DatabaseModule.kt b/app/src/main/java/com/github/andreyasadchy/xtra/di/DatabaseModule.kt index 533b9682c..bdb97c2b4 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/di/DatabaseModule.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/di/DatabaseModule.kt @@ -9,7 +9,6 @@ import com.github.andreyasadchy.xtra.db.BookmarksDao import com.github.andreyasadchy.xtra.db.LocalFollowsChannelDao import com.github.andreyasadchy.xtra.db.LocalFollowsGameDao import com.github.andreyasadchy.xtra.db.RecentEmotesDao -import com.github.andreyasadchy.xtra.db.RequestsDao import com.github.andreyasadchy.xtra.db.SortChannelDao import com.github.andreyasadchy.xtra.db.SortGameDao import com.github.andreyasadchy.xtra.db.VideoPositionsDao @@ -34,7 +33,7 @@ class DatabaseModule { @Singleton @Provides - fun providesRepository(videosDao: VideosDao, requestsDao: RequestsDao, localFollowsChannelDao: LocalFollowsChannelDao, bookmarksDao: BookmarksDao): OfflineRepository = OfflineRepository(videosDao, requestsDao, localFollowsChannelDao, bookmarksDao) + fun providesRepository(videosDao: VideosDao, localFollowsChannelDao: LocalFollowsChannelDao, bookmarksDao: BookmarksDao): OfflineRepository = OfflineRepository(videosDao, localFollowsChannelDao, bookmarksDao) @Singleton @Provides @@ -64,10 +63,6 @@ class DatabaseModule { @Provides fun providesVideosDao(database: AppDatabase): VideosDao = database.videos() - @Singleton - @Provides - fun providesRequestsDao(database: AppDatabase): RequestsDao = database.requests() - @Singleton @Provides fun providesRecentEmotesDao(database: AppDatabase): RecentEmotesDao = database.recentEmotes() @@ -234,6 +229,15 @@ class DatabaseModule { db.execSQL("ALTER TABLE videos1 RENAME TO videos") } }, + object : Migration(24, 25) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE requests") + db.execSQL("CREATE TABLE IF NOT EXISTS videos1 (url TEXT, source_url TEXT, source_start_position INTEGER, name TEXT, channel_id TEXT, channel_login TEXT, channel_name TEXT, channel_logo TEXT, thumbnail TEXT, gameId TEXT, gameSlug TEXT, gameName TEXT, duration INTEGER, upload_date INTEGER, download_date INTEGER, last_watch_position INTEGER, progress INTEGER NOT NULL, max_progress INTEGER NOT NULL, bytes INTEGER NOT NULL, downloadPath TEXT, fromTime INTEGER, toTime INTEGER, status INTEGER NOT NULL, type TEXT, videoId TEXT, clipId TEXT, quality TEXT, downloadChat INTEGER NOT NULL, downloadChatEmotes INTEGER NOT NULL, chatProgress INTEGER NOT NULL, maxChatProgress INTEGER NOT NULL, chatBytes INTEGER NOT NULL, chatOffsetSeconds INTEGER NOT NULL, chatUrl TEXT, playlistToFile INTEGER NOT NULL, id INTEGER NOT NULL, PRIMARY KEY (id))") + db.execSQL("INSERT INTO videos1 (url, source_url, source_start_position, name, channel_id, channel_login, channel_name, channel_logo, thumbnail, gameId, gameSlug, gameName, duration, upload_date, download_date, last_watch_position, progress, max_progress, bytes, downloadPath, fromTime, toTime, status, type, videoId, quality, downloadChat, downloadChatEmotes, chatProgress, maxChatProgress, chatBytes, chatOffsetSeconds, chatUrl, playlistToFile, id) SELECT url, source_url, source_start_position, name, channel_id, channel_login, channel_name, channel_logo, thumbnail, gameId, gameSlug, gameName, duration, upload_date, download_date, last_watch_position, progress, max_progress, 0, downloadPath, fromTime, toTime, status, type, videoId, quality, downloadChat, downloadChatEmotes, chatProgress, 100, 0, 0, chatUrl, 0, id FROM videos") + db.execSQL("DROP TABLE videos") + db.execSQL("ALTER TABLE videos1 RENAME TO videos") + } + }, ) .build() } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/di/XtraModule.kt b/app/src/main/java/com/github/andreyasadchy/xtra/di/XtraModule.kt index 0202110b3..896c19ac3 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/di/XtraModule.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/di/XtraModule.kt @@ -1,6 +1,5 @@ package com.github.andreyasadchy.xtra.di -import android.app.Application import com.apollographql.apollo3.ApolloClient import com.apollographql.apollo3.network.okHttpClient import com.github.andreyasadchy.xtra.BuildConfig @@ -119,10 +118,7 @@ import com.github.andreyasadchy.xtra.model.helix.user.UsersDeserializer import com.github.andreyasadchy.xtra.model.helix.user.UsersResponse import com.github.andreyasadchy.xtra.model.helix.video.VideosDeserializer import com.github.andreyasadchy.xtra.model.helix.video.VideosResponse -import com.github.andreyasadchy.xtra.util.FetchProvider import com.google.gson.GsonBuilder -import com.tonyodev.fetch2.FetchConfiguration -import com.tonyodev.fetch2okhttp.OkHttpDownloader import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -280,22 +276,4 @@ class XtraModule { } return builder.build() } - - @Singleton - @Provides - fun providesFetchProvider(fetchConfigurationBuilder: FetchConfiguration.Builder): FetchProvider { - return FetchProvider(fetchConfigurationBuilder) - } - - @Singleton - @Provides - fun providesFetchConfigurationBuilder(application: Application, okHttpClient: OkHttpClient): FetchConfiguration.Builder { - return FetchConfiguration.Builder(application) - .enableLogging(BuildConfig.DEBUG) - .enableRetryOnNetworkGain(true) - .setDownloadConcurrentLimit(3) - .setHttpDownloader(OkHttpDownloader(okHttpClient)) - .setProgressReportingInterval(1000L) - .setAutoRetryMaxAttempts(3) - } } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/model/offline/Downloadable.kt b/app/src/main/java/com/github/andreyasadchy/xtra/model/offline/Downloadable.kt deleted file mode 100644 index ccfd93dbd..000000000 --- a/app/src/main/java/com/github/andreyasadchy/xtra/model/offline/Downloadable.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.andreyasadchy.xtra.model.offline - -interface Downloadable { - val id: String? - val title: String? - val thumbnail: String? - val channelId: String? - val channelLogin: String? - val channelName: String? - val channelLogo: String? - val gameId: String? - val gameSlug: String? - val gameName: String? - val uploadDate: String? - val type: String? -} \ No newline at end of file diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/model/offline/OfflineVideo.kt b/app/src/main/java/com/github/andreyasadchy/xtra/model/offline/OfflineVideo.kt index 9d8bd87ed..1a838c4ff 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/model/offline/OfflineVideo.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/model/offline/OfflineVideo.kt @@ -10,7 +10,7 @@ import kotlinx.parcelize.Parcelize @Parcelize @Entity(tableName = "videos") data class OfflineVideo( - var url: String, + var url: String? = null, @ColumnInfo(name = "source_url") val sourceUrl: String? = null, @ColumnInfo(name = "source_start_position") @@ -35,29 +35,31 @@ data class OfflineVideo( val downloadDate: Long? = null, @ColumnInfo(name = "last_watch_position") var lastWatchPosition: Long? = null, - var progress: Int, + var progress: Int = 0, @ColumnInfo(name = "max_progress") - var maxProgress: Int, + var maxProgress: Int = 100, + var bytes: Long = 0, var downloadPath: String? = null, val fromTime: Long? = null, val toTime: Long? = null, var status: Int = STATUS_PENDING, val type: String? = null, var videoId: String? = null, + val clipId: String? = null, val quality: String? = null, - val downloadChat: Boolean? = null, - val downloadChatEmotes: Boolean? = null, - var chatProgress: Int? = null, - var chatUrl: String? = null) : Parcelable { + val downloadChat: Boolean = false, + val downloadChatEmotes: Boolean = false, + var chatProgress: Int = 0, + var maxChatProgress: Int = 100, + var chatBytes: Long = 0, + var chatOffsetSeconds: Int = 0, + var chatUrl: String? = null, + val playlistToFile: Boolean = false) : Parcelable { @IgnoredOnParcel @PrimaryKey(autoGenerate = true) var id = 0 - @IgnoredOnParcel - @ColumnInfo(name = "is_vod") - var vod = url.endsWith(".m3u8") - companion object { const val STATUS_PENDING = 0 const val STATUS_DOWNLOADING = 1 @@ -65,5 +67,8 @@ data class OfflineVideo( const val STATUS_MOVING = 3 const val STATUS_DELETING = 4 const val STATUS_CONVERTING = 5 + const val STATUS_BLOCKED = 6 + const val STATUS_QUEUED = 7 + const val STATUS_QUEUED_WIFI = 8 } } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/model/offline/Request.kt b/app/src/main/java/com/github/andreyasadchy/xtra/model/offline/Request.kt deleted file mode 100644 index fde03322d..000000000 --- a/app/src/main/java/com/github/andreyasadchy/xtra/model/offline/Request.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.andreyasadchy.xtra.model.offline - -import android.os.Parcelable -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey -import kotlinx.parcelize.Parcelize - -@Parcelize -@Entity(tableName = "requests", foreignKeys = [ForeignKey(entity = OfflineVideo::class, parentColumns = arrayOf("id"), childColumns = arrayOf("offline_video_id"), onDelete = ForeignKey.CASCADE)]) -data class Request( - @PrimaryKey - @ColumnInfo(name = "offline_video_id") - val offlineVideoId: Int, - val url: String, - val path: String) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/model/ui/Clip.kt b/app/src/main/java/com/github/andreyasadchy/xtra/model/ui/Clip.kt index e2ae9f763..f84c7bbf4 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/model/ui/Clip.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/model/ui/Clip.kt @@ -1,34 +1,31 @@ package com.github.andreyasadchy.xtra.model.ui import android.os.Parcelable -import com.github.andreyasadchy.xtra.model.offline.Downloadable import com.github.andreyasadchy.xtra.util.TwitchApiHelper import kotlinx.parcelize.Parcelize @Parcelize data class Clip( - override val id: String? = null, - override val channelId: String? = null, - override val channelName: String? = null, + val id: String? = null, + val channelId: String? = null, + val channelName: String? = null, val videoId: String? = null, - override var gameId: String? = null, - override val title: String? = null, + var gameId: String? = null, + val title: String? = null, val viewCount: Int? = null, - override val uploadDate: String? = null, + val uploadDate: String? = null, val thumbnailUrl: String? = null, val duration: Double? = null, val vodOffset: Int? = null, - override var gameSlug: String? = null, - override var gameName: String? = null, - override var channelLogin: String? = null, + var gameSlug: String? = null, + var gameName: String? = null, + var channelLogin: String? = null, var profileImageUrl: String? = null, - val videoAnimatedPreviewURL: String? = null) : Parcelable, Downloadable { + val videoAnimatedPreviewURL: String? = null) : Parcelable { - override val thumbnail: String? + val thumbnail: String? get() = TwitchApiHelper.getTemplateUrl(thumbnailUrl, "clip") - override val channelLogo: String? + val channelLogo: String? get() = TwitchApiHelper.getTemplateUrl(profileImageUrl, "profileimage") - override val type: String? - get() = null } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/model/ui/Video.kt b/app/src/main/java/com/github/andreyasadchy/xtra/model/ui/Video.kt index 2b048a7eb..5b46a7876 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/model/ui/Video.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/model/ui/Video.kt @@ -1,32 +1,31 @@ package com.github.andreyasadchy.xtra.model.ui import android.os.Parcelable -import com.github.andreyasadchy.xtra.model.offline.Downloadable import com.github.andreyasadchy.xtra.util.TwitchApiHelper import kotlinx.parcelize.Parcelize @Parcelize data class Video( - override val id: String? = null, - override val channelId: String? = null, - override val channelLogin: String? = null, - override val channelName: String? = null, - override val title: String? = null, - override val uploadDate: String? = null, + val id: String? = null, + val channelId: String? = null, + val channelLogin: String? = null, + val channelName: String? = null, + val title: String? = null, + val uploadDate: String? = null, val thumbnailUrl: String? = null, val viewCount: Int? = null, - override val type: String? = null, + val type: String? = null, val duration: String? = null, - override var gameId: String? = null, - override var gameSlug: String? = null, - override var gameName: String? = null, + var gameId: String? = null, + var gameSlug: String? = null, + var gameName: String? = null, var profileImageUrl: String? = null, val tags: List? = null, - val animatedPreviewURL: String? = null) : Parcelable, Downloadable { + val animatedPreviewURL: String? = null) : Parcelable { - override val thumbnail: String? - get() = TwitchApiHelper.getTemplateUrl(thumbnailUrl, "video") - override val channelLogo: String? - get() = TwitchApiHelper.getTemplateUrl(profileImageUrl, "profileimage") + val thumbnail: String? + get() = TwitchApiHelper.getTemplateUrl(thumbnailUrl, "video") + val channelLogo: String? + get() = TwitchApiHelper.getTemplateUrl(profileImageUrl, "profileimage") } \ No newline at end of file diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/repository/BookmarksRepository.kt b/app/src/main/java/com/github/andreyasadchy/xtra/repository/BookmarksRepository.kt index 505649f12..0d45b54e4 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/repository/BookmarksRepository.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/repository/BookmarksRepository.kt @@ -41,7 +41,7 @@ class BookmarksRepository @Inject constructor( if (!item.videoId.isNullOrBlank() && videosDao.getByVideoId(item.videoId).isEmpty()) { File(context.filesDir.path + File.separator + "thumbnails" + File.separator + "${item.videoId}.png").delete() } - if (!item.userId.isNullOrBlank() && localFollowsChannelDao.getByUserId(item.userId) == null && videosDao.getByUserId(item.userId).isEmpty()) { + if (!item.userId.isNullOrBlank() && getBookmarksByUserId(item.userId).isEmpty() && videosDao.getByUserId(item.userId).isEmpty() && localFollowsChannelDao.getByUserId(item.userId) == null) { File(context.filesDir.path + File.separator + "profile_pics" + File.separator + "${item.userId}.png").delete() } bookmarksDao.delete(item) diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/repository/OfflineRepository.kt b/app/src/main/java/com/github/andreyasadchy/xtra/repository/OfflineRepository.kt index f366b9504..145d075b2 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/repository/OfflineRepository.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/repository/OfflineRepository.kt @@ -3,12 +3,8 @@ package com.github.andreyasadchy.xtra.repository import android.content.Context import com.github.andreyasadchy.xtra.db.BookmarksDao import com.github.andreyasadchy.xtra.db.LocalFollowsChannelDao -import com.github.andreyasadchy.xtra.db.RequestsDao import com.github.andreyasadchy.xtra.db.VideosDao import com.github.andreyasadchy.xtra.model.offline.OfflineVideo -import com.github.andreyasadchy.xtra.model.offline.Request -import com.github.andreyasadchy.xtra.ui.download.DownloadService -import com.github.andreyasadchy.xtra.util.DownloadUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -20,7 +16,6 @@ import javax.inject.Singleton @Singleton class OfflineRepository @Inject constructor( private val videosDao: VideosDao, - private val requestsDao: RequestsDao, private val localFollowsChannelDao: LocalFollowsChannelDao, private val bookmarksDao: BookmarksDao) { @@ -44,12 +39,12 @@ class OfflineRepository @Inject constructor( suspend fun deleteVideo(context: Context, video: OfflineVideo) = withContext(Dispatchers.IO) { video.videoId?.let { - if (it.isNotBlank() && bookmarksDao.getByVideoId(it) == null) { + if (it.isNotBlank() && videosDao.getByVideoId(it).isEmpty() && bookmarksDao.getByVideoId(it) == null) { File(context.filesDir.path + File.separator + "thumbnails" + File.separator + "${it}.png").delete() } } video.channelId?.let { - if (it.isNotBlank() && localFollowsChannelDao.getByUserId(it) == null && bookmarksDao.getByUserId(it).isEmpty()) { + if (it.isNotBlank() && getVideosByUserId(it).isEmpty() && bookmarksDao.getByUserId(it).isEmpty() && localFollowsChannelDao.getByUserId(it) == null) { File(context.filesDir.path + File.separator + "profile_pics" + File.separator + "${it}.png").delete() } } @@ -69,20 +64,4 @@ class OfflineRepository @Inject constructor( suspend fun deletePositions() = withContext(Dispatchers.IO) { videosDao.deletePositions() } - - suspend fun resumeDownloads(context: Context) = withContext(Dispatchers.IO) { - requestsDao.getAll().forEach { - if (DownloadService.activeRequests.add(it.offlineVideoId)) { - DownloadUtils.download(context, it) - } - } - } - - suspend fun saveRequest(request: Request) = withContext(Dispatchers.IO) { - requestsDao.insert(request) - } - - suspend fun deleteRequest(request: Request) = withContext(Dispatchers.IO) { - requestsDao.delete(request) - } } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/chat/ChatViewModel.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/chat/ChatViewModel.kt index 5051e3227..2689118e5 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/chat/ChatViewModel.kt @@ -1335,127 +1335,154 @@ class ChatViewModel @Inject constructor( FileInputStream(File(url)).bufferedReader() }?.use { fileReader -> JsonReader(fileReader).use { reader -> + reader.isLenient = true var position = 0L - reader.beginObject().also { position += 1 } - while (reader.hasNext()) { - when (reader.peek()) { - JsonToken.NAME -> { - when (reader.nextName().also { position += it.length + 3 }) { - "comments" -> { - reader.beginArray().also { position += 1 } - while (reader.hasNext()) { - reader.beginObject().also { position += 1 } - val message = StringBuilder() - var id: String? = null - var offsetSeconds: Int? = null - var userId: String? = null - var userLogin: String? = null - var userName: String? = null - var color: String? = null - val emotesList = mutableListOf() - val badgesList = mutableListOf() - while (reader.hasNext()) { - when (reader.nextName().also { position += it.length + 3 }) { - "id" -> id = reader.nextString().also { position += it.length + 2 } - "commenter" -> { + var token: JsonToken + do { + token = reader.peek() + when (token) { + JsonToken.END_DOCUMENT -> {} + JsonToken.BEGIN_OBJECT -> { + reader.beginObject().also { position += 1 } + while (reader.hasNext()) { + when (reader.peek()) { + JsonToken.NAME -> { + when (reader.nextName().also { position += it.length + 3 }) { + "comments" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { reader.beginObject().also { position += 1 } + val message = StringBuilder() + var id: String? = null + var offsetSeconds: Int? = null + var userId: String? = null + var userLogin: String? = null + var userName: String? = null + var color: String? = null + val emotesList = mutableListOf() + val badgesList = mutableListOf() while (reader.hasNext()) { when (reader.nextName().also { position += it.length + 3 }) { - "id" -> userId = reader.nextString().also { position += it.length + 2 } - "login" -> userLogin = reader.nextString().also { position += it.length + 2 } - "displayName" -> userName = reader.nextString().also { position += it.length + 2 } - else -> position += skipJsonValue(reader) - } - if (reader.peek() != JsonToken.END_OBJECT) { - position += 1 - } - } - reader.endObject().also { position += 1 } - } - "contentOffsetSeconds" -> offsetSeconds = reader.nextInt().also { position += it.toString().length } - "message" -> { - reader.beginObject().also { position += 1 } - while (reader.hasNext()) { - when (reader.nextName().also { position += it.length + 3 }) { - "fragments" -> { - reader.beginArray().also { position += 1 } + "id" -> id = reader.nextString().also { position += it.length + 2 } + "commenter" -> { + reader.beginObject().also { position += 1 } + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "id" -> userId = reader.nextString().also { position += it.length + 2 } + "login" -> userLogin = reader.nextString().also { position += it.length + 2 } + "displayName" -> userName = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + reader.endObject().also { position += 1 } + } + "contentOffsetSeconds" -> offsetSeconds = reader.nextInt().also { position += it.toString().length } + "message" -> { + reader.beginObject().also { position += 1 } while (reader.hasNext()) { - reader.beginObject().also { position += 1 } - var emoteId: String? = null - var fragmentText: String? = null - while (reader.hasNext()) { - when (reader.nextName().also { position += it.length + 3 }) { - "emote" -> { - when (reader.peek()) { - JsonToken.BEGIN_OBJECT -> { - reader.beginObject().also { position += 1 } - while (reader.hasNext()) { - when (reader.nextName().also { position += it.length + 3 }) { - "emoteID" -> emoteId = reader.nextString().also { position += it.length + 2 } + when (reader.nextName().also { position += it.length + 3 }) { + "fragments" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + var emoteId: String? = null + var fragmentText: String? = null + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "emote" -> { + when (reader.peek()) { + JsonToken.BEGIN_OBJECT -> { + reader.beginObject().also { position += 1 } + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "emoteID" -> emoteId = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + reader.endObject().also { position += 1 } + } else -> position += skipJsonValue(reader) } - if (reader.peek() != JsonToken.END_OBJECT) { - position += 1 - } } - reader.endObject().also { position += 1 } + "text" -> fragmentText = reader.nextString().also { position += it.length + 2 + it.count { c -> c == '"' || c == '\\' } } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 } - else -> position += skipJsonValue(reader) + } + if (fragmentText != null && !emoteId.isNullOrBlank()) { + emotesList.add(TwitchEmote( + id = emoteId, + begin = message.codePointCount(0, message.length), + end = message.codePointCount(0, message.length) + fragmentText.lastIndex + )) + } + message.append(fragmentText) + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 } } - "text" -> fragmentText = reader.nextString().also { position += it.length + 2 + it.count { c -> c == '"' || c == '\\' } } - else -> position += skipJsonValue(reader) + reader.endArray().also { position += 1 } } - if (reader.peek() != JsonToken.END_OBJECT) { - position += 1 - } - } - if (fragmentText != null && !emoteId.isNullOrBlank()) { - emotesList.add(TwitchEmote( - id = emoteId, - begin = message.codePointCount(0, message.length), - end = message.codePointCount(0, message.length) + fragmentText.lastIndex - )) - } - message.append(fragmentText) - reader.endObject().also { position += 1 } - if (reader.peek() != JsonToken.END_ARRAY) { - position += 1 - } - } - reader.endArray().also { position += 1 } - } - "userBadges" -> { - reader.beginArray().also { position += 1 } - while (reader.hasNext()) { - reader.beginObject().also { position += 1 } - var set: String? = null - var version: String? = null - while (reader.hasNext()) { - when (reader.nextName().also { position += it.length + 3 }) { - "setID" -> set = reader.nextString().also { position += it.length + 2 } - "version" -> version = reader.nextString().also { position += it.length + 2 } - else -> position += skipJsonValue(reader) + "userBadges" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + var set: String? = null + var version: String? = null + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "setID" -> set = reader.nextString().also { position += it.length + 2 } + "version" -> version = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + if (!set.isNullOrBlank() && !version.isNullOrBlank()) { + badgesList.add(Badge(set, version)) + } + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 + } + } + reader.endArray().also { position += 1 } } - if (reader.peek() != JsonToken.END_OBJECT) { - position += 1 + "userColor" -> { + when (reader.peek()) { + JsonToken.STRING -> color = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } } + else -> position += skipJsonValue(reader) } - if (!set.isNullOrBlank() && !version.isNullOrBlank()) { - badgesList.add(Badge(set, version)) - } - reader.endObject().also { position += 1 } - if (reader.peek() != JsonToken.END_ARRAY) { + if (reader.peek() != JsonToken.END_OBJECT) { position += 1 } } - reader.endArray().also { position += 1 } - } - "userColor" -> { - when (reader.peek()) { - JsonToken.STRING -> color = reader.nextString().also { position += it.length + 2 } - else -> position += skipJsonValue(reader) - } + messages.add(VideoChatMessage( + id = id, + offsetSeconds = offsetSeconds, + userId = userId, + userLogin = userLogin, + userName = userName, + message = message.toString(), + color = color, + emotes = emotesList, + badges = badgesList, + fullMsg = null + )) + reader.endObject().also { position += 1 } } else -> position += skipJsonValue(reader) } @@ -1463,197 +1490,181 @@ class ChatViewModel @Inject constructor( position += 1 } } - messages.add(VideoChatMessage( - id = id, - offsetSeconds = offsetSeconds, - userId = userId, - userLogin = userLogin, - userName = userName, - message = message.toString(), - color = color, - emotes = emotesList, - badges = badgesList, - fullMsg = null - )) reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 + } } - else -> position += skipJsonValue(reader) + reader.endArray().also { position += 1 } } - if (reader.peek() != JsonToken.END_OBJECT) { - position += 1 - } - } - reader.endObject().also { position += 1 } - if (reader.peek() != JsonToken.END_ARRAY) { - position += 1 - } - } - reader.endArray().also { position += 1 } - } - "twitchEmotes" -> { - reader.beginArray().also { position += 1 } - while (reader.hasNext()) { - reader.beginObject().also { position += 1 } - var id: String? = null - var data: Pair? = null - while (reader.hasNext()) { - when (reader.nextName().also { position += it.length + 3 }) { - "data" -> { - position += 1 - val length = reader.nextString().length - data = Pair(position, length) - position += length + 1 + "twitchEmotes" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + var id: String? = null + var data: Pair? = null + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "data" -> { + position += 1 + val length = reader.nextString().length + data = Pair(position, length) + position += length + 1 + } + "id" -> id = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + if (!id.isNullOrBlank() && data != null) { + twitchEmotes.add(TwitchEmote( + id = id, + localData = data + )) + } + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 + } } - "id" -> id = reader.nextString().also { position += it.length + 2 } - else -> position += skipJsonValue(reader) + reader.endArray().also { position += 1 } } - if (reader.peek() != JsonToken.END_OBJECT) { - position += 1 - } - } - if (!id.isNullOrBlank() && data != null) { - twitchEmotes.add(TwitchEmote( - id = id, - localData = data - )) - } - reader.endObject().also { position += 1 } - if (reader.peek() != JsonToken.END_ARRAY) { - position += 1 - } - } - reader.endArray().also { position += 1 } - } - "twitchBadges" -> { - reader.beginArray().also { position += 1 } - while (reader.hasNext()) { - reader.beginObject().also { position += 1 } - var setId: String? = null - var version: String? = null - var data: Pair? = null - while (reader.hasNext()) { - when (reader.nextName().also { position += it.length + 3 }) { - "data" -> { - position += 1 - val length = reader.nextString().length - data = Pair(position, length) - position += length + 1 + "twitchBadges" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + var setId: String? = null + var version: String? = null + var data: Pair? = null + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "data" -> { + position += 1 + val length = reader.nextString().length + data = Pair(position, length) + position += length + 1 + } + "setId" -> setId = reader.nextString().also { position += it.length + 2 } + "version" -> version = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + if (!setId.isNullOrBlank() && !version.isNullOrBlank() && data != null) { + twitchBadges.add(TwitchBadge( + setId = setId, + version = version, + localData = data + )) + } + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 + } } - "setId" -> setId = reader.nextString().also { position += it.length + 2 } - "version" -> version = reader.nextString().also { position += it.length + 2 } - else -> position += skipJsonValue(reader) + reader.endArray().also { position += 1 } } - if (reader.peek() != JsonToken.END_OBJECT) { - position += 1 - } - } - if (!setId.isNullOrBlank() && !version.isNullOrBlank() && data != null) { - twitchBadges.add(TwitchBadge( - setId = setId, - version = version, - localData = data - )) - } - reader.endObject().also { position += 1 } - if (reader.peek() != JsonToken.END_ARRAY) { - position += 1 - } - } - reader.endArray().also { position += 1 } - } - "cheerEmotes" -> { - reader.beginArray().also { position += 1 } - while (reader.hasNext()) { - reader.beginObject().also { position += 1 } - var name: String? = null - var data: Pair? = null - var minBits: Int? = null - var color: String? = null - while (reader.hasNext()) { - when (reader.nextName().also { position += it.length + 3 }) { - "data" -> { - position += 1 - val length = reader.nextString().length - data = Pair(position, length) - position += length + 1 - } - "name" -> name = reader.nextString().also { position += it.length + 2 } - "minBits" -> minBits = reader.nextInt().also { position += it.toString().length } - "color" -> { - when (reader.peek()) { - JsonToken.STRING -> color = reader.nextString().also { position += it.length + 2 } - else -> position += skipJsonValue(reader) + "cheerEmotes" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + var name: String? = null + var data: Pair? = null + var minBits: Int? = null + var color: String? = null + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "data" -> { + position += 1 + val length = reader.nextString().length + data = Pair(position, length) + position += length + 1 + } + "name" -> name = reader.nextString().also { position += it.length + 2 } + "minBits" -> minBits = reader.nextInt().also { position += it.toString().length } + "color" -> { + when (reader.peek()) { + JsonToken.STRING -> color = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } + } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + if (!name.isNullOrBlank() && minBits != null && data != null) { + cheerEmotesList.add(CheerEmote( + name = name, + localData = data, + minBits = minBits, + color = color + )) + } + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 } } - else -> position += skipJsonValue(reader) + reader.endArray().also { position += 1 } } - if (reader.peek() != JsonToken.END_OBJECT) { - position += 1 - } - } - if (!name.isNullOrBlank() && minBits != null && data != null) { - cheerEmotesList.add(CheerEmote( - name = name, - localData = data, - minBits = minBits, - color = color - )) - } - reader.endObject().also { position += 1 } - if (reader.peek() != JsonToken.END_ARRAY) { - position += 1 - } - } - reader.endArray().also { position += 1 } - } - "emotes" -> { - reader.beginArray().also { position += 1 } - while (reader.hasNext()) { - reader.beginObject().also { position += 1 } - var data: Pair? = null - var name: String? = null - var isZeroWidth = false - while (reader.hasNext()) { - when (reader.nextName().also { position += it.length + 3 }) { - "data" -> { - position += 1 - val length = reader.nextString().length - data = Pair(position, length) - position += length + 1 + "emotes" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + var data: Pair? = null + var name: String? = null + var isZeroWidth = false + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "data" -> { + position += 1 + val length = reader.nextString().length + data = Pair(position, length) + position += length + 1 + } + "name" -> name = reader.nextString().also { position += it.length + 2 } + "isZeroWidth" -> isZeroWidth = reader.nextBoolean().also { position += it.toString().length } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + if (!name.isNullOrBlank() && data != null) { + emotes.add(Emote( + name = name, + localData = data, + isZeroWidth = isZeroWidth + )) + } + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 + } } - "name" -> name = reader.nextString().also { position += it.length + 2 } - "isZeroWidth" -> isZeroWidth = reader.nextBoolean().also { position += it.toString().length } - else -> position += skipJsonValue(reader) - } - if (reader.peek() != JsonToken.END_OBJECT) { - position += 1 + reader.endArray().also { position += 1 } } - } - if (!name.isNullOrBlank() && data != null) { - emotes.add(Emote( - name = name, - localData = data, - isZeroWidth = isZeroWidth - )) - } - reader.endObject().also { position += 1 } - if (reader.peek() != JsonToken.END_ARRAY) { - position += 1 + "startTime" -> { startTimeMs = reader.nextInt().also { position += it.toString().length }.times(1000L) } + else -> position += skipJsonValue(reader) } } - reader.endArray().also { position += 1 } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 } - "startTime" -> { startTimeMs = reader.nextInt().also { position += it.toString().length }.times(1000L) } - else -> position += skipJsonValue(reader) } + reader.endObject().also { position += 1 } } else -> position += skipJsonValue(reader) } - if (reader.peek() != JsonToken.END_OBJECT) { - position += 1 - } - } - reader.endObject().also { position += 1 } + } while (token != JsonToken.END_DOCUMENT) } } localTwitchEmotes.postValue(twitchEmotes) diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/ClipDownloadDialog.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/ClipDownloadDialog.kt index 6a96b85ef..20f142ab7 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/ClipDownloadDialog.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/ClipDownloadDialog.kt @@ -3,8 +3,10 @@ package com.github.andreyasadchy.xtra.ui.download import android.app.Activity import android.app.Dialog import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Bundle +import android.provider.DocumentsContract import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.edit import androidx.core.os.bundleOf @@ -81,19 +83,15 @@ class ClipDownloadDialog : BaseDownloadDialog() { requireContext().contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) sharedPath = it.toString() directory.visible() - directory.text = it.path?.substringAfter("/document/") + directory.text = it.path?.substringAfter("/tree/")?.removeSuffix(":") } } } selectDirectory.setOnClickListener { - resultLauncher.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - putExtra(Intent.EXTRA_TITLE, if (!viewModel.clip.id.isNullOrBlank()) { - "${viewModel.clip.id}${binding.spinner.editText?.text.toString()}.mp4" - } else { - "${System.currentTimeMillis()}.mp4" - }) + resultLauncher.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + putExtra(DocumentsContract.EXTRA_INITIAL_URI, sharedPath) + } }) } } @@ -104,6 +102,24 @@ class ClipDownloadDialog : BaseDownloadDialog() { with(binding) { val context = requireContext() init(context, binding.storageSelectionContainer, download) + val previousPath = prefs.getString(C.DOWNLOAD_SHARED_PATH, null) + if (!previousPath.isNullOrBlank()) { + sharedPath = previousPath + storageSelectionContainer.directory.apply { + visible() + text = Uri.decode(previousPath.substringAfter("/tree/")) + } + } + downloadChat.apply { + isChecked = prefs.getBoolean(C.DOWNLOAD_CHAT, false) + setOnCheckedChangeListener { _, isChecked -> + downloadChatEmotes.isEnabled = isChecked + } + } + downloadChatEmotes.apply { + isChecked = prefs.getBoolean(C.DOWNLOAD_CHAT_EMOTES, false) + isEnabled = downloadChat.isChecked + } (spinner.editText as? MaterialAutoCompleteTextView)?.apply { setSimpleItems(qualities.keys.toTypedArray()) setText(adapter.getItem(0).toString(), false) @@ -113,10 +129,17 @@ class ClipDownloadDialog : BaseDownloadDialog() { val quality = spinner.editText?.text.toString() val location = resources.getStringArray(R.array.spinnerStorage).indexOf(storageSelectionContainer.storageSpinner.editText?.text.toString()) val path = if (location == 0) sharedPath else downloadPath + val downloadChat = downloadChat.isChecked + val downloadChatEmotes = downloadChatEmotes.isChecked if (!path.isNullOrBlank()) { - viewModel.download(qualities.getValue(quality), path, quality, Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE || requireContext().prefs().getBoolean(C.DEBUG_WORKMANAGER_DOWNLOADS, false)) + viewModel.download(qualities.getValue(quality), path, quality, downloadChat, downloadChatEmotes, requireContext().prefs().getBoolean(C.DOWNLOAD_WIFI_ONLY, false)) requireContext().prefs().edit { putInt(C.DOWNLOAD_LOCATION, location) + if (location == 0) { + putString(C.DOWNLOAD_SHARED_PATH, sharedPath) + } + putBoolean(C.DOWNLOAD_CHAT, downloadChat) + putBoolean(C.DOWNLOAD_CHAT_EMOTES, downloadChatEmotes) } DownloadUtils.requestNotificationPermission(requireActivity()) } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/ClipDownloadViewModel.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/ClipDownloadViewModel.kt index 8f59f2bf4..5d2765c18 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/ClipDownloadViewModel.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/ClipDownloadViewModel.kt @@ -1,17 +1,17 @@ package com.github.andreyasadchy.xtra.ui.download -import android.content.ContentResolver import android.content.Context -import androidx.core.net.toUri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.work.Constraints import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.workDataOf -import com.github.andreyasadchy.xtra.model.offline.Request +import com.github.andreyasadchy.xtra.model.offline.OfflineVideo import com.github.andreyasadchy.xtra.model.ui.Clip import com.github.andreyasadchy.xtra.repository.GraphQLRepository import com.github.andreyasadchy.xtra.repository.OfflineRepository @@ -22,7 +22,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import java.io.File import javax.inject.Inject @HiltViewModel @@ -70,40 +69,51 @@ class ClipDownloadViewModel @Inject constructor( } } - fun download(url: String, path: String, quality: String, useWorkManager: Boolean) { + fun download(url: String, path: String, quality: String, downloadChat: Boolean, downloadChatEmotes: Boolean, wifiOnly: Boolean) { GlobalScope.launch { - val fileUri = if (path.toUri().scheme == ContentResolver.SCHEME_CONTENT) { - path - } else { - val fileName = if (!clip.id.isNullOrBlank()) { - "${clip.id}$quality" - } else { - System.currentTimeMillis() + with(clip) { + val downloadedThumbnail = id.takeIf { !it.isNullOrBlank() }?.let { + DownloadUtils.savePng(applicationContext, thumbnail, "thumbnails", it) } - "$path${File.separator}$fileName.mp4" - } - val offlineVideo = DownloadUtils.prepareDownload( - context = applicationContext, - downloadable = clip, - url = url, - path = fileUri, - downloadDate = System.currentTimeMillis(), - duration = clip.duration?.toLong()?.times(1000L), - startPosition = clip.vodOffset?.toLong()?.times(1000L)) - val videoId = offlineRepository.saveVideo(offlineVideo).toInt() - if (useWorkManager) { + val downloadedLogo = channelId.takeIf { !it.isNullOrBlank() }?.let { + DownloadUtils.savePng(applicationContext, channelLogo, "profile_pics", it) + } + val videoId = offlineRepository.saveVideo(OfflineVideo( + sourceUrl = url, + sourceStartPosition = vodOffset?.toLong()?.times(1000L), + name = title, + channelId = channelId, + channelLogin = channelLogin, + channelName = channelName, + channelLogo = downloadedLogo, + thumbnail = downloadedThumbnail, + gameId = gameId, + gameSlug = gameSlug, + gameName = gameName, + duration = duration?.toLong()?.times(1000L), + uploadDate = uploadDate?.let { TwitchApiHelper.parseIso8601DateUTC(it) }, + downloadDate = System.currentTimeMillis(), + downloadPath = path, + status = OfflineVideo.STATUS_BLOCKED, + videoId = videoId, + clipId = id, + quality = if (!quality.contains("Audio", true)) quality else "audio", + downloadChat = downloadChat, + downloadChatEmotes = downloadChatEmotes + )).toInt() WorkManager.getInstance(applicationContext).enqueueUniqueWork( - videoId.toString(), - ExistingWorkPolicy.KEEP, + "download", + ExistingWorkPolicy.APPEND_OR_REPLACE, OneTimeWorkRequestBuilder() .setInputData(workDataOf(DownloadWorker.KEY_VIDEO_ID to videoId)) + .addTag(videoId.toString()) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) + .build() + ) .build() ) - } else { - val request = Request(videoId, url, offlineVideo.url) - offlineRepository.saveRequest(request) - - DownloadUtils.download(applicationContext, request) } } } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/DownloadService.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/DownloadService.kt deleted file mode 100644 index f748283ec..000000000 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/DownloadService.kt +++ /dev/null @@ -1,384 +0,0 @@ -package com.github.andreyasadchy.xtra.ui.download - -import android.annotation.SuppressLint -import android.app.IntentService -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.BroadcastReceiver -import android.content.ContentResolver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.Build -import android.util.Log -import androidx.annotation.StringRes -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile -import com.github.andreyasadchy.xtra.R -import com.github.andreyasadchy.xtra.model.offline.OfflineVideo -import com.github.andreyasadchy.xtra.model.offline.Request -import com.github.andreyasadchy.xtra.repository.OfflineRepository -import com.github.andreyasadchy.xtra.repository.PlayerRepository -import com.github.andreyasadchy.xtra.ui.main.MainActivity -import com.github.andreyasadchy.xtra.util.C -import com.github.andreyasadchy.xtra.util.FetchProvider -import com.github.andreyasadchy.xtra.util.prefs -import com.iheartradio.m3u8.Encoding -import com.iheartradio.m3u8.Format -import com.iheartradio.m3u8.ParsingMode -import com.iheartradio.m3u8.PlaylistParser -import com.iheartradio.m3u8.data.MediaPlaylist -import com.iheartradio.m3u8.data.TrackData -import com.tonyodev.fetch2.AbstractFetchListener -import com.tonyodev.fetch2.Download -import com.tonyodev.fetch2.Fetch -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import okio.use -import java.io.File -import java.io.FileInputStream -import java.util.concurrent.CountDownLatch -import javax.inject.Inject -import kotlin.math.min -import com.tonyodev.fetch2.Request as FetchRequest - - -@AndroidEntryPoint -class DownloadService : IntentService(TAG) { - - companion object { - private const val TAG = "DownloadService" - private const val NOTIFICATION_TAG = "NotifActionReceiver" - - private const val ENQUEUE_SIZE = 15 - private const val REQUEST_CODE_DOWNLOAD = 0 - private const val REQUEST_CODE_PLAY = 1 - - const val GROUP_KEY = "com.github.andreyasadchy.xtra.DOWNLOADS" - const val KEY_REQUEST = "request" - const val KEY_WIFI = "wifi" - - const val ACTION_CANCEL = "com.github.andreyasadchy.xtra.ACTION_DOWNLOAD_CANCEL" - const val ACTION_PAUSE = "com.github.andreyasadchy.xtra.ACTION_DOWNLOAD_PAUSE" - const val ACTION_RESUME = "com.github.andreyasadchy.xtra.ACTION_DOWNLOAD_RESUME" - - val activeRequests = HashSet() - } - - @Inject - lateinit var playerRepository: PlayerRepository - - @Inject - lateinit var offlineRepository: OfflineRepository - - @Inject - lateinit var fetchProvider: FetchProvider - private lateinit var fetch: Fetch - private lateinit var notificationBuilder: NotificationCompat.Builder - private lateinit var notificationManager: NotificationManagerCompat - private lateinit var request: Request - private lateinit var offlineVideo: OfflineVideo - - private lateinit var playlist: MediaPlaylist - - private val notificationActionReceiver = NotificationActionReceiver() - private lateinit var pauseAction: NotificationCompat.Action - private lateinit var resumeAction: NotificationCompat.Action - - init { - setIntentRedelivery(true) - } - - @Deprecated("Deprecated in Java") - override fun onCreate() { - super.onCreate() - pauseAction = createAction(R.string.pause, ACTION_PAUSE, 1) - resumeAction = createAction(R.string.resume, ACTION_RESUME, 2) - registerReceiver(notificationActionReceiver, IntentFilter().apply { - addAction(ACTION_PAUSE) - addAction(ACTION_RESUME) - }) - } - - @Deprecated("Deprecated in Java") - @SuppressLint("CheckResult") - override fun onHandleIntent(intent: Intent?) { - request = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent!!.getParcelableExtra(KEY_REQUEST, Request::class.java)!! - } else { - @Suppress("DEPRECATION") - intent!!.getParcelableExtra(KEY_REQUEST)!! - } - offlineVideo = runBlocking { offlineRepository.getVideoById(request.offlineVideoId) } - ?: return //Download was canceled - Log.d(TAG, "Starting download. Id: ${offlineVideo.id}") - fetch = fetchProvider.get(offlineVideo.id, prefs().getInt(C.DOWNLOAD_CONCURRENT_LIMIT, 10)) - val countDownLatch = CountDownLatch(1) - val channelId = getString(R.string.notification_downloads_channel_id) - notificationBuilder = NotificationCompat.Builder(this, channelId).apply { - setSmallIcon(android.R.drawable.stat_sys_download) - setGroup(GROUP_KEY) - setContentTitle(ContextCompat.getString(this@DownloadService, R.string.downloading)) - setOngoing(true) - setContentText(offlineVideo.name) - val clickIntent = Intent(this@DownloadService, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra(MainActivity.KEY_CODE, MainActivity.INTENT_OPEN_DOWNLOADS_TAB) - } - setContentIntent(PendingIntent.getActivity(this@DownloadService, REQUEST_CODE_DOWNLOAD, clickIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)) - addAction(pauseAction) - } - - notificationManager = NotificationManagerCompat.from(this) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (manager.getNotificationChannel(channelId) == null) { - NotificationChannel(channelId, ContextCompat.getString(this, R.string.notification_downloads_channel_title), NotificationManager.IMPORTANCE_DEFAULT).apply { - setSound(null, null) - manager.createNotificationChannel(this) - } - } - } - updateProgress(offlineVideo.maxProgress, offlineVideo.progress) - if (offlineVideo.vod) { - fetch.addListener(object : AbstractFetchListener() { - var activeDownloadsCount = 0 - - override fun onAdded(download: Download) { - activeDownloadsCount++ - } - - override fun onCompleted(download: Download) { - with(offlineVideo) { - if (++progress < maxProgress) { - Log.d(TAG, "$progress / $maxProgress") - updateProgress(maxProgress, progress) - if (--activeDownloadsCount == 0) { - enqueueNext() - } - } else { - onDownloadCompleted() - countDownLatch.countDown() - } - } - } - - override fun onDeleted(download: Download) { - if (--activeDownloadsCount == 0) { - GlobalScope.launch { - offlineRepository.deleteVideo(this@DownloadService, offlineVideo) - } - stopForegroundInternal(true) - if (offlineVideo.url.toUri().scheme == ContentResolver.SCHEME_CONTENT) { - val directory = DocumentFile.fromTreeUri(this@DownloadService, request.path.toUri()) - val videoDirectory = directory?.findFile(offlineVideo.url.substringBeforeLast("%2F").substringAfterLast("%2F").substringAfterLast("%3A")) - if (videoDirectory != null && videoDirectory.listFiles().isEmpty()) { - directory.delete() - } - } else { - val directory = File(request.path) - if (directory.exists() && directory.list()?.isEmpty() == true) { - directory.deleteRecursively() - } - } - countDownLatch.countDown() - } - } - }) - GlobalScope.launch { - try { - playlist = if (offlineVideo.url.toUri().scheme == ContentResolver.SCHEME_CONTENT) { - contentResolver.openInputStream(offlineVideo.url.toUri()).use { - PlaylistParser(it, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse().mediaPlaylist - } - } else { - FileInputStream(File(offlineVideo.url)).use { - PlaylistParser(it, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse().mediaPlaylist - } - } - enqueueNext() - } catch (e: Exception) { - - } - } - } else { - fetch.addListener(object : AbstractFetchListener() { - override fun onCompleted(download: Download) { - onDownloadCompleted() - countDownLatch.countDown() - } - - override fun onProgress(download: Download, etaInMilliSeconds: Long, downloadedBytesPerSecond: Long) { - offlineVideo.progress = download.progress - updateProgress(100, download.progress) - } - - override fun onDeleted(download: Download) { - GlobalScope.launch { - offlineRepository.deleteVideo(this@DownloadService, offlineVideo) - } - stopForegroundInternal(true) - countDownLatch.countDown() - } - }) - fetch.enqueue(FetchRequest(request.url, request.path).apply { groupId = request.offlineVideoId }) - } - GlobalScope.launch { - offlineRepository.updateVideo(offlineVideo.apply { status = OfflineVideo.STATUS_DOWNLOADING }) - } - startForeground(offlineVideo.id, notificationBuilder.build()) - countDownLatch.await() - activeRequests.remove(request.offlineVideoId) - fetch.close() - } - - @Deprecated("Deprecated in Java") - override fun onDestroy() { - unregisterReceiver(notificationActionReceiver) - super.onDestroy() - } - - private fun enqueueNext() { - val requests = mutableListOf() - val tracks: List - try { - tracks = playlist.tracks - } catch (e: UninitializedPropertyAccessException) { - GlobalScope.launch { - delay(3000L) - enqueueNext() - } - return - } - with(request) { - val current = offlineVideo.progress - if (offlineVideo.url.toUri().scheme == ContentResolver.SCHEME_CONTENT) { - val directory = DocumentFile.fromTreeUri(this@DownloadService, path.toUri())!! - val videoDirectory = directory.findFile(offlineVideo.url.substringBeforeLast("%2F").substringAfterLast("%2F").substringAfterLast("%3A"))!! - try { - for (i in current..min(current + ENQUEUE_SIZE, tracks.lastIndex)) { - val track = tracks[i] - val trackUri = track.uri.substringAfterLast("%2F") - val trackFile = videoDirectory.findFile(trackUri) ?: videoDirectory.createFile("", trackUri) - requests.add(FetchRequest(url + trackUri, trackFile!!.uri.toString()).apply { groupId = offlineVideoId }) - } - } catch (e: IndexOutOfBoundsException) { - } - } else { - try { - for (i in current..min(current + ENQUEUE_SIZE, tracks.lastIndex)) { - val track = tracks[i] - requests.add(FetchRequest(url + track.uri, path + track.uri).apply { groupId = offlineVideoId }) - } - } catch (e: IndexOutOfBoundsException) { - } - } - } - fetch.enqueue(requests) - } - - private fun stopForegroundInternal(removeNotification: Boolean) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - stopForeground(if (removeNotification) Service.STOP_FOREGROUND_REMOVE else Service.STOP_FOREGROUND_DETACH) - } else { - @Suppress("DEPRECATION") - stopForeground(removeNotification) - } - } - - @SuppressLint("RestrictedApi") - private fun onDownloadCompleted() { - GlobalScope.launch { - offlineRepository.updateVideo(offlineVideo.apply { status = OfflineVideo.STATUS_DOWNLOADED }) - offlineRepository.deleteRequest(request) - } - val intent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra(MainActivity.KEY_VIDEO, offlineVideo) - putExtra(MainActivity.KEY_CODE, MainActivity.INTENT_OPEN_DOWNLOADED_VIDEO) - } - notificationBuilder.apply { - setAutoCancel(true) - setContentTitle(ContextCompat.getString(this@DownloadService, R.string.downloaded)) - setProgress(0, 0, false) - setOngoing(false) - setSmallIcon(android.R.drawable.stat_sys_download_done) - setContentIntent(PendingIntent.getActivity(this@DownloadService, REQUEST_CODE_PLAY, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)) - mActions.clear() - } - notificationManager.notify(offlineVideo.id, notificationBuilder.build()) - stopForegroundInternal(false) - } - - private fun updateProgress(maxProgress: Int, progress: Int) { - notificationManager.notify(offlineVideo.id, notificationBuilder.setProgress(maxProgress, progress, false).build()) - GlobalScope.launch { - offlineRepository.updateVideo(offlineVideo) - } - } - - private fun createAction(@StringRes title: Int, action: String, requestCode: Int) = NotificationCompat.Action(0, ContextCompat.getString(this, title), PendingIntent.getBroadcast(this, requestCode, Intent(action), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)) - - inner class NotificationActionReceiver : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - ACTION_CANCEL -> { - Log.d(NOTIFICATION_TAG, "Canceled download. Id: ${offlineVideo.id}") - GlobalScope.launch { - try { - activeRequests.remove(offlineVideo.id) - fetch.deleteAll() - } catch (e: Exception) { - } - } - } - ACTION_PAUSE -> { - Log.d(NOTIFICATION_TAG, "Paused download. Id: ${offlineVideo.id}") - notificationManager.notify(offlineVideo.id, notificationBuilder.run { - mActions.removeAt(0) - mActions.add(resumeAction) - build() - }) - fetch.pauseGroup(offlineVideo.id) - } - ACTION_RESUME -> { - Log.d(NOTIFICATION_TAG, "Resumed download. Id: ${offlineVideo.id}") - notificationManager.notify(offlineVideo.id, notificationBuilder.run { - mActions.removeAt(0) - mActions.add(pauseAction) - build() - }) - fetch.resumeGroup(offlineVideo.id) - } - } - } - } -} - -// val mainFile = File(path + "${System.currentTimeMillis()}.ts") -// try { -// for (i in segmentFrom!!..segmentTo!!) { -// val file = File("$path${playlist.tracks[i].uri}") -// mainFile.appendBytes(file.readBytes()) -// file.delete() -// } -// } catch (e: UninitializedPropertyAccessException) { -// GlobalScope.launch { -// delay(3000L) -// onDownloadCompleted() -// } -// return -// } catch (e: IndexOutOfBoundsException) { -// Crashlytics.log("DownloadService.onDownloadCompleted: Playlist tracks size: ${playlist.tracks.size}. Segment from $segmentFrom. Segment to: $segmentTo.") -// Crashlytics.logException(e) -// } -// Log.d(TAG, "Merged videos") \ No newline at end of file diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/DownloadWorker.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/DownloadWorker.kt index e62ba4dfe..3d9177fb0 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/DownloadWorker.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/DownloadWorker.kt @@ -3,12 +3,14 @@ package com.github.andreyasadchy.xtra.ui.download import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent -import android.content.ContentResolver.SCHEME_CONTENT +import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.os.Build import android.util.Base64 +import android.util.JsonReader +import android.util.JsonToken import android.util.JsonWriter import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat @@ -21,10 +23,12 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import com.github.andreyasadchy.xtra.R import com.github.andreyasadchy.xtra.model.Account +import com.github.andreyasadchy.xtra.model.chat.Badge import com.github.andreyasadchy.xtra.model.chat.CheerEmote import com.github.andreyasadchy.xtra.model.chat.Emote import com.github.andreyasadchy.xtra.model.chat.TwitchBadge import com.github.andreyasadchy.xtra.model.chat.TwitchEmote +import com.github.andreyasadchy.xtra.model.chat.VideoChatMessage import com.github.andreyasadchy.xtra.model.offline.OfflineVideo import com.github.andreyasadchy.xtra.repository.ApiRepository import com.github.andreyasadchy.xtra.repository.GraphQLRepository @@ -35,10 +39,13 @@ import com.github.andreyasadchy.xtra.util.C import com.github.andreyasadchy.xtra.util.TwitchApiHelper import com.github.andreyasadchy.xtra.util.prefs import com.google.gson.JsonElement +import com.google.gson.JsonObject import com.iheartradio.m3u8.Encoding import com.iheartradio.m3u8.Format import com.iheartradio.m3u8.ParsingMode import com.iheartradio.m3u8.PlaylistParser +import com.iheartradio.m3u8.PlaylistWriter +import com.iheartradio.m3u8.data.Playlist import com.iheartradio.m3u8.data.TrackData import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -62,10 +69,11 @@ import okio.buffer import okio.sink import okio.use import java.io.File +import java.io.FileFilter import java.io.FileInputStream import java.io.FileOutputStream +import java.io.StringReader import javax.inject.Inject -import kotlin.math.min @HiltWorker class DownloadWorker @AssistedInject constructor( @@ -89,19 +97,18 @@ class DownloadWorker @AssistedInject constructor( private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private lateinit var offlineVideo: OfflineVideo - private var progress = 0 override suspend fun doWork(): Result { offlineVideo = offlineRepository.getVideoById(inputData.getInt(KEY_VIDEO_ID, 0)) ?: return Result.failure() offlineRepository.updateVideo(offlineVideo.apply { status = OfflineVideo.STATUS_DOWNLOADING }) setForeground(createForegroundInfo()) return withContext(Dispatchers.IO) { - val sourceUrl = offlineVideo.sourceUrl - if (sourceUrl?.endsWith(".m3u8") == true) { + val sourceUrl = offlineVideo.sourceUrl!! + if (sourceUrl.endsWith(".m3u8")) { val path = offlineVideo.downloadPath!! val from = offlineVideo.fromTime!! val to = offlineVideo.toTime!! - val isShared = path.toUri().scheme == SCHEME_CONTENT + val isShared = path.toUri().scheme == ContentResolver.SCHEME_CONTENT val playlist = okHttpClient.newCall(Request.Builder().url(sourceUrl).build()).execute().use { response -> response.body.byteStream().use { PlaylistParser(it, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse().mediaPlaylist @@ -146,189 +153,420 @@ class DownloadWorker @AssistedInject constructor( } val urlPath = sourceUrl.substringBeforeLast('/') + "/" val tracks = ArrayList() - for (i in fromIndex + offlineVideo.progress..toIndex) { - val track = playlist.tracks[i] - tracks.add( - track.buildUpon() - .withUri(track.uri.replace("-unmuted", "-muted")) - .build() - ) - } - val videoFileUri = if (offlineVideo.url.isNotBlank()) { - progress = offlineVideo.progress - offlineVideo.url - } else { - val fileName = "${offlineVideo.videoId ?: ""}${offlineVideo.quality ?: ""}${offlineVideo.downloadDate}.${tracks.first().uri.substringAfterLast(".")}" - val fileUri = if (isShared) { - val directory = DocumentFile.fromTreeUri(applicationContext, path.toUri()) - directory?.createFile("", fileName)!!.uri.toString() - } else { - "$path${File.separator}$fileName" + if (offlineVideo.progress < offlineVideo.maxProgress) { + for (i in fromIndex + offlineVideo.progress..toIndex) { + val track = playlist.tracks[i] + tracks.add( + track.buildUpon() + .withUri(track.uri.replace("-unmuted", "-muted")) + .build() + ) } - val startPosition = relativeStartTimes[fromIndex] - offlineRepository.updateVideo(offlineVideo.apply { - url = fileUri - duration = (relativeStartTimes[toIndex] + durations[toIndex] - startPosition) - 1000L - sourceStartPosition = startPosition - maxProgress = toIndex - fromIndex + 1 - vod = false - }) - fileUri } val requestSemaphore = Semaphore(context.prefs().getInt(C.DOWNLOAD_CONCURRENT_LIMIT, 10)) - val mutexMap = mutableMapOf() - val count = MutableStateFlow(0) - val collector = launch { - count.collect { - mutexMap[it]?.unlock() - mutexMap.remove(it) - } - } - val jobs = tracks.map { - async { - requestSemaphore.withPermit { - okHttpClient.newCall(Request.Builder().url(urlPath + it.uri).build()).execute().use { response -> - val mutex = Mutex() - val id = tracks.indexOf(it) - if (count.value != id) { - mutex.lock() - mutexMap[id] = mutex + if (offlineVideo.playlistToFile) { + val videoFileUri = if (!offlineVideo.url.isNullOrBlank()) { + val fileUri = offlineVideo.url!! + if (isShared) { + context.contentResolver.openFileDescriptor(fileUri.toUri(), "rw")!!.use { + FileOutputStream(it.fileDescriptor).use { output -> + output.channel.truncate(offlineVideo.bytes) } - mutex.withLock { - if (isShared) { - context.contentResolver.openOutputStream(videoFileUri.toUri(), "wa")!!.sink().buffer().use { sink -> - sink.writeAll(response.body.source()) - } - } else { - File(videoFileUri).appendingSink().buffer().use { sink -> + } + } else { + FileOutputStream(fileUri).use { output -> + output.channel.truncate(offlineVideo.bytes) + } + } + fileUri + } else { + val fileName = "${offlineVideo.videoId ?: ""}${offlineVideo.quality ?: ""}${offlineVideo.downloadDate}.${tracks.first().uri.substringAfterLast(".")}" + val fileUri = if (isShared) { + val directory = DocumentFile.fromTreeUri(applicationContext, path.toUri())!! + (directory.findFile(fileName) ?: directory.createFile("", fileName))!!.uri.toString() + } else { + "$path${File.separator}$fileName" + } + val startPosition = relativeStartTimes[fromIndex] + offlineRepository.updateVideo(offlineVideo.apply { + url = fileUri + duration = (relativeStartTimes[toIndex] + durations[toIndex] - startPosition) - 1000L + sourceStartPosition = startPosition + maxProgress = toIndex - fromIndex + 1 + }) + fileUri + } + val mutexMap = mutableMapOf() + val count = MutableStateFlow(0) + val collector = launch { + count.collect { + mutexMap[it]?.unlock() + mutexMap.remove(it) + } + } + val jobs = tracks.map { + async { + requestSemaphore.withPermit { + okHttpClient.newCall(Request.Builder().url(urlPath + it.uri).build()).execute().use { response -> + val mutex = Mutex() + val id = tracks.indexOf(it) + if (count.value != id) { + mutex.lock() + mutexMap[id] = mutex + } + mutex.withLock { + if (isShared) { + context.contentResolver.openOutputStream(videoFileUri.toUri(), "wa")!!.sink().buffer() + } else { + File(videoFileUri).appendingSink().buffer() + }.use { sink -> sink.writeAll(response.body.source()) + offlineRepository.updateVideo(offlineVideo.apply { + bytes += response.body.contentLength() + progress += 1 + }) } } } + count.update { it + 1 } + setForeground(createForegroundInfo()) } - count.update { it + 1 } - progress += 1 - offlineRepository.updateVideo(offlineVideo.apply { progress = this@DownloadWorker.progress }) - setForeground(createForegroundInfo()) } } - } - val chatJob = startChatJob(this, path) - jobs.awaitAll() - collector.cancel() - chatJob.join() - if (offlineVideo.progress < offlineVideo.maxProgress) { - offlineRepository.updateVideo(offlineVideo.apply { status = OfflineVideo.STATUS_PENDING }) + val chatJob = startChatJob(this, path) + jobs.awaitAll() + collector.cancel() + chatJob.join() } else { - offlineRepository.updateVideo(offlineVideo.apply { status = OfflineVideo.STATUS_DOWNLOADED }) - } - } else { - val isShared = offlineVideo.url.toUri().scheme == SCHEME_CONTENT - if (offlineVideo.vod) { - val requestSemaphore = Semaphore(context.prefs().getInt(C.DOWNLOAD_CONCURRENT_LIMIT, 10)) - val jobs = if (isShared) { - val playlist = context.contentResolver.openInputStream(offlineVideo.url.toUri()).use { - PlaylistParser(it, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse().mediaPlaylist + val videoDirectoryName = if (!offlineVideo.videoId.isNullOrBlank()) { + "${offlineVideo.videoId}${offlineVideo.quality ?: ""}" + } else { + "${offlineVideo.downloadDate}" + } + if (isShared) { + val directory = DocumentFile.fromTreeUri(applicationContext, path.toUri())!! + val videoDirectory = directory.findFile(videoDirectoryName) ?: directory.createDirectory(videoDirectoryName)!! + val playlistFileUri = if (!offlineVideo.url.isNullOrBlank()) { + offlineVideo.url!! + } else { + val sharedTracks = ArrayList() + for (i in fromIndex..toIndex) { + val track = playlist.tracks[i] + sharedTracks.add( + track.buildUpon() + .withUri(videoDirectory.uri.toString() + "%2F" + track.uri.replace("-unmuted", "-muted")) + .build() + ) + } + val fileName = "${offlineVideo.downloadDate}.m3u8" + val playlistFile = videoDirectory.findFile(fileName) ?: videoDirectory.createFile("", fileName)!! + applicationContext.contentResolver.openOutputStream(playlistFile.uri).use { + PlaylistWriter(it, Format.EXT_M3U, Encoding.UTF_8).write(Playlist.Builder().withMediaPlaylist(playlist.buildUpon().withTracks(sharedTracks).build()).build()) + } + val playlistUri = playlistFile.uri.toString() + val startPosition = relativeStartTimes[fromIndex] + offlineRepository.updateVideo(offlineVideo.apply { + url = playlistUri + duration = (relativeStartTimes[toIndex] + durations[toIndex] - startPosition) - 1000L + sourceStartPosition = startPosition + maxProgress = toIndex - fromIndex + 1 + }) + playlistUri + } + val downloadedTracks = mutableListOf() + val playlists = videoDirectory.listFiles().filter { it.isFile && it.name?.endsWith(".m3u8") == true && it.uri.toString() != playlistFileUri } + playlists.forEach { file -> + val p = applicationContext.contentResolver.openInputStream(file.uri).use { + PlaylistParser(it, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse() + } + p.mediaPlaylist.tracks.forEach { downloadedTracks.add(it.uri.substringAfterLast("%2F").substringAfterLast("/")) } } - val directory = DocumentFile.fromTreeUri(context, offlineVideo.url.substringBefore("/document/").toUri())!! - val videoDirectory = directory.findFile(offlineVideo.url.substringBeforeLast("%2F").substringAfterLast("%2F").substringAfterLast("%3A"))!! - playlist.tracks.map { + val jobs = tracks.map { async { requestSemaphore.withPermit { - val uri = it.uri.substringAfterLast("%2F") - if (videoDirectory.findFile(uri) == null) { - downloadShared(offlineVideo.sourceUrl + uri, videoDirectory.createFile("", uri)!!.uri.toString()) + if (videoDirectory.findFile(it.uri) == null || !downloadedTracks.contains(it.uri)) { + okHttpClient.newCall(Request.Builder().url(urlPath + it.uri).build()).execute().use { response -> + val file = videoDirectory.findFile(it.uri) ?: videoDirectory.createFile("", it.uri)!! + context.contentResolver.openOutputStream(file.uri)!!.sink().buffer().use { sink -> + sink.writeAll(response.body.source()) + } + } } - progress += 1 - offlineRepository.updateVideo(offlineVideo.apply { progress = this@DownloadWorker.progress }) + offlineRepository.updateVideo(offlineVideo.apply { progress += 1 }) setForeground(createForegroundInfo()) } } } + val chatJob = startChatJob(this, path) + jobs.awaitAll() + chatJob.join() } else { - val playlist = FileInputStream(File(offlineVideo.url)).use { - PlaylistParser(it, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse().mediaPlaylist + val directory = "$path${File.separator}$videoDirectoryName${File.separator}" + val playlistFileUri = if (!offlineVideo.url.isNullOrBlank()) { + offlineVideo.url!! + } else { + File(directory).mkdir() + val playlistUri = "$directory${offlineVideo.downloadDate}.m3u8" + FileOutputStream(playlistUri).use { + PlaylistWriter(it, Format.EXT_M3U, Encoding.UTF_8).write(Playlist.Builder().withMediaPlaylist(playlist.buildUpon().withTracks(tracks).build()).build()) + } + val startPosition = relativeStartTimes[fromIndex] + offlineRepository.updateVideo(offlineVideo.apply { + url = playlistUri + duration = (relativeStartTimes[toIndex] + durations[toIndex] - startPosition) - 1000L + sourceStartPosition = startPosition + maxProgress = toIndex - fromIndex + 1 + }) + playlistUri + } + val downloadedTracks = mutableListOf() + val playlists = File(directory).listFiles(FileFilter { it.extension == "m3u8" && it.path != playlistFileUri }) + playlists?.forEach { file -> + val p = PlaylistParser(file.inputStream(), Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse() + p.mediaPlaylist.tracks.forEach { downloadedTracks.add(it.uri.substringAfterLast("%2F").substringAfterLast("/")) } } - playlist.tracks.map { + val jobs = tracks.map { async { requestSemaphore.withPermit { - val output = File(offlineVideo.url).parent!! + "/" + it.uri - if (!File(output).exists()) { - download(offlineVideo.sourceUrl + it.uri, output) + if (!File(directory + it.uri).exists() || !downloadedTracks.contains(it.uri)) { + okHttpClient.newCall(Request.Builder().url(urlPath + it.uri).build()).execute().use { response -> + File(directory + it.uri).sink().buffer().use { sink -> + sink.writeAll(response.body.source()) + } + } } - progress += 1 - offlineRepository.updateVideo(offlineVideo.apply { progress = this@DownloadWorker.progress }) + offlineRepository.updateVideo(offlineVideo.apply { progress += 1 }) setForeground(createForegroundInfo()) } } } + val chatJob = startChatJob(this, path) + jobs.awaitAll() + chatJob.join() } - val chatJob = startChatJob(this, if (isShared) { - offlineVideo.url.substringBefore("/document/") - } else { - offlineVideo.url.substringBeforeLast("/").substringBeforeLast("/") - }) - jobs.awaitAll() - chatJob.join() - if (offlineVideo.progress < offlineVideo.maxProgress) { - offlineRepository.updateVideo(offlineVideo.apply { status = OfflineVideo.STATUS_PENDING }) + } + } else { + val path = offlineVideo.downloadPath!! + val isShared = path.toUri().scheme == ContentResolver.SCHEME_CONTENT + val videoFileUri = if (!offlineVideo.url.isNullOrBlank()) { + offlineVideo.url!! + } else { + val fileName = if (!offlineVideo.clipId.isNullOrBlank()) { + "${offlineVideo.clipId}${offlineVideo.quality ?: ""}.mp4" } else { - offlineRepository.updateVideo(offlineVideo.apply { status = OfflineVideo.STATUS_DOWNLOADED }) + "${offlineVideo.downloadDate}.mp4" } - } else { - if (isShared) { - downloadShared(offlineVideo.sourceUrl!!, offlineVideo.url) + val fileUri = if (isShared) { + val directory = DocumentFile.fromTreeUri(applicationContext, path.toUri())!! + (directory.findFile(fileName) ?: directory.createFile("", fileName))!!.uri.toString() } else { - if (!File(offlineVideo.url).exists()) { - download(offlineVideo.sourceUrl!!, offlineVideo.url) + "$path${File.separator}$fileName" + } + offlineRepository.updateVideo(offlineVideo.apply { + url = fileUri + }) + fileUri + } + val jobs = async { + if (offlineVideo.progress < offlineVideo.maxProgress) { + okHttpClient.newCall(Request.Builder().url(sourceUrl).build()).execute().use { response -> + if (isShared) { + context.contentResolver.openOutputStream(videoFileUri.toUri())!!.sink().buffer() + } else { + File(videoFileUri).sink().buffer() + }.use { sink -> + sink.writeAll(response.body.source()) + } } + offlineRepository.updateVideo(offlineVideo.apply { progress = offlineVideo.maxProgress }) + setForeground(createForegroundInfo()) } - offlineRepository.updateVideo(offlineVideo.apply { status = OfflineVideo.STATUS_DOWNLOADED }) } + val chatJob = startChatJob(this, path) + jobs.join() + chatJob.join() } - Result.success() - } - } - - private fun downloadShared(url: String, output: String) { - okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> - context.contentResolver.openOutputStream(output.toUri())!!.sink().buffer().use { sink -> - sink.writeAll(response.body.source()) - } - } - } - - private fun download(url: String, output: String) { - okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> - File(output).sink().buffer().use { sink -> - sink.writeAll(response.body.source()) + if (offlineVideo.progress < offlineVideo.maxProgress || offlineVideo.downloadChat && offlineVideo.chatProgress < offlineVideo.maxChatProgress) { + offlineRepository.updateVideo(offlineVideo.apply { status = OfflineVideo.STATUS_PENDING }) + } else { + offlineRepository.updateVideo(offlineVideo.apply { status = OfflineVideo.STATUS_DOWNLOADED }) } + Result.success() } } private fun startChatJob(coroutineScope: CoroutineScope, path: String): Job { return coroutineScope.launch { - if (offlineVideo.downloadChat == true && offlineVideo.chatUrl == null) { + if (offlineVideo.downloadChat && offlineVideo.chatProgress < offlineVideo.maxChatProgress) { offlineVideo.videoId?.let { videoId -> - val isShared = path.toUri().scheme == SCHEME_CONTENT - val fileName = "${videoId}${offlineVideo.quality ?: ""}${offlineVideo.downloadDate}_chat.json" - val fileUri = if (isShared) { - val directory = DocumentFile.fromTreeUri(applicationContext, path.toUri()) - (directory?.findFile(fileName) ?: directory?.createFile("", fileName))!!.uri.toString() - } else { - "$path${File.separator}$fileName" - } - val downloadEmotes = offlineVideo.downloadChatEmotes == true + val isShared = path.toUri().scheme == ContentResolver.SCHEME_CONTENT val startTimeSeconds = (offlineVideo.sourceStartPosition!! / 1000).toInt() val durationSeconds = (offlineVideo.duration!! / 1000).toInt() val endTimeSeconds = startTimeSeconds + durationSeconds + val fileName = "${videoId}${offlineVideo.quality ?: ""}${offlineVideo.downloadDate}_chat.json" + val resumed = !offlineVideo.chatUrl.isNullOrBlank() + val savedOffset = offlineVideo.chatOffsetSeconds + val latestSavedMessages = mutableListOf() + val savedTwitchEmotes = mutableListOf() + val savedBadges = mutableListOf>() + val savedEmotes = mutableListOf() + val fileUri = if (resumed) { + val fileUri = offlineVideo.chatUrl!! + if (isShared) { + context.contentResolver.openFileDescriptor(fileUri.toUri(), "rw")!!.use { + FileOutputStream(it.fileDescriptor).use { output -> + output.channel.truncate(offlineVideo.chatBytes) + } + } + } else { + FileOutputStream(fileUri).use { output -> + output.channel.truncate(offlineVideo.chatBytes) + } + } + if (isShared) { + context.contentResolver.openOutputStream(fileUri.toUri(), "wa")!!.bufferedWriter() + } else { + FileOutputStream(fileUri, true).bufferedWriter() + }.use { fileWriter -> + fileWriter.write("}") + } + if (isShared) { + context.contentResolver.openInputStream(fileUri.toUri())?.bufferedReader() + } else { + FileInputStream(File(fileUri)).bufferedReader() + }?.use { fileReader -> + JsonReader(fileReader).use { reader -> + reader.isLenient = true + var token: JsonToken + do { + token = reader.peek() + when (token) { + JsonToken.END_DOCUMENT -> {} + JsonToken.BEGIN_OBJECT -> { + reader.beginObject() + while (reader.hasNext()) { + when (reader.peek()) { + JsonToken.NAME -> { + when (reader.nextName()) { + "comments" -> { + reader.beginArray() + while (reader.hasNext()) { + readMessageObject(reader)?.let { + if (it.offsetSeconds == savedOffset) { + latestSavedMessages.add(it) + } + } + } + reader.endArray() + } + "twitchEmotes" -> { + reader.beginArray() + while (reader.hasNext()) { + reader.beginObject() + var id: String? = null + while (reader.hasNext()) { + when (reader.nextName()) { + "id" -> id = reader.nextString() + else -> reader.skipValue() + } + } + if (!id.isNullOrBlank()) { + savedTwitchEmotes.add(id) + } + reader.endObject() + } + reader.endArray() + } + "twitchBadges" -> { + reader.beginArray() + while (reader.hasNext()) { + reader.beginObject() + var setId: String? = null + var version: String? = null + while (reader.hasNext()) { + when (reader.nextName()) { + "setId" -> setId = reader.nextString() + "version" -> version = reader.nextString() + else -> reader.skipValue() + } + } + if (!setId.isNullOrBlank() && !version.isNullOrBlank()) { + savedBadges.add(Pair(setId, version)) + } + reader.endObject() + } + reader.endArray() + } + "cheerEmotes" -> { + reader.beginArray() + while (reader.hasNext()) { + reader.beginObject() + var name: String? = null + while (reader.hasNext()) { + when (reader.nextName()) { + "name" -> name = reader.nextString() + else -> reader.skipValue() + } + } + if (!name.isNullOrBlank()) { + savedEmotes.add(name) + } + reader.endObject() + } + reader.endArray() + } + "emotes" -> { + reader.beginArray() + while (reader.hasNext()) { + reader.beginObject() + var name: String? = null + while (reader.hasNext()) { + when (reader.nextName()) { + "name" -> name = reader.nextString() + else -> reader.skipValue() + } + } + if (!name.isNullOrBlank()) { + savedEmotes.add(name) + } + reader.endObject() + } + reader.endArray() + } + else -> reader.skipValue() + } + } + else -> reader.skipValue() + } + } + reader.endObject() + } + else -> reader.skipValue() + } + } while (token != JsonToken.END_DOCUMENT) + } + } + fileUri + } else { + val fileUri = if (isShared) { + val directory = DocumentFile.fromTreeUri(applicationContext, path.toUri()) + (directory?.findFile(fileName) ?: directory?.createFile("", fileName))!!.uri.toString() + } else { + "$path${File.separator}$fileName" + } + offlineRepository.updateVideo(offlineVideo.apply { + maxChatProgress = durationSeconds + chatUrl = fileUri + }) + fileUri + } + val downloadEmotes = offlineVideo.downloadChatEmotes val gqlHeaders = TwitchApiHelper.getGQLHeaders(context, true) val helixClientId = context.prefs().getString(C.HELIX_CLIENT_ID, "ilfexgv3nnljz3isbm257gzwrzr7bi") val helixToken = Account.get(context).helixToken val emoteQuality = context.prefs().getString(C.CHAT_IMAGE_QUALITY, "4") ?: "4" val channelId = offlineVideo.channelId val channelLogin = offlineVideo.channelLogin - val savedTwitchEmotes = mutableListOf() val badgeList = mutableListOf().apply { if (downloadEmotes) { val channelBadges = try { repository.loadChannelBadges(helixClientId, helixToken, gqlHeaders, channelId, channelLogin, emoteQuality, false) } catch (e: Exception) { emptyList() } @@ -337,7 +575,6 @@ class DownloadWorker @AssistedInject constructor( addAll(globalBadges.filter { badge -> badge.setId !in channelBadges.map { it.setId } }) } } - val savedBadges = mutableListOf() val cheerEmoteList = if (downloadEmotes) { try { repository.loadCheerEmotes(helixClientId, helixToken, gqlHeaders, channelId, channelLogin, animateGifs = true, checkIntegrity = false) @@ -357,47 +594,67 @@ class DownloadWorker @AssistedInject constructor( try { playerRepository.loadGlobalFfzEmotes().body()?.emotes?.let { addAll(it) } } catch (e: Exception) {} } } - val savedEmotes = mutableListOf() if (isShared) { - context.contentResolver.openOutputStream(fileUri.toUri())!!.bufferedWriter() + context.contentResolver.openOutputStream(fileUri.toUri(), if (resumed) "wa" else "w")!!.bufferedWriter() } else { - FileOutputStream(fileUri).bufferedWriter() + FileOutputStream(fileUri, resumed).bufferedWriter() }.use { fileWriter -> JsonWriter(fileWriter).use { writer -> - writer.beginObject() - writer.name("video") - writer.beginObject() - writer.name("id").value(videoId) - writer.name("title").value(offlineVideo.name) - writer.name("uploadDate").value(offlineVideo.uploadDate) - writer.name("channelId").value(offlineVideo.channelId) - writer.name("channelLogin").value(offlineVideo.channelLogin) - writer.name("channelName").value(offlineVideo.channelName) - writer.name("gameId").value(offlineVideo.gameId) - writer.name("gameSlug").value(offlineVideo.gameSlug) - writer.name("gameName").value(offlineVideo.gameName) - writer.endObject() - writer.name("startTime").value(startTimeSeconds) + var position = offlineVideo.chatBytes + if (!resumed) { + writer.beginObject().also { position += 1 } + writer.name("video".also { position += it.length + 3 }) + writer.beginObject().also { position += 1 } + writer.name("id".also { position += it.length + 3 }).value(videoId.also { position += it.length + 2 }) + offlineVideo.name?.let { value -> writer.name("title".also { position += it.length + 4 }).value(value.also { position += it.toByteArray().size + it.count { c -> c == '"' || c == '\\' } + 2 }) } + offlineVideo.uploadDate?.let { value -> writer.name("uploadDate".also { position += it.length + 4 }).value(value.also { position += it.toString().length }) } + offlineVideo.channelId?.let { value -> writer.name("channelId".also { position += it.length + 4 }).value(value.also { position += it.length + 2 }) } + offlineVideo.channelLogin?.let { value -> writer.name("channelLogin".also { position += it.length + 4 }).value(value.also { position += it.toByteArray().size + it.count { c -> c == '"' || c == '\\' } + 2 }) } + offlineVideo.channelName?.let { value -> writer.name("channelName".also { position += it.length + 4 }).value(value.also { position += it.toByteArray().size + it.count { c -> c == '"' || c == '\\' } + 2 }) } + offlineVideo.gameId?.let { value -> writer.name("gameId".also { position += it.length + 4 }).value(value.also { position += it.length + 2 }) } + offlineVideo.gameSlug?.let { value -> writer.name("gameSlug".also { position += it.length + 4 }).value(value.also { position += it.toByteArray().size + it.count { c -> c == '"' || c == '\\' } + 2 }) } + offlineVideo.gameName?.let { value -> writer.name("gameName".also { position += it.length + 4 }).value(value.also { position += it.toByteArray().size + it.count { c -> c == '"' || c == '\\' } + 2 }) } + writer.endObject().also { position += 1 } + writer.name("startTime".also { position += it.length + 4 }).value(startTimeSeconds.also { position += it.toString().length }) + } var cursor: String? = null do { val get = if (cursor == null) { - graphQLRepository.loadVideoMessagesDownload(gqlHeaders, videoId, offset = startTimeSeconds) + graphQLRepository.loadVideoMessagesDownload(gqlHeaders, videoId, offset = if (resumed) savedOffset else startTimeSeconds) } else { graphQLRepository.loadVideoMessagesDownload(gqlHeaders, videoId, cursor = cursor) } + val comments = if (cursor == null && resumed) { + writer.beginObject().also { position += 1 } + val list = mutableListOf() + get.data.forEach { json -> + StringReader(json.toString()).use { string -> + JsonReader(string).use { reader -> + readMessageObject(reader)?.let { + it.offsetSeconds?.let { offset -> + if ((offset == savedOffset && !latestSavedMessages.contains(it)) || offset > savedOffset) { + list.add(json) + } + } + } + } + } + } + list + } else get.data cursor = get.cursor - val comments = get.data if (comments.isNotEmpty()) { - writer.name("comments") - writer.beginArray() - comments.forEach { json -> - writer.beginObject() - json.keySet().forEach { key -> - writeJsonElement(key, json.get(key), writer) + writer.name("comments".also { position += it.length + 4 }) + writer.beginArray().also { position += 1 } + var empty = true + comments.forEach { + val length = writeJsonElement(null, it, writer) + if (length > 0L) { + position += length + 1 + empty = false } - writer.endObject() } - writer.endArray() + writer.endArray().also { if (empty) { position += 1 } } } if (downloadEmotes) { val twitchEmotes = mutableListOf() @@ -411,10 +668,13 @@ class DownloadWorker @AssistedInject constructor( } } get.badges.forEach { - val badge = badgeList.find { badge -> badge.setId == it.setId && badge.version == it.version } - if (badge != null && !savedBadges.contains(badge)) { - savedBadges.add(badge) - twitchBadges.add(badge) + val pair = Pair(it.setId, it.version) + if (!savedBadges.contains(pair)) { + savedBadges.add(pair) + val badge = badgeList.find { badge -> badge.setId == it.setId && badge.version == it.version } + if (badge != null) { + twitchBadges.add(badge) + } } } get.words.forEach { word -> @@ -437,8 +697,9 @@ class DownloadWorker @AssistedInject constructor( } } if (twitchEmotes.isNotEmpty()) { - writer.name("twitchEmotes") - writer.beginArray() + writer.name("twitchEmotes".also { position += it.length + 4 }) + writer.beginArray().also { position += 1 } + val last = twitchEmotes.lastOrNull() twitchEmotes.forEach { emote -> val url = when (emoteQuality) { "4" -> emote.url4x ?: emote.url3x ?: emote.url2x ?: emote.url1x @@ -447,17 +708,21 @@ class DownloadWorker @AssistedInject constructor( else -> emote.url1x }!! okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> - writer.beginObject() - writer.name("data").value(Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)) - writer.name("id").value(emote.id) - writer.endObject() + writer.beginObject().also { position += 1 } + writer.name("data".also { position += it.length + 3 }).value(Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING).also { position += it.toByteArray().size + 2 }) + writer.name("id".also { position += it.length + 4 }).value(emote.id.also { position += it.toString().toByteArray().size + it.toString().count { c -> c == '"' || c == '\\' } + 2 }) + writer.endObject().also { position += 1 } + } + if (emote != last) { + position += 1 } } - writer.endArray() + writer.endArray().also { position += 1 } } if (twitchBadges.isNotEmpty()) { - writer.name("twitchBadges") - writer.beginArray() + writer.name("twitchBadges".also { position += it.length + 4 }) + writer.beginArray().also { position += 1 } + val last = twitchBadges.lastOrNull() twitchBadges.forEach { badge -> val url = when (emoteQuality) { "4" -> badge.url4x ?: badge.url3x ?: badge.url2x ?: badge.url1x @@ -466,18 +731,22 @@ class DownloadWorker @AssistedInject constructor( else -> badge.url1x }!! okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> - writer.beginObject() - writer.name("data").value(Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)) - writer.name("setId").value(badge.setId) - writer.name("version").value(badge.version) - writer.endObject() + writer.beginObject().also { position += 1 } + writer.name("data".also { position += it.length + 3 }).value(Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING).also { position += it.toByteArray().size + 2 }) + writer.name("setId".also { position += it.length + 4 }).value(badge.setId.also { position += it.toByteArray().size + it.count { c -> c == '"' || c == '\\' } + 2 }) + writer.name("version".also { position += it.length + 4 }).value(badge.version.also { position += it.toByteArray().size + it.count { c -> c == '"' || c == '\\' } + 2 }) + writer.endObject().also { position += 1 } + } + if (badge != last) { + position += 1 } } - writer.endArray() + writer.endArray().also { position += 1 } } if (cheerEmotes.isNotEmpty()) { - writer.name("cheerEmotes") - writer.beginArray() + writer.name("cheerEmotes".also { position += it.length + 4 }) + writer.beginArray().also { position += 1 } + val last = cheerEmotes.lastOrNull() cheerEmotes.forEach { cheerEmote -> val url = when (emoteQuality) { "4" -> cheerEmote.url4x ?: cheerEmote.url3x ?: cheerEmote.url2x ?: cheerEmote.url1x @@ -486,19 +755,23 @@ class DownloadWorker @AssistedInject constructor( else -> cheerEmote.url1x }!! okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> - writer.beginObject() - writer.name("data").value(Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)) - writer.name("name").value(cheerEmote.name) - writer.name("minBits").value(cheerEmote.minBits) - writer.name("color").value(cheerEmote.color) - writer.endObject() + writer.beginObject().also { position += 1 } + writer.name("data".also { position += it.length + 3 }).value(Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING).also { position += it.toByteArray().size + 2 }) + writer.name("name".also { position += it.length + 4 }).value(cheerEmote.name.also { position += it.toByteArray().size + it.count { c -> c == '"' || c == '\\' } + 2 }) + writer.name("minBits".also { position += it.length + 4 }).value(cheerEmote.minBits.also { position += it.toString().length }) + cheerEmote.color?.let { value -> writer.name("color".also { position += it.length + 4 }).value(value.also { position += it.toByteArray().size + it.count { c -> c == '"' || c == '\\' } + 2 }) } + writer.endObject().also { position += 1 } + } + if (cheerEmote != last) { + position += 1 } } - writer.endArray() + writer.endArray().also { position += 1 } } if (emotes.isNotEmpty()) { - writer.name("emotes") - writer.beginArray() + writer.name("emotes".also { position += it.length + 4 }) + writer.beginArray().also { position += 1 } + val last = emotes.lastOrNull() emotes.forEach { emote -> val url = when (emoteQuality) { "4" -> emote.url4x ?: emote.url3x ?: emote.url2x ?: emote.url1x @@ -507,67 +780,222 @@ class DownloadWorker @AssistedInject constructor( else -> emote.url1x }!! okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> - writer.beginObject() - writer.name("data").value(Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)) - writer.name("name").value(emote.name) - writer.name("isZeroWidth").value(emote.isZeroWidth) - writer.endObject() + writer.beginObject().also { position += 1 } + writer.name("data".also { position += it.length + 3 }).value(Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING).also { position += it.toByteArray().size + 2 }) + writer.name("name".also { position += it.length + 4 }).value(emote.name.also { position += it.toString().toByteArray().size + it.toString().count { c -> c == '"' || c == '\\' } + 2 }) + writer.name("isZeroWidth".also { position += it.length + 4 }).value(emote.isZeroWidth.also { position += it.toString().length }) + writer.endObject().also { position += 1 } + } + if (emote != last) { + position += 1 } } - writer.endArray() + writer.endArray().also { position += 1 } } } if (get.lastOffsetSeconds != null) { offlineRepository.updateVideo(offlineVideo.apply { - chatProgress = min((((get.lastOffsetSeconds - startTimeSeconds).toFloat() / durationSeconds) * 100f).toInt(), 100) + chatProgress = get.lastOffsetSeconds - startTimeSeconds + chatBytes = position + chatOffsetSeconds = get.lastOffsetSeconds }) } } while (get.lastOffsetSeconds?.let { it < endTimeSeconds } != false && !get.cursor.isNullOrBlank() && get.hasNextPage != false) - writer.endObject() + writer.endObject().also { position += 1 } } } - offlineRepository.updateVideo(offlineVideo.apply { - chatUrl = fileUri - }) } } } } - private fun writeJsonElement(key: String, value: JsonElement, writer: JsonWriter) { + private fun writeJsonElement(key: String?, value: JsonElement, writer: JsonWriter): Long { + var position = 0L if (key != "__typename") { when { value.isJsonObject -> { - writer.name(key) - writer.beginObject() + if (key != null) { + writer.name(key.also { position += it.length + 3 }) + } + writer.beginObject().also { position += 1 } + var empty = true value.asJsonObject.entrySet().forEach { - writeJsonElement(it.key, it.value, writer) + val length = writeJsonElement(it.key, it.value, writer) + if (length > 0L) { + position += length + 1 + empty = false + } } - writer.endObject() + writer.endObject().also { if (empty) { position += 1 } } } value.isJsonArray -> { - writer.name(key) - writer.beginArray() - value.asJsonArray.forEach { json -> - if (json.isJsonObject) { - writer.beginObject() - json.asJsonObject.entrySet().forEach { - writeJsonElement(it.key, it.value, writer) - } - writer.endObject() + if (key != null) { + writer.name(key.also { position += it.length + 3 }) + } + writer.beginArray().also { position += 1 } + var empty = true + value.asJsonArray.forEach { + val length = writeJsonElement(null, it, writer) + if (length > 0L) { + position += length + 1 + empty = false } } - writer.endArray() + writer.endArray().also { if (empty) { position += 1 } } } value.isJsonPrimitive -> { when { - value.asJsonPrimitive.isString -> writer.name(key).value(value.asString) - value.asJsonPrimitive.isBoolean -> writer.name(key).value(value.asBoolean) - value.asJsonPrimitive.isNumber -> writer.name(key).value(value.asNumber) + value.asJsonPrimitive.isString -> { + if (key != null) { + writer.name(key.also { position += it.length + 3 }) + } + writer.value(value.asString.also { position += it.toByteArray().size + it.count { c -> c == '"' || c == '\\' } + 2 }) + } + value.asJsonPrimitive.isBoolean -> { + if (key != null) { + writer.name(key.also { position += it.length + 3 }) + } + writer.value(value.asBoolean.also { position += it.toString().length }) + } + value.asJsonPrimitive.isNumber -> { + if (key != null) { + writer.name(key.also { position += it.length + 3 }) + } + writer.value(value.asNumber.also { position += it.toString().length }) + } + } + } + } + } + return position + } + + private fun readMessageObject(reader: JsonReader): VideoChatMessage? { + var chatMessage: VideoChatMessage? = null + reader.beginObject() + val message = StringBuilder() + var id: String? = null + var offsetSeconds: Int? = null + var userId: String? = null + var userLogin: String? = null + var userName: String? = null + var color: String? = null + val emotesList = mutableListOf() + val badgesList = mutableListOf() + while (reader.hasNext()) { + when (reader.nextName()) { + "id" -> id = reader.nextString() + "commenter" -> { + when (reader.peek()) { + JsonToken.BEGIN_OBJECT -> { + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "id" -> userId = reader.nextString() + "login" -> userLogin = reader.nextString() + "displayName" -> userName = reader.nextString() + else -> reader.skipValue() + } + } + reader.endObject() + } + else -> reader.skipValue() } } + "contentOffsetSeconds" -> offsetSeconds = reader.nextInt() + "message" -> { + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "fragments" -> { + reader.beginArray() + while (reader.hasNext()) { + reader.beginObject() + var emoteId: String? = null + var fragmentText: String? = null + while (reader.hasNext()) { + when (reader.nextName()) { + "emote" -> { + when (reader.peek()) { + JsonToken.BEGIN_OBJECT -> { + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "emoteID" -> emoteId = reader.nextString() + else -> reader.skipValue() + } + } + reader.endObject() + } + else -> reader.skipValue() + } + } + "text" -> fragmentText = reader.nextString() + else -> reader.skipValue() + } + } + if (fragmentText != null && !emoteId.isNullOrBlank()) { + emotesList.add(TwitchEmote( + id = emoteId, + begin = message.codePointCount(0, message.length), + end = message.codePointCount(0, message.length) + fragmentText.lastIndex + )) + } + message.append(fragmentText) + reader.endObject() + } + reader.endArray() + } + "userBadges" -> { + reader.beginArray() + while (reader.hasNext()) { + reader.beginObject() + var set: String? = null + var version: String? = null + while (reader.hasNext()) { + when (reader.nextName()) { + "setID" -> set = reader.nextString() + "version" -> version = reader.nextString() + else -> reader.skipValue() + } + } + if (!set.isNullOrBlank() && !version.isNullOrBlank()) { + badgesList.add( + Badge(set, version) + ) + } + reader.endObject() + } + reader.endArray() + } + "userColor" -> { + when (reader.peek()) { + JsonToken.STRING -> color = reader.nextString() + else -> reader.skipValue() + } + } + else -> reader.skipValue() + } + } + chatMessage = VideoChatMessage( + id = id, + offsetSeconds = offsetSeconds, + userId = userId, + userLogin = userLogin, + userName = userName, + message = message.toString(), + color = color, + emotes = emotesList, + badges = badgesList, + fullMsg = null + ) + reader.endObject() + } + else -> reader.skipValue() } } + reader.endObject() + return chatMessage } private fun createForegroundInfo(): ForegroundInfo { diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/VideoDownloadDialog.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/VideoDownloadDialog.kt index 644876d9b..4f3fe92ee 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/VideoDownloadDialog.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/VideoDownloadDialog.kt @@ -163,13 +163,12 @@ class VideoDownloadDialog : BaseDownloadDialog() { } from < to -> { val quality = spinner.editText?.text.toString() - val url = videoInfo.qualities.getValue(quality) val location = resources.getStringArray(R.array.spinnerStorage).indexOf(storageSelectionContainer.storageSpinner.editText?.text.toString()) val path = if (location == 0) sharedPath else downloadPath val downloadChat = downloadChat.isChecked val downloadChatEmotes = downloadChatEmotes.isChecked if (!path.isNullOrBlank()) { - viewModel.download(url, path, quality, from, to, downloadChat, downloadChatEmotes, requireContext().prefs().getBoolean(C.DOWNLOAD_PLAYLIST_TO_FILE, false), Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE || requireContext().prefs().getBoolean(C.DEBUG_WORKMANAGER_DOWNLOADS, false)) + viewModel.download(videoInfo.qualities.getValue(quality), path, quality, from, to, downloadChat, downloadChatEmotes, requireContext().prefs().getBoolean(C.DOWNLOAD_PLAYLIST_TO_FILE, false), requireContext().prefs().getBoolean(C.DOWNLOAD_WIFI_ONLY, false)) requireContext().prefs().edit { putInt(C.DOWNLOAD_LOCATION, location) if (location == 0) { diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/VideoDownloadViewModel.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/VideoDownloadViewModel.kt index 0214bc90e..55d59b311 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/VideoDownloadViewModel.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/VideoDownloadViewModel.kt @@ -1,21 +1,20 @@ package com.github.andreyasadchy.xtra.ui.download -import android.content.ContentResolver import android.content.Context import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.work.Constraints import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.workDataOf import com.github.andreyasadchy.xtra.R import com.github.andreyasadchy.xtra.model.VideoDownloadInfo -import com.github.andreyasadchy.xtra.model.offline.Request +import com.github.andreyasadchy.xtra.model.offline.OfflineVideo import com.github.andreyasadchy.xtra.model.ui.Video import com.github.andreyasadchy.xtra.repository.OfflineRepository import com.github.andreyasadchy.xtra.repository.PlayerRepository @@ -23,20 +22,10 @@ import com.github.andreyasadchy.xtra.util.DownloadUtils import com.github.andreyasadchy.xtra.util.SingleLiveEvent import com.github.andreyasadchy.xtra.util.TwitchApiHelper import com.github.andreyasadchy.xtra.util.toast -import com.iheartradio.m3u8.Encoding -import com.iheartradio.m3u8.Format -import com.iheartradio.m3u8.ParsingMode -import com.iheartradio.m3u8.PlaylistParser -import com.iheartradio.m3u8.PlaylistWriter -import com.iheartradio.m3u8.data.Playlist -import com.iheartradio.m3u8.data.TrackData import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import java.io.ByteArrayInputStream -import java.io.File -import java.io.FileOutputStream import javax.inject.Inject @HiltViewModel @@ -99,169 +88,52 @@ class VideoDownloadViewModel @Inject constructor( } } - fun download(url: String, path: String, quality: String, from: Long, to: Long, downloadChat: Boolean, downloadChatEmotes: Boolean, playlistToFile: Boolean, useWorkManager: Boolean) { + fun download(url: String, path: String, quality: String, from: Long, to: Long, downloadChat: Boolean, downloadChatEmotes: Boolean, playlistToFile: Boolean, wifiOnly: Boolean) { GlobalScope.launch { - val video = _videoInfo.value!!.video - if (playlistToFile) { - val offlineVideo = DownloadUtils.prepareDownload( - context = applicationContext, - downloadable = video, - url = url, - path = "", + with(_videoInfo.value!!.video) { + val downloadedThumbnail = id.takeIf { !it.isNullOrBlank() }?.let { + DownloadUtils.savePng(applicationContext, thumbnail, "thumbnails", it) + } + val downloadedLogo = channelId.takeIf { !it.isNullOrBlank() }?.let { + DownloadUtils.savePng(applicationContext, channelLogo, "profile_pics", it) + } + val videoId = offlineRepository.saveVideo(OfflineVideo( + sourceUrl = url, + name = title, + channelId = channelId, + channelLogin = channelLogin, + channelName = channelName, + channelLogo = downloadedLogo, + thumbnail = downloadedThumbnail, + gameId = gameId, + gameSlug = gameSlug, + gameName = gameName, + uploadDate = uploadDate?.let { TwitchApiHelper.parseIso8601DateUTC(it) }, downloadDate = System.currentTimeMillis(), downloadPath = path, fromTime = from, toTime = to, - quality = quality, + status = OfflineVideo.STATUS_BLOCKED, + type = type, + videoId = id, + quality = if (!quality.contains("Audio", true)) quality else "audio", downloadChat = downloadChat, - downloadChatEmotes = downloadChatEmotes) - val videoId = offlineRepository.saveVideo(offlineVideo).toInt() + downloadChatEmotes = downloadChatEmotes, + playlistToFile = playlistToFile + )).toInt() WorkManager.getInstance(applicationContext).enqueueUniqueWork( - videoId.toString(), - ExistingWorkPolicy.KEEP, + "download", + ExistingWorkPolicy.APPEND_OR_REPLACE, OneTimeWorkRequestBuilder() .setInputData(workDataOf(DownloadWorker.KEY_VIDEO_ID to videoId)) - .build() - ) - } else { - val playlist = ByteArrayInputStream(playerRepository.getResponse(url = url).toByteArray()).use { - PlaylistParser(it, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse().mediaPlaylist - } - val targetDuration = playlist.targetDuration * 1000L - var totalDuration = 0L - val size = playlist.tracks.size - val relativeStartTimes = ArrayList(size) - val durations = ArrayList(size) - var relativeTime = 0L - playlist.tracks.forEach { - val duration = (it.trackInfo.duration * 1000f).toLong() - durations.add(duration) - totalDuration += duration - relativeStartTimes.add(relativeTime) - relativeTime += duration - } - val fromIndex = if (from == 0L) { - 0 - } else { - val min = from - targetDuration - relativeStartTimes.binarySearch(comparison = { time -> - when { - time > from -> 1 - time < min -> -1 - else -> 0 - } - }).let { if (it < 0) -it else it } - } - val toIndex = if (to in relativeStartTimes.last()..totalDuration) { - relativeStartTimes.lastIndex - } else { - val max = to + targetDuration - relativeStartTimes.binarySearch(comparison = { time -> - when { - time > max -> 1 - time < to -> -1 - else -> 0 - } - }).let { if (it < 0) -it else it } - } - val startPosition = relativeStartTimes[fromIndex] - val duration = (relativeStartTimes[toIndex] + durations[toIndex] - startPosition) - 1000L - val urlPath = url.substringBeforeLast('/') + "/" - val videoDirectoryName = if (!video.id.isNullOrBlank()) { - "${video.id}${if (!quality.contains("Audio", true)) quality else "audio"}" - } else { - "${System.currentTimeMillis()}" - } - val downloadDate = System.currentTimeMillis() - if (path.toUri().scheme == ContentResolver.SCHEME_CONTENT) { - val directory = DocumentFile.fromTreeUri(applicationContext, path.toUri()) - val videoDirectory = directory?.findFile(videoDirectoryName) ?: directory?.createDirectory(videoDirectoryName) - val tracks = ArrayList() - for (i in fromIndex..toIndex) { - val track = playlist.tracks[i] - tracks.add( - track.buildUpon() - .withUri(videoDirectory?.uri.toString() + "%2F" + track.uri.replace("-unmuted", "-muted")) - .build() - ) - } - val playlistFile = videoDirectory?.createFile("", "${downloadDate}.m3u8")!! - applicationContext.contentResolver.openOutputStream(playlistFile.uri).use { - PlaylistWriter(it, Format.EXT_M3U, Encoding.UTF_8).write(Playlist.Builder().withMediaPlaylist(playlist.buildUpon().withTracks(tracks).build()).build()) - } - val offlineVideo = DownloadUtils.prepareDownload( - context = applicationContext, - downloadable = video, - url = urlPath, - path = playlistFile.uri.toString(), - duration = duration, - downloadDate = downloadDate, - startPosition = startPosition, - segmentFrom = fromIndex, - segmentTo = toIndex, - quality = quality, - downloadChat = downloadChat, - downloadChatEmotes = downloadChatEmotes) - val videoId = offlineRepository.saveVideo(offlineVideo).toInt() - if (useWorkManager || downloadChat) { - WorkManager.getInstance(applicationContext).enqueueUniqueWork( - videoId.toString(), - ExistingWorkPolicy.KEEP, - OneTimeWorkRequestBuilder() - .setInputData(workDataOf(DownloadWorker.KEY_VIDEO_ID to videoId)) + .addTag(videoId.toString()) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) .build() ) - } else { - val request = Request(videoId, urlPath, path) - offlineRepository.saveRequest(request) - - DownloadUtils.download(applicationContext, request) - } - } else { - val tracks = ArrayList() - for (i in fromIndex..toIndex) { - val track = playlist.tracks[i] - tracks.add( - track.buildUpon() - .withUri(track.uri.replace("-unmuted", "-muted")) - .build() - ) - } - val directory = "$path${File.separator}$videoDirectoryName${File.separator}" - File(directory).mkdir() - val playlistUri = "$directory${downloadDate}.m3u8" - FileOutputStream(playlistUri).use { - PlaylistWriter(it, Format.EXT_M3U, Encoding.UTF_8).write(Playlist.Builder().withMediaPlaylist(playlist.buildUpon().withTracks(tracks).build()).build()) - } - val offlineVideo = DownloadUtils.prepareDownload( - context = applicationContext, - downloadable = video, - url = urlPath, - path = playlistUri, - duration = duration, - downloadDate = downloadDate, - startPosition = startPosition, - segmentFrom = fromIndex, - segmentTo = toIndex, - quality = quality, - downloadChat = downloadChat, - downloadChatEmotes = downloadChatEmotes) - val videoId = offlineRepository.saveVideo(offlineVideo).toInt() - if (useWorkManager || downloadChat) { - WorkManager.getInstance(applicationContext).enqueueUniqueWork( - videoId.toString(), - ExistingWorkPolicy.KEEP, - OneTimeWorkRequestBuilder() - .setInputData(workDataOf(DownloadWorker.KEY_VIDEO_ID to videoId)) - .build() - ) - } else { - val request = Request(videoId, urlPath, directory) - offlineRepository.saveRequest(request) - - DownloadUtils.download(applicationContext, request) - } - } + .build() + ) } } } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/main/MainViewModel.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/main/MainViewModel.kt index 4d2add57b..dd6a2f595 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/main/MainViewModel.kt @@ -1,7 +1,6 @@ package com.github.andreyasadchy.xtra.ui.main import android.app.Activity -import android.content.Context import android.content.Intent import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -16,7 +15,6 @@ import com.github.andreyasadchy.xtra.model.ui.User import com.github.andreyasadchy.xtra.model.ui.Video import com.github.andreyasadchy.xtra.repository.ApiRepository import com.github.andreyasadchy.xtra.repository.AuthRepository -import com.github.andreyasadchy.xtra.repository.OfflineRepository import com.github.andreyasadchy.xtra.ui.login.LoginActivity import com.github.andreyasadchy.xtra.util.C import com.github.andreyasadchy.xtra.util.Event @@ -25,17 +23,14 @@ import com.github.andreyasadchy.xtra.util.TwitchApiHelper import com.github.andreyasadchy.xtra.util.nullIfEmpty import com.github.andreyasadchy.xtra.util.toast import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.launch import retrofit2.HttpException import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( - @ApplicationContext applicationContext: Context, private val repository: ApiRepository, - private val authRepository: AuthRepository, - private val offlineRepository: OfflineRepository) : ViewModel() { + private val authRepository: AuthRepository) : ViewModel() { private val _integrity by lazy { SingleLiveEvent() } val integrity: LiveData @@ -61,12 +56,6 @@ class MainViewModel @Inject constructor( val user: MutableLiveData get() = _user - init { - viewModelScope.launch { - offlineRepository.resumeDownloads(applicationContext) - } - } - fun onMaximize() { isPlayerMaximized = true } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/PlaybackService.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/PlaybackService.kt index c3a7acac8..89757f492 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/PlaybackService.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/PlaybackService.kt @@ -484,27 +484,29 @@ class PlaybackService : MediaSessionService() { @Suppress("DEPRECATION") customCommand.customExtras.getParcelable(ITEM) }?.let { item -> - Companion.item = item - urls = mapOf( - ContextCompat.getString(this@PlaybackService, R.string.source) to item.url, - ContextCompat.getString(this@PlaybackService, R.string.audio_only) to "" - ) - qualities = LinkedList(urls.keys) - session.player.setMediaItem(MediaItem.Builder() - .setUri(item.url) - .setMediaMetadata(MediaMetadata.Builder() - .setTitle(item.name) - .setArtist(item.channelName) - .setArtworkUri(item.channelLogo?.toUri()) + item.url?.let { url -> + Companion.item = item + urls = mapOf( + ContextCompat.getString(this@PlaybackService, R.string.source) to url, + ContextCompat.getString(this@PlaybackService, R.string.audio_only) to "" + ) + qualities = LinkedList(urls.keys) + session.player.setMediaItem(MediaItem.Builder() + .setUri(url) + .setMediaMetadata(MediaMetadata.Builder() + .setTitle(item.name) + .setArtist(item.channelName) + .setArtworkUri(item.channelLogo?.toUri()) + .build()) .build()) - .build()) - session.player.volume = prefs.getInt(C.PLAYER_VOLUME, 100) / 100f - session.player.setPlaybackSpeed(prefs().getFloat(C.PLAYER_SPEED, 1f)) - session.player.prepare() - session.player.playWhenReady = true - session.player.seekTo(if (prefs.getBoolean(C.PLAYER_USE_VIDEOPOSITIONS, true)) { - savedPosition?.let { if (it.id == item.id.toLong()) it.position else null } ?: item.lastWatchPosition ?: 0 - } else 0) + session.player.volume = prefs.getInt(C.PLAYER_VOLUME, 100) / 100f + session.player.setPlaybackSpeed(prefs().getFloat(C.PLAYER_SPEED, 1f)) + session.player.prepare() + session.player.playWhenReady = true + session.player.seekTo(if (prefs.getBoolean(C.PLAYER_USE_VIDEOPOSITIONS, true)) { + savedPosition?.let { if (it.id == item.id.toLong()) it.position else null } ?: item.lastWatchPosition ?: 0 + } else 0) + } } Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/saved/downloads/DownloadsAdapter.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/saved/downloads/DownloadsAdapter.kt index 88694851a..3d85e3457 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/saved/downloads/DownloadsAdapter.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/saved/downloads/DownloadsAdapter.kt @@ -1,8 +1,6 @@ package com.github.andreyasadchy.xtra.ui.saved.downloads import android.content.ContentResolver -import android.graphics.Color -import android.os.Build import android.text.format.DateUtils import android.view.LayoutInflater import android.view.View @@ -31,9 +29,11 @@ import com.github.andreyasadchy.xtra.util.gone import com.github.andreyasadchy.xtra.util.loadImage import com.github.andreyasadchy.xtra.util.prefs import com.github.andreyasadchy.xtra.util.visible +import kotlin.math.min class DownloadsAdapter( private val fragment: Fragment, + private val checkDownloadStatus: (Int) -> Unit, private val stopDownload: (Int) -> Unit, private val resumeDownload: (Int) -> Unit, private val convertVideo: (OfflineVideo) -> Unit, @@ -188,23 +188,24 @@ class DownloadsAdapter( options.setOnClickListener { it -> PopupMenu(context, it).apply { inflate(R.menu.offline_item) - if (item.status == OfflineVideo.STATUS_DOWNLOADED || item.status == OfflineVideo.STATUS_MOVING || item.status == OfflineVideo.STATUS_DELETING || item.status == OfflineVideo.STATUS_CONVERTING) { - menu.findItem(R.id.moveVideo).apply { - isVisible = true - title = context.getString(if (item.url.toUri().scheme == ContentResolver.SCHEME_CONTENT) { - R.string.move_to_app_storage - } else { - R.string.move_to_shared_storage - }) - } - if (item.vod) { - menu.findItem(R.id.convertVideo).isVisible = true - } - menu.findItem(R.id.updateChatUrl).isVisible = true - } else { - if (item.downloadChat == true || item.sourceUrl?.endsWith(".m3u8") == true || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE || context.prefs().getBoolean(C.DEBUG_WORKMANAGER_DOWNLOADS, false))) { + when (item.status) { + OfflineVideo.STATUS_DOWNLOADING, OfflineVideo.STATUS_BLOCKED, OfflineVideo.STATUS_QUEUED, OfflineVideo.STATUS_QUEUED_WIFI -> { menu.findItem(R.id.stopDownload).isVisible = true - menu.findItem(R.id.resumeDownload).isVisible = true + } + OfflineVideo.STATUS_PENDING -> menu.findItem(R.id.resumeDownload).isVisible = true + else -> { + menu.findItem(R.id.moveVideo).apply { + isVisible = true + title = context.getString(if (item.url?.toUri()?.scheme == ContentResolver.SCHEME_CONTENT) { + R.string.move_to_app_storage + } else { + R.string.move_to_shared_storage + }) + } + if (item.url?.endsWith(".m3u8") == true) { + menu.findItem(R.id.convertVideo).isVisible = true + } + menu.findItem(R.id.updateChatUrl).isVisible = true } } setOnMenuItemClickListener { @@ -230,24 +231,20 @@ class DownloadsAdapter( OfflineVideo.STATUS_MOVING -> context.getString(R.string.download_moving) OfflineVideo.STATUS_DELETING -> context.getString(R.string.download_deleting) OfflineVideo.STATUS_CONVERTING -> context.getString(R.string.download_converting) + OfflineVideo.STATUS_BLOCKED -> context.getString(R.string.download_queued) + OfflineVideo.STATUS_QUEUED -> context.getString(R.string.download_blocked) + OfflineVideo.STATUS_QUEUED_WIFI -> context.getString(R.string.download_blocked_wifi) else -> context.getString(R.string.download_pending) } - if (item.downloadChat == true && item.status == OfflineVideo.STATUS_DOWNLOADING) { + if (item.downloadChat && item.status == OfflineVideo.STATUS_DOWNLOADING) { chatDownloadProgress.visible() - chatDownloadProgress.text = context.getString(R.string.chat_downloading_progress, item.chatProgress ?: 0) + chatDownloadProgress.text = context.getString(R.string.chat_downloading_progress, min(((item.chatProgress.toFloat() / item.maxChatProgress) * 100f).toInt(), 100)) } else { chatDownloadProgress.gone() } status.visible() - if (item.vod || item.sourceUrl?.endsWith(".m3u8") == true) { - status.background = null - status.isClickable = false - status.isFocusable = false - downloadProgress.setShadowLayer(4f, 0f, 0f, Color.BLACK) - chatDownloadProgress.setShadowLayer(4f, 0f, 0f, Color.BLACK) - } else { - status.setOnClickListener { deleteVideo(item) } - status.setOnLongClickListener { deleteVideo(item); true } + if (item.status == OfflineVideo.STATUS_DOWNLOADING || item.status == OfflineVideo.STATUS_BLOCKED || item.status == OfflineVideo.STATUS_QUEUED || item.status == OfflineVideo.STATUS_QUEUED_WIFI) { + checkDownloadStatus(item.id) } } } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/saved/downloads/DownloadsFragment.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/saved/downloads/DownloadsFragment.kt index 7f055c74a..5562617eb 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/saved/downloads/DownloadsFragment.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/saved/downloads/DownloadsFragment.kt @@ -18,7 +18,9 @@ import androidx.fragment.app.viewModels import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.work.Constraints import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.workDataOf @@ -81,15 +83,28 @@ class DownloadsFragment : PagedListFragment(), Scrollable { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) pagingAdapter = DownloadsAdapter(this, { - WorkManager.getInstance(requireContext()).cancelUniqueWork(it.toString()) + viewModel.checkDownloadStatus(it) + }, { + WorkManager.getInstance(requireContext()).cancelAllWorkByTag(it.toString()) }, { WorkManager.getInstance(requireContext()).enqueueUniqueWork( - it.toString(), - ExistingWorkPolicy.KEEP, + "download", + ExistingWorkPolicy.APPEND_OR_REPLACE, OneTimeWorkRequestBuilder() .setInputData(workDataOf(DownloadWorker.KEY_VIDEO_ID to it)) + .addTag(it.toString()) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(if (requireContext().prefs().getBoolean(C.DOWNLOAD_WIFI_ONLY, false)) { + NetworkType.UNMETERED + } else { + NetworkType.CONNECTED + }) + .build() + ) .build() ) + viewModel.checkDownloadStatus(it) }, { val convert = getString(R.string.convert) requireActivity().getAlertDialogBuilder() @@ -99,7 +114,7 @@ class DownloadsFragment : PagedListFragment(), Scrollable { .setNegativeButton(getString(android.R.string.cancel), null) .show() }, { - if (it.url.toUri().scheme == ContentResolver.SCHEME_CONTENT) { + if (it.url?.toUri()?.scheme == ContentResolver.SCHEME_CONTENT) { val storage = DownloadUtils.getAvailableStorage(requireContext()) val binding = StorageSelectionBinding.inflate(layoutInflater).apply { storageSpinner.gone() diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/saved/downloads/DownloadsViewModel.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/saved/downloads/DownloadsViewModel.kt index 385ea86d0..fa99e0bf5 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/saved/downloads/DownloadsViewModel.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/saved/downloads/DownloadsViewModel.kt @@ -3,7 +3,6 @@ package com.github.andreyasadchy.xtra.ui.saved.downloads import android.content.ContentResolver import android.content.Context import android.net.Uri -import android.os.Build import android.util.JsonReader import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile @@ -12,12 +11,11 @@ import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import androidx.work.NetworkType +import androidx.work.WorkInfo import androidx.work.WorkManager import com.github.andreyasadchy.xtra.model.offline.OfflineVideo import com.github.andreyasadchy.xtra.repository.OfflineRepository -import com.github.andreyasadchy.xtra.util.C -import com.github.andreyasadchy.xtra.util.FetchProvider -import com.github.andreyasadchy.xtra.util.prefs import com.iheartradio.m3u8.Encoding import com.iheartradio.m3u8.Format import com.iheartradio.m3u8.ParsingMode @@ -28,6 +26,7 @@ import com.iheartradio.m3u8.data.TrackData import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import okio.appendingSink import okio.buffer @@ -44,11 +43,11 @@ import kotlin.math.max @HiltViewModel class DownloadsViewModel @Inject internal constructor( @ApplicationContext private val applicationContext: Context, - private val repository: OfflineRepository, - private val fetchProvider: FetchProvider) : ViewModel() { + private val repository: OfflineRepository) : ViewModel() { var selectedVideo: OfflineVideo? = null private val videosInUse = mutableListOf() + private val currentDownloads = mutableListOf() val flow = Pager( PagingConfig(pageSize = 30, prefetchDistance = 3, initialLoadSize = 30), @@ -56,18 +55,62 @@ class DownloadsViewModel @Inject internal constructor( repository.loadAllVideos() }.flow.cachedIn(viewModelScope) + fun checkDownloadStatus(videoId: Int) { + if (!currentDownloads.contains(videoId)) { + currentDownloads.add(videoId) + viewModelScope.launch(Dispatchers.IO) { + WorkManager.getInstance(applicationContext).getWorkInfosByTagFlow(videoId.toString()).collect { list -> + val work = list.lastOrNull() + when { + work == null || work.state.isFinished -> { + repository.getVideoById(videoId)?.let { video -> + if (video.status == OfflineVideo.STATUS_DOWNLOADING || video.status == OfflineVideo.STATUS_BLOCKED || video.status == OfflineVideo.STATUS_QUEUED || video.status == OfflineVideo.STATUS_QUEUED_WIFI) { + repository.updateVideo(video.apply { + status = OfflineVideo.STATUS_PENDING + }) + } + } + cancel() + } + work.state == WorkInfo.State.ENQUEUED -> { + repository.getVideoById(videoId)?.let { video -> + repository.updateVideo(video.apply { + status = if (work.constraints.requiredNetworkType == NetworkType.UNMETERED) { + OfflineVideo.STATUS_QUEUED_WIFI + } else { + OfflineVideo.STATUS_QUEUED + } + }) + } + } + work.state == WorkInfo.State.BLOCKED -> { + repository.getVideoById(videoId)?.let { video -> + repository.updateVideo(video.apply { + status = OfflineVideo.STATUS_BLOCKED + }) + } + } + } + } + }.invokeOnCompletion { + currentDownloads.remove(videoId) + } + } + } + fun convertToFile(video: OfflineVideo) { - if (!videosInUse.contains(video)) { + val videoUrl = video.url + if (!videosInUse.contains(video) && videoUrl != null) { videosInUse.add(video) viewModelScope.launch(Dispatchers.IO) { repository.updateVideo(video.apply { status = OfflineVideo.STATUS_CONVERTING }) - if (video.url.toUri().scheme == ContentResolver.SCHEME_CONTENT) { - val oldPlaylistFile = DocumentFile.fromSingleUri(applicationContext, video.url.toUri()) + if (videoUrl.toUri().scheme == ContentResolver.SCHEME_CONTENT) { + val oldPlaylistFile = DocumentFile.fromSingleUri(applicationContext, videoUrl.toUri()) if (oldPlaylistFile != null) { - val oldDirectory = DocumentFile.fromTreeUri(applicationContext, video.url.substringBefore("/document/").toUri()) - val oldVideoDirectory = oldDirectory?.findFile(video.url.substringAfter("/document/").substringBeforeLast("%2F").substringAfterLast("%2F").substringAfterLast("%3A")) + val oldDirectory = DocumentFile.fromTreeUri(applicationContext, videoUrl.substringBefore("/document/").toUri()) + val oldVideoDirectory = oldDirectory?.findFile(videoUrl.substringAfter("/document/").substringBeforeLast("%2F").substringAfterLast("%2F").substringAfterLast("%3A")) if (oldVideoDirectory != null) { val oldPlaylist = applicationContext.contentResolver.openInputStream(oldPlaylistFile.uri).use { PlaylistParser(it, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse().mediaPlaylist @@ -102,7 +145,6 @@ class DownloadsViewModel @Inject internal constructor( } } url = newVideoFile.uri.toString() - vod = false }) if (playlists.isNotEmpty()) { oldPlaylistFile.delete() @@ -113,7 +155,7 @@ class DownloadsViewModel @Inject internal constructor( } } } else { - val oldPlaylistFile = File(video.url) + val oldPlaylistFile = File(videoUrl) if (oldPlaylistFile.exists()) { val oldVideoDirectory = oldPlaylistFile.parentFile val oldDirectory = oldVideoDirectory?.parentFile @@ -148,7 +190,6 @@ class DownloadsViewModel @Inject internal constructor( } } url = newVideoFileUri - vod = false }) if (playlists?.isNotEmpty() == true) { oldPlaylistFile.delete() @@ -170,14 +211,15 @@ class DownloadsViewModel @Inject internal constructor( } fun moveToSharedStorage(newUri: Uri, video: OfflineVideo) { - if (!videosInUse.contains(video)) { + val videoUrl = video.url + if (!videosInUse.contains(video) && videoUrl != null) { videosInUse.add(video) viewModelScope.launch(Dispatchers.IO) { repository.updateVideo(video.apply { status = OfflineVideo.STATUS_MOVING }) - if (video.vod) { - val oldPlaylistFile = File(video.url) + if (videoUrl.endsWith(".m3u8")) { + val oldPlaylistFile = File(videoUrl) if (oldPlaylistFile.exists()) { val oldVideoDirectory = oldPlaylistFile.parentFile if (oldVideoDirectory != null) { @@ -250,7 +292,7 @@ class DownloadsViewModel @Inject internal constructor( } } } else { - val oldFile = File(video.url) + val oldFile = File(videoUrl) if (oldFile.exists()) { val newDirectory = DocumentFile.fromTreeUri(applicationContext, newUri) val newFile = newDirectory?.findFile(oldFile.name) ?: newDirectory?.createFile("", oldFile.name) @@ -291,17 +333,18 @@ class DownloadsViewModel @Inject internal constructor( } fun moveToAppStorage(path: String, video: OfflineVideo) { - if (!videosInUse.contains(video)) { + val videoUrl = video.url + if (!videosInUse.contains(video) && videoUrl != null) { videosInUse.add(video) viewModelScope.launch(Dispatchers.IO) { repository.updateVideo(video.apply { status = OfflineVideo.STATUS_MOVING }) - if (video.vod) { - val oldPlaylistFile = DocumentFile.fromSingleUri(applicationContext, video.url.toUri()) + if (videoUrl.endsWith(".m3u8")) { + val oldPlaylistFile = DocumentFile.fromSingleUri(applicationContext, videoUrl.toUri()) if (oldPlaylistFile != null) { - val oldDirectory = DocumentFile.fromTreeUri(applicationContext, video.url.substringBefore("/document/").toUri()) - val oldVideoDirectory = oldDirectory?.findFile(video.url.substringAfter("/document/").substringBeforeLast("%2F").substringAfterLast("%2F").substringAfterLast("%3A")) + val oldDirectory = DocumentFile.fromTreeUri(applicationContext, videoUrl.substringBefore("/document/").toUri()) + val oldVideoDirectory = oldDirectory?.findFile(videoUrl.substringAfter("/document/").substringBeforeLast("%2F").substringAfterLast("%2F").substringAfterLast("%3A")) if (oldVideoDirectory != null) { val newVideoDirectoryUri = "$path${File.separator}${oldVideoDirectory.name}${File.separator}" File(newVideoDirectoryUri).mkdir() @@ -366,7 +409,7 @@ class DownloadsViewModel @Inject internal constructor( } } } else { - val oldFile = DocumentFile.fromSingleUri(applicationContext, video.url.toUri()) + val oldFile = DocumentFile.fromSingleUri(applicationContext, videoUrl.toUri()) if (oldFile != null) { val newFileUri = "$path${File.separator}${oldFile.name}" File(newFileUri).sink().buffer().use { sink -> @@ -465,76 +508,70 @@ class DownloadsViewModel @Inject internal constructor( } fun delete(video: OfflineVideo, keepFiles: Boolean) { - if (!videosInUse.contains(video)) { + val videoUrl = video.url + if (!videosInUse.contains(video) && videoUrl != null) { videosInUse.add(video) viewModelScope.launch(Dispatchers.IO) { repository.updateVideo(video.apply { status = OfflineVideo.STATUS_DELETING }) - val useWorkManager = video.downloadChat == true || video.sourceUrl?.endsWith(".m3u8") == true || Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE || applicationContext.prefs().getBoolean(C.DEBUG_WORKMANAGER_DOWNLOADS, false) - if (video.status == OfflineVideo.STATUS_DOWNLOADED || video.status == OfflineVideo.STATUS_MOVING || video.status == OfflineVideo.STATUS_DELETING || video.status == OfflineVideo.STATUS_CONVERTING || useWorkManager) { - if (useWorkManager) { - WorkManager.getInstance(applicationContext).cancelUniqueWork(video.id.toString()) - } - if (!keepFiles) { - if (video.url.toUri().scheme == ContentResolver.SCHEME_CONTENT) { - if (video.vod) { - val directory = DocumentFile.fromTreeUri(applicationContext, video.url.substringBefore("/document/").toUri()) ?: return@launch - val videoDirectory = directory.findFile(video.url.substringBeforeLast("%2F").substringAfterLast("%2F").substringAfterLast("%3A")) ?: return@launch - val playlistFile = videoDirectory.findFile(video.url.substringAfterLast("%2F")) ?: return@launch - val playlists = videoDirectory.listFiles().filter { it.name?.endsWith(".m3u8") == true && it.uri != playlistFile.uri } - if (playlists.isEmpty()) { - videoDirectory.delete() - } else { - val playlist = applicationContext.contentResolver.openInputStream(video.url.toUri()).use { + WorkManager.getInstance(applicationContext).cancelAllWorkByTag(video.id.toString()) + if (!keepFiles) { + if (videoUrl.toUri().scheme == ContentResolver.SCHEME_CONTENT) { + if (videoUrl.endsWith(".m3u8")) { + val directory = DocumentFile.fromTreeUri(applicationContext, videoUrl.substringBefore("/document/").toUri()) ?: return@launch + val videoDirectory = directory.findFile(videoUrl.substringBeforeLast("%2F").substringAfterLast("%2F").substringAfterLast("%3A")) ?: return@launch + val playlistFile = videoDirectory.findFile(videoUrl.substringAfterLast("%2F")) ?: return@launch + val playlists = videoDirectory.listFiles().filter { it.name?.endsWith(".m3u8") == true && it.uri != playlistFile.uri } + if (playlists.isEmpty()) { + videoDirectory.delete() + } else { + val playlist = applicationContext.contentResolver.openInputStream(videoUrl.toUri()).use { + PlaylistParser(it, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse() + } + val tracksToDelete = playlist.mediaPlaylist.tracks.toMutableSet() + playlists.forEach { file -> + val p = applicationContext.contentResolver.openInputStream(file.uri).use { PlaylistParser(it, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse() } - val tracksToDelete = playlist.mediaPlaylist.tracks.toMutableSet() - playlists.forEach { file -> - val p = applicationContext.contentResolver.openInputStream(file.uri).use { - PlaylistParser(it, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse() - } - tracksToDelete.removeAll(p.mediaPlaylist.tracks.toSet()) - } - tracksToDelete.forEach { videoDirectory.findFile(it.uri.substringAfterLast("%2F"))?.delete() } - playlistFile.delete() + tracksToDelete.removeAll(p.mediaPlaylist.tracks.toSet()) } - } else { - DocumentFile.fromSingleUri(applicationContext, video.url.toUri())?.delete() + tracksToDelete.forEach { videoDirectory.findFile(it.uri.substringAfterLast("%2F"))?.delete() } + playlistFile.delete() } - video.chatUrl?.let { DocumentFile.fromSingleUri(applicationContext, it.toUri())?.delete() } } else { - val playlistFile = File(video.url) - if (!playlistFile.exists()) { - return@launch - } - if (video.vod) { - val directory = playlistFile.parentFile - if (directory != null) { - val playlists = directory.listFiles(FileFilter { it.extension == "m3u8" && it != playlistFile }) - if (playlists != null) { - if (playlists.isEmpty()) { - directory.deleteRecursively() - } else { - val playlist = PlaylistParser(playlistFile.inputStream(), Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse() - val tracksToDelete = playlist.mediaPlaylist.tracks.toMutableSet() - playlists.forEach { - val p = PlaylistParser(it.inputStream(), Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse() - tracksToDelete.removeAll(p.mediaPlaylist.tracks.toSet()) - } - tracksToDelete.forEach { File(it.uri).delete() } - playlistFile.delete() + DocumentFile.fromSingleUri(applicationContext, videoUrl.toUri())?.delete() + } + video.chatUrl?.let { DocumentFile.fromSingleUri(applicationContext, it.toUri())?.delete() } + } else { + val playlistFile = File(videoUrl) + if (!playlistFile.exists()) { + return@launch + } + if (videoUrl.endsWith(".m3u8")) { + val directory = playlistFile.parentFile + if (directory != null) { + val playlists = directory.listFiles(FileFilter { it.extension == "m3u8" && it != playlistFile }) + if (playlists != null) { + if (playlists.isEmpty()) { + directory.deleteRecursively() + } else { + val playlist = PlaylistParser(playlistFile.inputStream(), Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse() + val tracksToDelete = playlist.mediaPlaylist.tracks.toMutableSet() + playlists.forEach { + val p = PlaylistParser(it.inputStream(), Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse() + tracksToDelete.removeAll(p.mediaPlaylist.tracks.toSet()) } + tracksToDelete.forEach { File(it.uri).delete() } + playlistFile.delete() } } - } else { - playlistFile.delete() } - video.chatUrl?.let { File(it).delete() } + } else { + playlistFile.delete() } + video.chatUrl?.let { File(it).delete() } } - } else { - fetchProvider.get(video.id).deleteGroup(video.id) } }.invokeOnCompletion { videosInUse.remove(video) diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/settings/SettingsActivity.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/settings/SettingsActivity.kt index 97467c3e8..9a5e838a6 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/settings/SettingsActivity.kt @@ -274,10 +274,6 @@ class SettingsActivity : AppCompatActivity() { IntegrityDialog.show(childFragmentManager) true } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - findPreference(C.DEBUG_WORKMANAGER_DOWNLOADS)?.isVisible = false - } } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/util/C.kt b/app/src/main/java/com/github/andreyasadchy/xtra/util/C.kt index 48481b82a..0fa786325 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/util/C.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/util/C.kt @@ -21,6 +21,7 @@ object C { const val USERNAME = "username" const val USER_ID = "user_id" const val DOWNLOAD_PLAYLIST_TO_FILE = "download_playlist_to_file" + const val DOWNLOAD_WIFI_ONLY = "download_wifi_only" const val DOWNLOAD_CONCURRENT_LIMIT = "download_concurrent_limit" const val DOWNLOAD_STORAGE = "downloadStorage" const val DOWNLOAD_SHARED_PATH = "download_shared_path" @@ -222,5 +223,4 @@ object C { const val ENABLE_INTEGRITY = "enable_integrity" const val USE_WEBVIEW_INTEGRITY = "use_webview_integrity" const val GET_ALL_GQL_HEADERS = "get_all_gql_headers" - const val DEBUG_WORKMANAGER_DOWNLOADS = "debug_workmanager_downloads" } \ No newline at end of file diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/util/DownloadUtils.kt b/app/src/main/java/com/github/andreyasadchy/xtra/util/DownloadUtils.kt index 963d48ccc..48830ba2f 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/util/DownloadUtils.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/util/DownloadUtils.kt @@ -3,7 +3,6 @@ package com.github.andreyasadchy.xtra.util import android.Manifest import android.app.Activity import android.content.Context -import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.drawable.Drawable @@ -16,12 +15,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.github.andreyasadchy.xtra.R -import com.github.andreyasadchy.xtra.model.offline.Downloadable -import com.github.andreyasadchy.xtra.model.offline.OfflineVideo -import com.github.andreyasadchy.xtra.model.offline.Request import com.github.andreyasadchy.xtra.ui.download.BaseDownloadDialog.Storage -import com.github.andreyasadchy.xtra.ui.download.DownloadService -import com.github.andreyasadchy.xtra.ui.download.DownloadService.Companion.KEY_REQUEST import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -32,51 +26,6 @@ object DownloadUtils { val isExternalStorageAvailable: Boolean get() = Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED - fun download(context: Context, request: Request) { - val intent = Intent(context, DownloadService::class.java) - .putExtra(KEY_REQUEST, request) - context.startService(intent) - DownloadService.activeRequests.add(request.offlineVideoId) - } - - suspend fun prepareDownload(context: Context, downloadable: Downloadable, url: String, path: String, duration: Long? = null, downloadDate: Long? = null, startPosition: Long? = null, segmentFrom: Int? = null, segmentTo: Int? = null, downloadPath: String? = null, fromTime: Long? = null, toTime: Long? = null, quality: String? = null, downloadChat: Boolean = false, downloadChatEmotes: Boolean = false): OfflineVideo { - return with(downloadable) { - val downloadedThumbnail = id.takeIf { !it.isNullOrBlank() }?.let { - savePng(context, thumbnail, "thumbnails", it) - } - val downloadedLogo = channelId.takeIf { !it.isNullOrBlank() }?.let { - savePng(context, channelLogo, "profile_pics", it) - } - OfflineVideo( - url = path, - sourceUrl = url, - sourceStartPosition = startPosition, - name = title, - channelId = channelId, - channelLogin = channelLogin, - channelName = channelName, - channelLogo = downloadedLogo, - thumbnail = downloadedThumbnail, - gameId = gameId, - gameSlug = gameSlug, - gameName = gameName, - duration = duration, - uploadDate = uploadDate?.let { TwitchApiHelper.parseIso8601DateUTC(it) }, - downloadDate = downloadDate, - progress = 0, - maxProgress = if (segmentTo != null && segmentFrom != null) segmentTo - segmentFrom + 1 else 100, - downloadPath = downloadPath, - fromTime = fromTime, - toTime = toTime, - type = type, - videoId = id, - quality = if (quality?.contains("Audio", true) != true) quality else "audio", - downloadChat = downloadChat, - downloadChatEmotes = downloadChatEmotes - ) - } - } - fun requestNotificationPermission(activity: Activity) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ActivityCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED && diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/util/FetchProvider.kt b/app/src/main/java/com/github/andreyasadchy/xtra/util/FetchProvider.kt deleted file mode 100644 index 3f56974d9..000000000 --- a/app/src/main/java/com/github/andreyasadchy/xtra/util/FetchProvider.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.github.andreyasadchy.xtra.util - -import com.tonyodev.fetch2.Fetch -import com.tonyodev.fetch2.FetchConfiguration -import javax.inject.Inject - -class FetchProvider @Inject constructor( - private val configurationBuilder: FetchConfiguration.Builder) { - - private var instance: Fetch? = null - - fun get(videoId: Int? = null, concurrentDownloads: Int = 10): Fetch { - if (instance == null || instance!!.isClosed) { - instance = Fetch.getInstance( - configurationBuilder - .setDownloadConcurrentLimit(concurrentDownloads) - .setNamespace("Fetch #$videoId") - .build()) - } - return instance!! - } -} \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_clip_download.xml b/app/src/main/res/layout/dialog_clip_download.xml index b0734bc17..561b00716 100644 --- a/app/src/main/res/layout/dialog_clip_download.xml +++ b/app/src/main/res/layout/dialog_clip_download.xml @@ -62,6 +62,32 @@ app:layout_constraintRight_toRightOf="parent" tools:visibility="visible"/> + + + +