From c0642f178b74e506e4338ce4c06b0a4a6b3f5caf Mon Sep 17 00:00:00 2001 From: SkyD666 Date: Fri, 29 Nov 2024 13:52:39 +0800 Subject: [PATCH] [feature|doc] Support downloading common files; supports renaming media library files; update README and screenshots --- README.md | 9 +- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 7 +- .../model/bean/download/DownloadInfoBean.kt | 125 +----- .../bean/download/bt/BtDownloadInfoBean.kt | 108 ++++++ .../{ => bt}/DownloadLinkUuidMapBean.kt | 2 +- .../bean/download/{ => bt}/PeerInfoBean.kt | 2 +- .../download/{ => bt}/SessionParamsBean.kt | 8 +- .../bean/download/{ => bt}/TorrentFileBean.kt | 6 +- .../com/skyd/anivu/model/db/AppDatabase.kt | 10 +- .../anivu/model/db/dao/DownloadInfoDao.kt | 98 ++--- .../anivu/model/db/dao/SessionParamsDao.kt | 4 +- .../skyd/anivu/model/db/dao/TorrentFileDao.kt | 4 +- .../anivu/model/db/migration/Migration1To2.kt | 4 +- .../anivu/model/db/migration/Migration2To3.kt | 10 +- .../anivu/model/db/migration/Migration8To9.kt | 18 +- .../anivu/model/repository/MediaRepository.kt | 10 +- .../repository/download/DownloadManager.kt | 358 ++++-------------- .../repository/download/DownloadRepository.kt | 24 +- .../repository/download/DownloadStarter.kt | 40 ++ .../download/bt/BtDownloadManager.kt | 308 +++++++++++++++ .../BtDownloadManagerIntent.kt} | 30 +- .../model/repository/feed/FeedRepository.kt | 2 +- .../worker/download/DownloadTorrentWorker.kt | 86 ++--- .../skyd/anivu/model/worker/download/Util.kt | 62 +-- .../skyd/anivu/ui/activity/MainActivity.kt | 5 +- .../skyd/anivu/ui/component/AniVuTextField.kt | 5 +- .../anivu/ui/component/ClipboardTextField.kt | 2 + .../ui/component/dialog/TextFieldDialog.kt | 4 + .../ui/screen/about/license/LicenseScreen.kt | 5 + .../anivu/ui/screen/article/Article1Item.kt | 2 + .../article/enclosure/EnclosureBottomSheet.kt | 28 +- .../ui/screen/download/BtDownloadItem.kt | 233 ++++++++++++ .../anivu/ui/screen/download/DownloadItem.kt | 141 +++---- .../download/DownloadPartialStateChange.kt | 12 +- .../ui/screen/download/DownloadScreen.kt | 128 +++++-- .../anivu/ui/screen/download/DownloadState.kt | 7 +- .../ui/screen/download/DownloadViewModel.kt | 12 +- .../ui/screen/media/list/EditMediaSheet.kt | 42 +- .../anivu/ui/screen/media/list/MediaList.kt | 11 +- .../ui/screen/media/list/MediaListIntent.kt | 1 + .../media/list/MediaListPartialStateChange.kt | 34 +- .../screen/media/list/MediaListViewModel.kt | 8 + app/src/main/res/values-zh-rCN/strings.xml | 11 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 11 +- build.gradle.kts | 1 + doc/image/en/ic_download_screen.jpg | Bin 53758 -> 64697 bytes doc/image/zh-rCN/ic_download_screen.jpg | Bin 60816 -> 60181 bytes doc/readme/README-zh-rCN.md | 9 +- downloader/.gitignore | 1 + downloader/build.gradle.kts | 54 +++ downloader/consumer-rules.pro | 0 downloader/proguard-rules.pro | 21 + .../downloader/ExampleInstrumentedTest.kt | 24 ++ downloader/src/main/AndroidManifest.xml | 21 + .../java/com/skyd/downloader/Downloader.kt | 194 ++++++++++ .../com/skyd/downloader/NotificationConfig.kt | 17 + .../main/java/com/skyd/downloader/Status.kt | 11 + .../java/com/skyd/downloader/UserAction.kt | 10 + .../skyd/downloader/db/DatabaseInstance.kt | 24 ++ .../com/skyd/downloader/db/DownloadDao.kt | 41 ++ .../skyd/downloader/db/DownloadDatabase.kt | 9 + .../com/skyd/downloader/db/DownloadEntity.kt | 29 ++ .../downloader/download/DownloadManager.kt | 213 +++++++++++ .../downloader/download/DownloadRequest.kt | 21 + .../skyd/downloader/download/DownloadTask.kt | 108 ++++++ .../downloader/download/DownloadWorker.kt | 167 ++++++++ .../skyd/downloader/net/DownloadService.kt | 23 ++ .../skyd/downloader/net/RetrofitInstance.kt | 34 ++ .../DownloadNotificationManager.kt | 327 ++++++++++++++++ .../notification/NotificationConst.kt | 32 ++ .../notification/NotificationReceiver.kt | 257 +++++++++++++ .../java/com/skyd/downloader/util/FileUtil.kt | 46 +++ .../skyd/downloader/util/NotificationUtil.kt | 13 + .../java/com/skyd/downloader/util/TextUtil.kt | 29 ++ .../src/main/res/values-zh-rCN/strings.xml | 8 + downloader/src/main/res/values/strings.xml | 8 + .../com/skyd/downloader/ExampleUnitTest.kt | 17 + gradle/libs.versions.toml | 4 +- settings.gradle.kts | 2 +- 81 files changed, 3073 insertions(+), 745 deletions(-) create mode 100644 app/src/main/java/com/skyd/anivu/model/bean/download/bt/BtDownloadInfoBean.kt rename app/src/main/java/com/skyd/anivu/model/bean/download/{ => bt}/DownloadLinkUuidMapBean.kt (94%) rename app/src/main/java/com/skyd/anivu/model/bean/download/{ => bt}/PeerInfoBean.kt (96%) rename app/src/main/java/com/skyd/anivu/model/bean/download/{ => bt}/SessionParamsBean.kt (87%) rename app/src/main/java/com/skyd/anivu/model/bean/download/{ => bt}/TorrentFileBean.kt (86%) create mode 100644 app/src/main/java/com/skyd/anivu/model/repository/download/DownloadStarter.kt create mode 100644 app/src/main/java/com/skyd/anivu/model/repository/download/bt/BtDownloadManager.kt rename app/src/main/java/com/skyd/anivu/model/repository/download/{DownloadManagerIntent.kt => bt/BtDownloadManagerIntent.kt} (64%) create mode 100644 app/src/main/java/com/skyd/anivu/ui/screen/download/BtDownloadItem.kt create mode 100644 downloader/.gitignore create mode 100644 downloader/build.gradle.kts create mode 100644 downloader/consumer-rules.pro create mode 100644 downloader/proguard-rules.pro create mode 100644 downloader/src/androidTest/java/com/skyd/downloader/ExampleInstrumentedTest.kt create mode 100644 downloader/src/main/AndroidManifest.xml create mode 100644 downloader/src/main/java/com/skyd/downloader/Downloader.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/NotificationConfig.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/Status.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/UserAction.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/db/DatabaseInstance.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/db/DownloadDao.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/db/DownloadDatabase.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/db/DownloadEntity.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/download/DownloadManager.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/download/DownloadRequest.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/download/DownloadTask.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/download/DownloadWorker.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/net/DownloadService.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/net/RetrofitInstance.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/notification/DownloadNotificationManager.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/notification/NotificationConst.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/notification/NotificationReceiver.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/util/FileUtil.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/util/NotificationUtil.kt create mode 100644 downloader/src/main/java/com/skyd/downloader/util/TextUtil.kt create mode 100644 downloader/src/main/res/values-zh-rCN/strings.xml create mode 100644 downloader/src/main/res/values/strings.xml create mode 100644 downloader/src/test/java/com/skyd/downloader/ExampleUnitTest.kt diff --git a/README.md b/README.md index b18a2a37..8675fbca 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ 1. **Subscribe to RSS**, Update RSS, **Read** RSS 2. **Automatically update RSS** subscriptions -3. **Download enclosures** (enclosure tags) of **torrent or magnet** links in RSS articles +3. **Download** enclosures (enclosure tags) in RSS articles, also supports **torrent or magnet links** 4. **Seeding** downloaded files 5. **Play media enclosures or downloaded videos** 6. Support variable playback **speed**, setup **audio track**, **subtitle track**, etc @@ -57,9 +57,10 @@ 9. **Searching** existing **RSS subscription content** 10. **Play other videos on the phone** 11. Support **custom MPV player** -12. Support **import and export** subscriptions via **OPML** -13. Support **dark mode** -14. ...... +12. Support Android **Picture in Picture** +13. Support **import and export** subscriptions via **OPML** +14. Support **dark mode** +15. ...... ## 🤩 Screenshots diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ea2f4554..75095a94 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,7 +22,7 @@ android { minSdk = 24 targetSdk = 35 versionCode = 24 - versionName = "2.1-beta10" + versionName = "2.1-beta11" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -226,6 +226,8 @@ dependencies { implementation(libs.libtorrent4j.x86) implementation(libs.libtorrent4j.x8664) + implementation(project(":downloader")) + // debugImplementation("com.squareup.leakcanary:leakcanary-android:2.13") testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 308bd7af..8e8ec25e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ - + - = emptyList() - - @IgnoredOnParcel - @Ignore - var uploadPayloadRate: Int = 0 - - @IgnoredOnParcel - @Ignore - var downloadPayloadRate: Int = 0 - - enum class DownloadState { - Init, Downloading, Paused, Completed, ErrorPaused, StorageMovedFailed, Seeding, SeedingPaused; - - fun downloadComplete(): Boolean { - return this == Completed || this == Seeding || this == SeedingPaused - } - } - - companion object { - const val LINK_COLUMN = "link" - const val NAME_COLUMN = "name" - const val DOWNLOAD_DATE_COLUMN = "downloadDate" - const val SIZE_COLUMN = "size" - const val PROGRESS_COLUMN = "progress" - const val DESCRIPTION_COLUMN = "description" - const val DOWNLOAD_STATE_COLUMN = "downloadState" - const val DOWNLOAD_REQUEST_ID_COLUMN = "downloadRequestId" - - const val PAYLOAD_PROGRESS = "progress" - const val PAYLOAD_DESCRIPTION = "description" - const val PAYLOAD_PEER_INFO = "peerInfo" - const val PAYLOAD_UPLOAD_PAYLOAD_RATE = "uploadPayloadRate" - const val PAYLOAD_DOWNLOAD_PAYLOAD_RATE = "downloadPayloadRate" - const val PAYLOAD_DOWNLOAD_STATE = "downloadState" - const val PAYLOAD_NAME = "name" - const val PAYLOAD_DOWNLOADING_DIR_NAME = "downloadingDirName" - const val PAYLOAD_SIZE = "size" - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as DownloadInfoBean - - if (link != other.link) return false - if (name != other.name) return false - if (downloadDate != other.downloadDate) return false - if (size != other.size) return false - if (progress != other.progress) return false - if (description != other.description) return false - if (downloadState != other.downloadState) return false - if (downloadRequestId != other.downloadRequestId) return false - if (uploadPayloadRate != other.uploadPayloadRate) return false - if (downloadPayloadRate != other.downloadPayloadRate) return false - return peerInfoList == other.peerInfoList - } - - override fun hashCode(): Int { - var result = link.hashCode() - result = 31 * result + name.hashCode() - result = 31 * result + downloadDate.hashCode() - result = 31 * result + size.hashCode() - result = 31 * result + progress.hashCode() - result = 31 * result + (description?.hashCode() ?: 0) - result = 31 * result + downloadState.hashCode() - result = 31 * result + downloadRequestId.hashCode() - result = 31 * result + peerInfoList.hashCode() - result = 31 * result + uploadPayloadRate.hashCode() - result = 31 * result + downloadPayloadRate.hashCode() - return result - } -} \ No newline at end of file + val id: Int, + val url: String, + val path: String, + val fileName: String, + val status: Status, + val totalBytes: Long, + val downloadedBytes: Long, + val speedInBytePerMs: Float, + val createTime: Long, + val failureReason: String +) \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/model/bean/download/bt/BtDownloadInfoBean.kt b/app/src/main/java/com/skyd/anivu/model/bean/download/bt/BtDownloadInfoBean.kt new file mode 100644 index 00000000..eeae238b --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/model/bean/download/bt/BtDownloadInfoBean.kt @@ -0,0 +1,108 @@ +package com.skyd.anivu.model.bean.download.bt + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import com.skyd.anivu.base.BaseBean +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +const val DOWNLOAD_INFO_TABLE_NAME = "DownloadInfo" + +@Parcelize +@Serializable +@Entity( + tableName = DOWNLOAD_INFO_TABLE_NAME, + primaryKeys = [ + BtDownloadInfoBean.LINK_COLUMN + ], + indices = [ + Index(BtDownloadInfoBean.LINK_COLUMN, unique = true), + ] +) +data class BtDownloadInfoBean( + @ColumnInfo(name = LINK_COLUMN) + val link: String, + @ColumnInfo(name = NAME_COLUMN) + val name: String, + @ColumnInfo(name = DOWNLOAD_DATE_COLUMN) + var downloadDate: Long, + @ColumnInfo(name = SIZE_COLUMN) + val size: Long, + @ColumnInfo(name = PROGRESS_COLUMN) + val progress: Float, + @ColumnInfo(name = DESCRIPTION_COLUMN) + val description: String? = null, + @ColumnInfo(name = DOWNLOAD_STATE_COLUMN) + val downloadState: DownloadState = DownloadState.Init, + @ColumnInfo(name = DOWNLOAD_REQUEST_ID_COLUMN) + val downloadRequestId: String, +) : BaseBean, Parcelable { + @IgnoredOnParcel + @Ignore + var peerInfoList: List = emptyList() + + @IgnoredOnParcel + @Ignore + var uploadPayloadRate: Int = 0 + + @IgnoredOnParcel + @Ignore + var downloadPayloadRate: Int = 0 + + enum class DownloadState { + Init, Downloading, Paused, Completed, ErrorPaused, StorageMovedFailed, Seeding, SeedingPaused; + + fun downloadComplete(): Boolean { + return this == Completed || this == Seeding || this == SeedingPaused + } + } + + companion object { + const val LINK_COLUMN = "link" + const val NAME_COLUMN = "name" + const val DOWNLOAD_DATE_COLUMN = "downloadDate" + const val SIZE_COLUMN = "size" + const val PROGRESS_COLUMN = "progress" + const val DESCRIPTION_COLUMN = "description" + const val DOWNLOAD_STATE_COLUMN = "downloadState" + const val DOWNLOAD_REQUEST_ID_COLUMN = "downloadRequestId" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BtDownloadInfoBean + + if (link != other.link) return false + if (name != other.name) return false + if (downloadDate != other.downloadDate) return false + if (size != other.size) return false + if (progress != other.progress) return false + if (description != other.description) return false + if (downloadState != other.downloadState) return false + if (downloadRequestId != other.downloadRequestId) return false + if (uploadPayloadRate != other.uploadPayloadRate) return false + if (downloadPayloadRate != other.downloadPayloadRate) return false + return peerInfoList == other.peerInfoList + } + + override fun hashCode(): Int { + var result = link.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + downloadDate.hashCode() + result = 31 * result + size.hashCode() + result = 31 * result + progress.hashCode() + result = 31 * result + (description?.hashCode() ?: 0) + result = 31 * result + downloadState.hashCode() + result = 31 * result + downloadRequestId.hashCode() + result = 31 * result + peerInfoList.hashCode() + result = 31 * result + uploadPayloadRate.hashCode() + result = 31 * result + downloadPayloadRate.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/model/bean/download/DownloadLinkUuidMapBean.kt b/app/src/main/java/com/skyd/anivu/model/bean/download/bt/DownloadLinkUuidMapBean.kt similarity index 94% rename from app/src/main/java/com/skyd/anivu/model/bean/download/DownloadLinkUuidMapBean.kt rename to app/src/main/java/com/skyd/anivu/model/bean/download/bt/DownloadLinkUuidMapBean.kt index b2474b02..f01543a6 100644 --- a/app/src/main/java/com/skyd/anivu/model/bean/download/DownloadLinkUuidMapBean.kt +++ b/app/src/main/java/com/skyd/anivu/model/bean/download/bt/DownloadLinkUuidMapBean.kt @@ -1,4 +1,4 @@ -package com.skyd.anivu.model.bean.download +package com.skyd.anivu.model.bean.download.bt import android.os.Parcelable import androidx.room.ColumnInfo diff --git a/app/src/main/java/com/skyd/anivu/model/bean/download/PeerInfoBean.kt b/app/src/main/java/com/skyd/anivu/model/bean/download/bt/PeerInfoBean.kt similarity index 96% rename from app/src/main/java/com/skyd/anivu/model/bean/download/PeerInfoBean.kt rename to app/src/main/java/com/skyd/anivu/model/bean/download/bt/PeerInfoBean.kt index 0fb2bd80..5300d3bb 100644 --- a/app/src/main/java/com/skyd/anivu/model/bean/download/PeerInfoBean.kt +++ b/app/src/main/java/com/skyd/anivu/model/bean/download/bt/PeerInfoBean.kt @@ -1,4 +1,4 @@ -package com.skyd.anivu.model.bean.download +package com.skyd.anivu.model.bean.download.bt import android.os.Parcelable import com.skyd.anivu.base.BaseBean diff --git a/app/src/main/java/com/skyd/anivu/model/bean/download/SessionParamsBean.kt b/app/src/main/java/com/skyd/anivu/model/bean/download/bt/SessionParamsBean.kt similarity index 87% rename from app/src/main/java/com/skyd/anivu/model/bean/download/SessionParamsBean.kt rename to app/src/main/java/com/skyd/anivu/model/bean/download/bt/SessionParamsBean.kt index fdb4002d..4321a48a 100644 --- a/app/src/main/java/com/skyd/anivu/model/bean/download/SessionParamsBean.kt +++ b/app/src/main/java/com/skyd/anivu/model/bean/download/bt/SessionParamsBean.kt @@ -1,4 +1,4 @@ -package com.skyd.anivu.model.bean.download +package com.skyd.anivu.model.bean.download.bt import android.os.Parcelable import androidx.room.ColumnInfo @@ -16,12 +16,12 @@ const val SESSION_PARAMS_TABLE_NAME = "SessionParams" @Entity( tableName = SESSION_PARAMS_TABLE_NAME, primaryKeys = [ - DownloadInfoBean.LINK_COLUMN + BtDownloadInfoBean.LINK_COLUMN ], foreignKeys = [ ForeignKey( - entity = DownloadInfoBean::class, - parentColumns = [DownloadInfoBean.LINK_COLUMN], + entity = BtDownloadInfoBean::class, + parentColumns = [BtDownloadInfoBean.LINK_COLUMN], childColumns = [SessionParamsBean.LINK_COLUMN], onDelete = ForeignKey.CASCADE ) diff --git a/app/src/main/java/com/skyd/anivu/model/bean/download/TorrentFileBean.kt b/app/src/main/java/com/skyd/anivu/model/bean/download/bt/TorrentFileBean.kt similarity index 86% rename from app/src/main/java/com/skyd/anivu/model/bean/download/TorrentFileBean.kt rename to app/src/main/java/com/skyd/anivu/model/bean/download/bt/TorrentFileBean.kt index e99e9d0c..c4b320f9 100644 --- a/app/src/main/java/com/skyd/anivu/model/bean/download/TorrentFileBean.kt +++ b/app/src/main/java/com/skyd/anivu/model/bean/download/bt/TorrentFileBean.kt @@ -1,4 +1,4 @@ -package com.skyd.anivu.model.bean.download +package com.skyd.anivu.model.bean.download.bt import android.os.Parcelable import androidx.room.ColumnInfo @@ -20,8 +20,8 @@ const val TORRENT_FILE_TABLE_NAME = "TorrentFile" ], foreignKeys = [ ForeignKey( - entity = DownloadInfoBean::class, - parentColumns = [DownloadInfoBean.LINK_COLUMN], + entity = BtDownloadInfoBean::class, + parentColumns = [BtDownloadInfoBean.LINK_COLUMN], childColumns = [TorrentFileBean.LINK_COLUMN], onDelete = ForeignKey.CASCADE ) diff --git a/app/src/main/java/com/skyd/anivu/model/db/AppDatabase.kt b/app/src/main/java/com/skyd/anivu/model/db/AppDatabase.kt index bad864f2..43877e9c 100644 --- a/app/src/main/java/com/skyd/anivu/model/db/AppDatabase.kt +++ b/app/src/main/java/com/skyd/anivu/model/db/AppDatabase.kt @@ -10,10 +10,10 @@ import com.skyd.anivu.model.bean.MediaPlayHistoryBean import com.skyd.anivu.model.bean.article.ArticleBean import com.skyd.anivu.model.bean.article.EnclosureBean import com.skyd.anivu.model.bean.article.RssMediaBean -import com.skyd.anivu.model.bean.download.DownloadInfoBean -import com.skyd.anivu.model.bean.download.DownloadLinkUuidMapBean -import com.skyd.anivu.model.bean.download.SessionParamsBean -import com.skyd.anivu.model.bean.download.TorrentFileBean +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean +import com.skyd.anivu.model.bean.download.bt.DownloadLinkUuidMapBean +import com.skyd.anivu.model.bean.download.bt.SessionParamsBean +import com.skyd.anivu.model.bean.download.bt.TorrentFileBean import com.skyd.anivu.model.bean.feed.FeedBean import com.skyd.anivu.model.bean.feed.FeedViewBean import com.skyd.anivu.model.bean.group.GroupBean @@ -50,7 +50,7 @@ const val APP_DATA_BASE_FILE_NAME = "app.db" FeedBean::class, ArticleBean::class, EnclosureBean::class, - DownloadInfoBean::class, + BtDownloadInfoBean::class, DownloadLinkUuidMapBean::class, SessionParamsBean::class, TorrentFileBean::class, diff --git a/app/src/main/java/com/skyd/anivu/model/db/dao/DownloadInfoDao.kt b/app/src/main/java/com/skyd/anivu/model/db/dao/DownloadInfoDao.kt index 3856e7c5..d1c2bfb3 100644 --- a/app/src/main/java/com/skyd/anivu/model/db/dao/DownloadInfoDao.kt +++ b/app/src/main/java/com/skyd/anivu/model/db/dao/DownloadInfoDao.kt @@ -6,28 +6,28 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction -import com.skyd.anivu.model.bean.download.DOWNLOAD_INFO_TABLE_NAME -import com.skyd.anivu.model.bean.download.DOWNLOAD_LINK_UUID_MAP_TABLE_NAME -import com.skyd.anivu.model.bean.download.DownloadInfoBean -import com.skyd.anivu.model.bean.download.DownloadLinkUuidMapBean +import com.skyd.anivu.model.bean.download.bt.DOWNLOAD_INFO_TABLE_NAME +import com.skyd.anivu.model.bean.download.bt.DOWNLOAD_LINK_UUID_MAP_TABLE_NAME +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean +import com.skyd.anivu.model.bean.download.bt.DownloadLinkUuidMapBean import kotlinx.coroutines.flow.Flow @Dao interface DownloadInfoDao { @Transaction @Insert(onConflict = OnConflictStrategy.REPLACE) - fun updateDownloadInfo(downloadInfo: DownloadInfoBean) + fun updateDownloadInfo(downloadInfo: BtDownloadInfoBean) @Transaction @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun updateDownloadInfo(downloadInfoBeanList: List) + suspend fun updateDownloadInfo(btDownloadInfoBeanList: List) @Transaction @Query( """ UPDATE $DOWNLOAD_INFO_TABLE_NAME - SET ${DownloadInfoBean.DOWNLOAD_REQUEST_ID_COLUMN} = :downloadRequestId - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + SET ${BtDownloadInfoBean.DOWNLOAD_REQUEST_ID_COLUMN} = :downloadRequestId + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun updateDownloadInfoRequestId( @@ -39,10 +39,10 @@ interface DownloadInfoDao { @Query( """ UPDATE $DOWNLOAD_INFO_TABLE_NAME - SET ${DownloadInfoBean.NAME_COLUMN} = :name, - ${DownloadInfoBean.SIZE_COLUMN} = :size, - ${DownloadInfoBean.PROGRESS_COLUMN} = :progress - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + SET ${BtDownloadInfoBean.NAME_COLUMN} = :name, + ${BtDownloadInfoBean.SIZE_COLUMN} = :size, + ${BtDownloadInfoBean.PROGRESS_COLUMN} = :progress + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun updateDownloadInfo( @@ -54,13 +54,13 @@ interface DownloadInfoDao { @Transaction @Delete - suspend fun deleteDownloadInfo(downloadInfoBean: DownloadInfoBean): Int + suspend fun deleteDownloadInfo(btDownloadInfoBean: BtDownloadInfoBean): Int @Transaction @Query( """ DELETE FROM $DOWNLOAD_INFO_TABLE_NAME - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) suspend fun deleteDownloadInfo( @@ -71,42 +71,42 @@ interface DownloadInfoDao { @Query( """ UPDATE $DOWNLOAD_INFO_TABLE_NAME - SET ${DownloadInfoBean.DOWNLOAD_STATE_COLUMN} = :downloadState - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + SET ${BtDownloadInfoBean.DOWNLOAD_STATE_COLUMN} = :downloadState + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun updateDownloadState( link: String, - downloadState: DownloadInfoBean.DownloadState, + downloadState: BtDownloadInfoBean.DownloadState, ): Int @Transaction @Query( """ - SELECT ${DownloadInfoBean.DOWNLOAD_STATE_COLUMN} FROM $DOWNLOAD_INFO_TABLE_NAME - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + SELECT ${BtDownloadInfoBean.DOWNLOAD_STATE_COLUMN} FROM $DOWNLOAD_INFO_TABLE_NAME + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun getDownloadState( link: String, - ): DownloadInfoBean.DownloadState? + ): BtDownloadInfoBean.DownloadState? @Transaction @Query( """ SELECT * FROM $DOWNLOAD_INFO_TABLE_NAME - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun getDownloadInfo( link: String, - ): DownloadInfoBean? + ): BtDownloadInfoBean? @Transaction @Query( """ - SELECT ${DownloadInfoBean.DESCRIPTION_COLUMN} FROM $DOWNLOAD_INFO_TABLE_NAME - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + SELECT ${BtDownloadInfoBean.DESCRIPTION_COLUMN} FROM $DOWNLOAD_INFO_TABLE_NAME + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun getDownloadDescription( @@ -117,8 +117,8 @@ interface DownloadInfoDao { @Query( """ UPDATE $DOWNLOAD_INFO_TABLE_NAME - SET ${DownloadInfoBean.DESCRIPTION_COLUMN} = :description - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + SET ${BtDownloadInfoBean.DESCRIPTION_COLUMN} = :description + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun updateDownloadDescription( @@ -129,8 +129,8 @@ interface DownloadInfoDao { @Transaction @Query( """ - SELECT ${DownloadInfoBean.SIZE_COLUMN} FROM $DOWNLOAD_INFO_TABLE_NAME - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + SELECT ${BtDownloadInfoBean.SIZE_COLUMN} FROM $DOWNLOAD_INFO_TABLE_NAME + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun getDownloadSize( @@ -141,8 +141,8 @@ interface DownloadInfoDao { @Query( """ UPDATE $DOWNLOAD_INFO_TABLE_NAME - SET ${DownloadInfoBean.SIZE_COLUMN} = :size - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + SET ${BtDownloadInfoBean.SIZE_COLUMN} = :size + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun updateDownloadSize( @@ -153,8 +153,8 @@ interface DownloadInfoDao { @Transaction @Query( """ - SELECT ${DownloadInfoBean.PROGRESS_COLUMN} FROM $DOWNLOAD_INFO_TABLE_NAME - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + SELECT ${BtDownloadInfoBean.PROGRESS_COLUMN} FROM $DOWNLOAD_INFO_TABLE_NAME + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun getDownloadProgress( @@ -165,8 +165,8 @@ interface DownloadInfoDao { @Query( """ UPDATE $DOWNLOAD_INFO_TABLE_NAME - SET ${DownloadInfoBean.PROGRESS_COLUMN} = :progress - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + SET ${BtDownloadInfoBean.PROGRESS_COLUMN} = :progress + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun updateDownloadProgress( @@ -177,8 +177,8 @@ interface DownloadInfoDao { @Transaction @Query( """ - SELECT ${DownloadInfoBean.NAME_COLUMN} FROM $DOWNLOAD_INFO_TABLE_NAME - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + SELECT ${BtDownloadInfoBean.NAME_COLUMN} FROM $DOWNLOAD_INFO_TABLE_NAME + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun getDownloadName( @@ -189,8 +189,8 @@ interface DownloadInfoDao { @Query( """ UPDATE $DOWNLOAD_INFO_TABLE_NAME - SET ${DownloadInfoBean.NAME_COLUMN} = :name - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + SET ${BtDownloadInfoBean.NAME_COLUMN} = :name + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun updateDownloadName( @@ -202,7 +202,7 @@ interface DownloadInfoDao { @Query( """ SELECT COUNT(1) FROM $DOWNLOAD_INFO_TABLE_NAME - WHERE ${DownloadInfoBean.LINK_COLUMN} = :link + WHERE ${BtDownloadInfoBean.LINK_COLUMN} = :link """ ) fun containsDownloadInfo( @@ -213,13 +213,13 @@ interface DownloadInfoDao { @Query( """ SELECT * FROM $DOWNLOAD_INFO_TABLE_NAME - WHERE ${DownloadInfoBean.PROGRESS_COLUMN} < 1 - AND ${DownloadInfoBean.DOWNLOAD_STATE_COLUMN} <> :completedState + WHERE ${BtDownloadInfoBean.PROGRESS_COLUMN} < 1 + AND ${BtDownloadInfoBean.DOWNLOAD_STATE_COLUMN} <> :completedState """ ) fun getDownloadingListFlow( - completedState: String = DownloadInfoBean.DownloadState.Completed.name - ): Flow> + completedState: String = BtDownloadInfoBean.DownloadState.Completed.name + ): Flow> @Transaction @Query( @@ -227,23 +227,23 @@ interface DownloadInfoDao { SELECT * FROM $DOWNLOAD_INFO_TABLE_NAME """ ) - fun getAllDownloadListFlow(): Flow> + fun getAllDownloadListFlow(): Flow> @Transaction @Query( """ - SELECT ${DownloadInfoBean.DOWNLOAD_REQUEST_ID_COLUMN} FROM $DOWNLOAD_INFO_TABLE_NAME + SELECT ${BtDownloadInfoBean.DOWNLOAD_REQUEST_ID_COLUMN} FROM $DOWNLOAD_INFO_TABLE_NAME """ ) fun getAllDownloadRequestIdFlow(): Flow> @Transaction - @Query("SELECT * FROM $DOWNLOAD_INFO_TABLE_NAME WHERE ${DownloadInfoBean.PROGRESS_COLUMN} < 1") - fun getDownloadingList(): List + @Query("SELECT * FROM $DOWNLOAD_INFO_TABLE_NAME WHERE ${BtDownloadInfoBean.PROGRESS_COLUMN} < 1") + fun getDownloadingList(): List @Transaction - @Query("SELECT * FROM $DOWNLOAD_INFO_TABLE_NAME WHERE ${DownloadInfoBean.PROGRESS_COLUMN} == 1") - fun getDownloadedList(): Flow> + @Query("SELECT * FROM $DOWNLOAD_INFO_TABLE_NAME WHERE ${BtDownloadInfoBean.PROGRESS_COLUMN} == 1") + fun getDownloadedList(): Flow> @Transaction @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/app/src/main/java/com/skyd/anivu/model/db/dao/SessionParamsDao.kt b/app/src/main/java/com/skyd/anivu/model/db/dao/SessionParamsDao.kt index 504ae491..7af31f7a 100644 --- a/app/src/main/java/com/skyd/anivu/model/db/dao/SessionParamsDao.kt +++ b/app/src/main/java/com/skyd/anivu/model/db/dao/SessionParamsDao.kt @@ -6,8 +6,8 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction -import com.skyd.anivu.model.bean.download.SESSION_PARAMS_TABLE_NAME -import com.skyd.anivu.model.bean.download.SessionParamsBean +import com.skyd.anivu.model.bean.download.bt.SESSION_PARAMS_TABLE_NAME +import com.skyd.anivu.model.bean.download.bt.SessionParamsBean @Dao interface SessionParamsDao { diff --git a/app/src/main/java/com/skyd/anivu/model/db/dao/TorrentFileDao.kt b/app/src/main/java/com/skyd/anivu/model/db/dao/TorrentFileDao.kt index 435efe9b..1845e6c5 100644 --- a/app/src/main/java/com/skyd/anivu/model/db/dao/TorrentFileDao.kt +++ b/app/src/main/java/com/skyd/anivu/model/db/dao/TorrentFileDao.kt @@ -6,8 +6,8 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction -import com.skyd.anivu.model.bean.download.TORRENT_FILE_TABLE_NAME -import com.skyd.anivu.model.bean.download.TorrentFileBean +import com.skyd.anivu.model.bean.download.bt.TORRENT_FILE_TABLE_NAME +import com.skyd.anivu.model.bean.download.bt.TorrentFileBean @Dao interface TorrentFileDao { diff --git a/app/src/main/java/com/skyd/anivu/model/db/migration/Migration1To2.kt b/app/src/main/java/com/skyd/anivu/model/db/migration/Migration1To2.kt index d4168333..ff9bdf30 100644 --- a/app/src/main/java/com/skyd/anivu/model/db/migration/Migration1To2.kt +++ b/app/src/main/java/com/skyd/anivu/model/db/migration/Migration1To2.kt @@ -2,8 +2,8 @@ package com.skyd.anivu.model.db.migration import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -import com.skyd.anivu.model.bean.download.DOWNLOAD_LINK_UUID_MAP_TABLE_NAME -import com.skyd.anivu.model.bean.download.DownloadLinkUuidMapBean +import com.skyd.anivu.model.bean.download.bt.DOWNLOAD_LINK_UUID_MAP_TABLE_NAME +import com.skyd.anivu.model.bean.download.bt.DownloadLinkUuidMapBean class Migration1To2 : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { diff --git a/app/src/main/java/com/skyd/anivu/model/db/migration/Migration2To3.kt b/app/src/main/java/com/skyd/anivu/model/db/migration/Migration2To3.kt index de603466..14ff3f59 100644 --- a/app/src/main/java/com/skyd/anivu/model/db/migration/Migration2To3.kt +++ b/app/src/main/java/com/skyd/anivu/model/db/migration/Migration2To3.kt @@ -2,10 +2,10 @@ package com.skyd.anivu.model.db.migration import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -import com.skyd.anivu.model.bean.download.DOWNLOAD_INFO_TABLE_NAME -import com.skyd.anivu.model.bean.download.DownloadInfoBean -import com.skyd.anivu.model.bean.download.TORRENT_FILE_TABLE_NAME -import com.skyd.anivu.model.bean.download.TorrentFileBean +import com.skyd.anivu.model.bean.download.bt.DOWNLOAD_INFO_TABLE_NAME +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean +import com.skyd.anivu.model.bean.download.bt.TORRENT_FILE_TABLE_NAME +import com.skyd.anivu.model.bean.download.bt.TorrentFileBean class Migration2To3 : Migration(2, 3) { override fun migrate(db: SupportSQLiteDatabase) { @@ -17,7 +17,7 @@ class Migration2To3 : Migration(2, 3) { ${TorrentFileBean.SIZE_COLUMN} INTEGER NOT NULL, PRIMARY KEY (${TorrentFileBean.LINK_COLUMN}, ${TorrentFileBean.PATH_COLUMN}) FOREIGN KEY (${TorrentFileBean.LINK_COLUMN}) - REFERENCES $DOWNLOAD_INFO_TABLE_NAME(${DownloadInfoBean.LINK_COLUMN}) + REFERENCES $DOWNLOAD_INFO_TABLE_NAME(${BtDownloadInfoBean.LINK_COLUMN}) ON DELETE CASCADE ) """ diff --git a/app/src/main/java/com/skyd/anivu/model/db/migration/Migration8To9.kt b/app/src/main/java/com/skyd/anivu/model/db/migration/Migration8To9.kt index 52f32cb8..0bbbef00 100644 --- a/app/src/main/java/com/skyd/anivu/model/db/migration/Migration8To9.kt +++ b/app/src/main/java/com/skyd/anivu/model/db/migration/Migration8To9.kt @@ -2,15 +2,15 @@ package com.skyd.anivu.model.db.migration import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -import com.skyd.anivu.model.bean.download.DOWNLOAD_INFO_TABLE_NAME -import com.skyd.anivu.model.bean.download.DownloadInfoBean.Companion.DESCRIPTION_COLUMN -import com.skyd.anivu.model.bean.download.DownloadInfoBean.Companion.DOWNLOAD_DATE_COLUMN -import com.skyd.anivu.model.bean.download.DownloadInfoBean.Companion.DOWNLOAD_REQUEST_ID_COLUMN -import com.skyd.anivu.model.bean.download.DownloadInfoBean.Companion.DOWNLOAD_STATE_COLUMN -import com.skyd.anivu.model.bean.download.DownloadInfoBean.Companion.LINK_COLUMN -import com.skyd.anivu.model.bean.download.DownloadInfoBean.Companion.NAME_COLUMN -import com.skyd.anivu.model.bean.download.DownloadInfoBean.Companion.PROGRESS_COLUMN -import com.skyd.anivu.model.bean.download.DownloadInfoBean.Companion.SIZE_COLUMN +import com.skyd.anivu.model.bean.download.bt.DOWNLOAD_INFO_TABLE_NAME +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean.Companion.DESCRIPTION_COLUMN +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean.Companion.DOWNLOAD_DATE_COLUMN +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean.Companion.DOWNLOAD_REQUEST_ID_COLUMN +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean.Companion.DOWNLOAD_STATE_COLUMN +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean.Companion.LINK_COLUMN +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean.Companion.NAME_COLUMN +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean.Companion.PROGRESS_COLUMN +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean.Companion.SIZE_COLUMN class Migration8To9 : Migration(8, 9) { override fun migrate(db: SupportSQLiteDatabase) { diff --git a/app/src/main/java/com/skyd/anivu/model/repository/MediaRepository.kt b/app/src/main/java/com/skyd/anivu/model/repository/MediaRepository.kt index b5a4d709..1acb688f 100644 --- a/app/src/main/java/com/skyd/anivu/model/repository/MediaRepository.kt +++ b/app/src/main/java/com/skyd/anivu/model/repository/MediaRepository.kt @@ -2,9 +2,10 @@ package com.skyd.anivu.model.repository import androidx.compose.ui.util.fastFirstOrNull import com.skyd.anivu.base.BaseRepository +import com.skyd.anivu.ext.validateFileName +import com.skyd.anivu.model.bean.MediaBean import com.skyd.anivu.model.bean.MediaGroupBean import com.skyd.anivu.model.bean.MediaGroupBean.Companion.isDefaultGroup -import com.skyd.anivu.model.bean.MediaBean import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -87,6 +88,13 @@ class MediaRepository @Inject constructor( }.flowOn(Dispatchers.IO) } + fun renameFile(file: File, newName: String): Flow { + return flow { + val newFile = File(file.parentFile, newName.validateFileName()) + emit(if (file.renameTo(newFile)) newFile else null) + }.flowOn(Dispatchers.IO) + } + fun createGroup(uriPath: String, group: MediaGroupBean): Flow = flow { if (group.isDefaultGroup()) { emit(Unit) diff --git a/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadManager.kt b/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadManager.kt index 2afc5824..55b30158 100644 --- a/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadManager.kt +++ b/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadManager.kt @@ -1,308 +1,84 @@ package com.skyd.anivu.model.repository.download -import androidx.work.WorkManager -import com.skyd.anivu.appContext -import com.skyd.anivu.ext.sampleWithoutFirst +import android.app.Application +import android.app.NotificationManager +import android.content.Context +import com.skyd.anivu.R import com.skyd.anivu.model.bean.download.DownloadInfoBean -import com.skyd.anivu.model.bean.download.DownloadInfoBean.DownloadState -import com.skyd.anivu.model.bean.download.DownloadLinkUuidMapBean -import com.skyd.anivu.model.bean.download.SessionParamsBean -import com.skyd.anivu.model.bean.download.TorrentFileBean -import com.skyd.anivu.model.db.dao.DownloadInfoDao -import com.skyd.anivu.model.db.dao.SessionParamsDao -import com.skyd.anivu.model.db.dao.TorrentFileDao -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import com.skyd.downloader.Downloader +import com.skyd.downloader.NotificationConfig +import com.skyd.downloader.Status +import com.skyd.downloader.db.DownloadEntity import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.UUID - -object DownloadManager { - @EntryPoint - @InstallIn(SingletonComponent::class) - interface DownloadManagerPoint { - val downloadInfoDao: DownloadInfoDao - val sessionParamsDao: SessionParamsDao - val torrentFileDao: TorrentFileDao - } - - private val hiltEntryPoint = EntryPointAccessors.fromApplication( - appContext, DownloadManagerPoint::class.java - ) - - private val downloadInfoDao = hiltEntryPoint.downloadInfoDao - private val sessionParamsDao = hiltEntryPoint.sessionParamsDao - private val torrentFileDao = hiltEntryPoint.torrentFileDao - private val scope = CoroutineScope(Dispatchers.IO) - private val intentFlow = MutableSharedFlow() - private lateinit var downloadInfoMap: LinkedHashMap - private lateinit var downloadInfoListFlow: MutableStateFlow> - - init { - intentFlow - .onEachIntent() - .launchIn(scope) - } - - private fun Flow.onEachIntent(): Flow { - return merge( - filterIsInstance() - .sampleWithoutFirst(100) - .onEach { intent -> - downloadInfoDao.updateDownloadInfo(intent.downloadInfoBean) - putDownloadInfoToMap( - link = intent.downloadInfoBean.link, - newInfo = intent.downloadInfoBean, - ) - }.catch { it.printStackTrace() }, - - filterIsInstance() - .onEach { intent -> - sessionParamsDao.updateSessionParams( - SessionParamsBean( - link = intent.link, - data = intent.sessionStateData, - ) - ) - }.catch { it.printStackTrace() }, - - filterIsInstance() - .sampleWithoutFirst(1000) - .onEach { intent -> - val result = downloadInfoDao.updateDownloadProgress( - link = intent.link, - progress = intent.progress, - ) - if (result > 0) { - updateDownloadInfoMap(intent.link) { copy(progress = intent.progress) } - } - }.catch { it.printStackTrace() }, - - filterIsInstance() - .onEach { intent -> - val result = downloadInfoDao.updateDownloadState( - link = intent.link, - downloadState = intent.downloadState, - ) - if (result > 0) { - updateDownloadInfoMap(intent.link) { copy(downloadState = intent.downloadState) } - } - }.catch { it.printStackTrace() }, - - filterIsInstance() - .sampleWithoutFirst(1000) - .onEach { intent -> - val result = downloadInfoDao.updateDownloadSize( - link = intent.link, size = intent.size, - ) - if (result > 0) { - updateDownloadInfoMap(intent.link) { copy(size = intent.size) } - } - }.catch { it.printStackTrace() }, - - filterIsInstance() - .sampleWithoutFirst(200) - .onEach { intent -> - if (intent.name.isNullOrBlank()) return@onEach - val result = downloadInfoDao.updateDownloadName( - link = intent.link, - name = intent.name, - ) - if (result > 0) { - updateDownloadInfoMap(intent.link) { copy(name = intent.name) } - } - }.catch { it.printStackTrace() }, - - filterIsInstance() - .onEach { intent -> - val result = downloadInfoDao.updateDownloadInfoRequestId( - link = intent.link, - downloadRequestId = intent.downloadRequestId, - ) - if (result > 0) { - updateDownloadInfoMap(intent.link) { - copy(downloadRequestId = intent.downloadRequestId) - } - } - }.catch { it.printStackTrace() }, - - filterIsInstance() - .onEach { intent -> torrentFileDao.updateTorrentFiles(intent.files) } - .catch { it.printStackTrace() }, - - filterIsInstance() - .sampleWithoutFirst(500) - .onEach { intent -> - val result = downloadInfoDao.updateDownloadDescription( - link = intent.link, - description = intent.description, - ) - if (result > 0) { - updateDownloadInfoMap(intent.link) { copy(description = intent.description) } - } - } - .catch { it.printStackTrace() }, +import kotlinx.coroutines.flow.map + +class DownloadManager private constructor(context: Context) { + private val downloader = Downloader.init( + context.applicationContext as Application, + NotificationConfig( + channelName = context.getString(R.string.download_channel_name), + channelDescription = context.getString(R.string.download_channel_description), + smallIcon = R.drawable.ic_icon_2_24, + importance = NotificationManager.IMPORTANCE_LOW, + pauseText = R.string.download_pause, + resumeText = R.string.download_resume, + cancelText = R.string.download_cancel, + retryText = R.string.download_retry, ) + ) + val downloadInfoListFlow: Flow> = downloader.observeDownloads() + .map { list -> list.map { it.toDownloadInfoBean() } } + + fun download( + url: String, + path: String, + fileName: String? = null, + ): Any { + return if (fileName == null) { + downloader.download(url = url, path = path) + } else { + downloader.download(url = url, fileName = fileName, path = path) + } } - fun sendIntent(intent: DownloadManagerIntent) = scope.launch { - intentFlow.emit(intent) - } - - suspend fun getDownloadInfoList(): Flow> { - checkDownloadInfoMapInitialized() - return downloadInfoListFlow - } - - suspend fun getDownloadInfo(link: String): DownloadInfoBean? { - checkDownloadInfoMapInitialized() - return downloadInfoMap[link] - } - - fun getDownloadLinkByUuid(uuid: String): String? { - return downloadInfoDao.getDownloadLinkByUuid(uuid) - } - - fun getDownloadUuidByLink(link: String): String? { - return downloadInfoDao.getDownloadUuidByLink(link) - } - - fun setDownloadLinkUuidMap(bean: DownloadLinkUuidMapBean) { - return downloadInfoDao.setDownloadLinkUuidMap(bean) - } - - suspend fun getDownloadName(link: String): String? { - checkDownloadInfoMapInitialized() - return downloadInfoMap[link]?.name - } - - suspend fun getDownloadProgress(link: String): Float? { - checkDownloadInfoMapInitialized() - return downloadInfoMap[link]?.progress - } - - suspend fun getDownloadState(link: String): DownloadState? { - checkDownloadInfoMapInitialized() - return downloadInfoMap[link]?.downloadState - } - - suspend fun containsDownloadInfo(link: String): Boolean { - checkDownloadInfoMapInitialized() - return downloadInfoMap.containsKey(link) - } - - fun getSessionParams(link: String): SessionParamsBean? { - return sessionParamsDao.getSessionParams(link) - } - - fun getTorrentFilesByLink(link: String): List { - return torrentFileDao.getTorrentFilesByLink(link = link) - } - - suspend fun deleteDownloadInfo(link: String): Int { - checkDownloadInfoMapInitialized() - removeDownloadInfoFromMap(link) - return downloadInfoDao.deleteDownloadInfo(link) - } - - fun deleteSessionParams(link: String): Int { - return sessionParamsDao.deleteSessionParams(link) - } - - fun removeDownloadLinkUuidMap(link: String): Int { - return downloadInfoDao.removeDownloadLinkUuidMap(link) - } - - // update --------------------------- - - private suspend fun checkDownloadInfoMapInitialized() { - withContext(Dispatchers.IO) { - if (!this@DownloadManager::downloadInfoMap.isInitialized) { - initDownloadInfoList() - } + fun pause(id: Int) = downloader.pause(id) + fun resume(id: Int) = downloader.resume(id) + fun retry(id: Int) = downloader.retry(id) + fun delete(id: Int) { + downloader.find(id) { entity -> + downloader.clearDb( + id, + deleteFile = entity != null && Status.valueOf(entity.status) != Status.Success, + ) } } - private suspend fun initDownloadInfoList() { - val workManager = WorkManager.getInstance(appContext) - downloadInfoMap = downloadInfoDao.getAllDownloadListFlow().first().let { list -> - val newList = list.map { - when (it.downloadState) { - DownloadState.Downloading -> { - if (workManager.getWorkInfoById(UUID.fromString(it.downloadRequestId)) - .get()?.state?.isFinished == true - ) return@map it - val result = downloadInfoDao.updateDownloadState( - link = it.link, - downloadState = DownloadState.Paused, - ) - if (result > 0) { - it.copy(downloadState = DownloadState.Paused) - } else it - } + private fun DownloadEntity.toDownloadInfoBean() = DownloadInfoBean( + id = id, + url = url, + path = path, + fileName = fileName, + status = Status.valueOf(status), + totalBytes = totalBytes, + downloadedBytes = downloadedBytes, + speedInBytePerMs = speedInBytePerMs, + createTime = createTime, + failureReason = failureReason, + ) - DownloadState.Seeding -> { - if (workManager.getWorkInfoById(UUID.fromString(it.downloadRequestId)) - .get()?.state?.isFinished == true - ) return@map it - val result = downloadInfoDao.updateDownloadState( - link = it.link, - downloadState = DownloadState.SeedingPaused, - ) - if (result > 0) { - it.copy(downloadState = DownloadState.SeedingPaused) - } else it - } + companion object { + @Volatile + private var instance: DownloadManager? = null - else -> it + fun getInstance(context: Context): DownloadManager { + if (instance == null) { + synchronized(DownloadManager) { + if (instance == null) { + instance = DownloadManager(context) + } } } - - linkedMapOf(*(newList.map { it.link to it }.toTypedArray())) + return instance!! } - downloadInfoListFlow = MutableStateFlow(downloadInfoMap.values.toList()) - } - - private suspend fun updateFlow() { - checkDownloadInfoMapInitialized() - downloadInfoListFlow.emit(downloadInfoMap.values.toList()) - } - - private suspend fun putDownloadInfoToMap( - link: String, - newInfo: DownloadInfoBean, - ) { - checkDownloadInfoMapInitialized() - downloadInfoMap[link] = newInfo - updateFlow() - } - - private suspend fun updateDownloadInfoMap( - link: String, - newInfo: DownloadInfoBean.() -> DownloadInfoBean, - ) { - checkDownloadInfoMapInitialized() - val downloadInfo = downloadInfoMap[link] ?: return - downloadInfoMap[link] = downloadInfo.newInfo() - updateFlow() - } - - private suspend fun removeDownloadInfoFromMap(link: String) { - checkDownloadInfoMapInitialized() - downloadInfoMap.remove(link) - updateFlow() } } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadRepository.kt b/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadRepository.kt index d204c133..4d5dd23a 100644 --- a/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadRepository.kt +++ b/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadRepository.kt @@ -1,10 +1,14 @@ package com.skyd.anivu.model.repository.download +import com.skyd.anivu.appContext import com.skyd.anivu.base.BaseRepository import com.skyd.anivu.config.Const import com.skyd.anivu.ext.sampleWithoutFirst import com.skyd.anivu.model.bean.download.DownloadInfoBean +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean +import com.skyd.anivu.model.repository.download.bt.BtDownloadManager import com.skyd.anivu.model.worker.download.DownloadTorrentWorker +import com.skyd.downloader.db.DownloadEntity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -15,9 +19,9 @@ import java.io.File import javax.inject.Inject class DownloadRepository @Inject constructor() : BaseRepository() { - suspend fun requestDownloadingVideos(): Flow> { + suspend fun requestBtDownloadTasksList(): Flow> { return combine( - DownloadManager.getDownloadInfoList().distinctUntilChanged(), + BtDownloadManager.getDownloadInfoList().distinctUntilChanged(), DownloadTorrentWorker.peerInfoMapFlow.sampleWithoutFirst(1000), DownloadTorrentWorker.torrentStatusMapFlow.sampleWithoutFirst(1000), ) { list, peerInfoMap, uploadPayloadRateMap -> @@ -34,23 +38,27 @@ class DownloadRepository @Inject constructor() : BaseRepository() { }.flowOn(Dispatchers.IO) } + fun requestDownloadTasksList(): Flow> { + return DownloadManager.getInstance(appContext).downloadInfoListFlow + } + suspend fun deleteDownloadTaskInfo( link: String, ): Flow { return flow { - if (DownloadManager.getDownloadState(link)?.downloadComplete() != true) { - DownloadManager.getTorrentFilesByLink(link).forEach { + if (BtDownloadManager.getDownloadState(link)?.downloadComplete() != true) { + BtDownloadManager.getTorrentFilesByLink(link).forEach { File(it.path).deleteRecursively() } } - val requestUuid = DownloadManager.getDownloadInfo(link)?.downloadRequestId + val requestUuid = BtDownloadManager.getDownloadInfo(link)?.downloadRequestId if (!requestUuid.isNullOrBlank()) { File(Const.TORRENT_RESUME_DATA_DIR, requestUuid).deleteRecursively() } // 这些最后删除,防止上面会使用 - DownloadManager.deleteDownloadInfo(link) - DownloadManager.deleteSessionParams(link) - DownloadManager.removeDownloadLinkUuidMap(link) + BtDownloadManager.deleteDownloadInfo(link) + BtDownloadManager.deleteSessionParams(link) + BtDownloadManager.removeDownloadLinkUuidMap(link) emit(Unit) }.flowOn(Dispatchers.IO) } diff --git a/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadStarter.kt b/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadStarter.kt new file mode 100644 index 00000000..ed2ecd77 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadStarter.kt @@ -0,0 +1,40 @@ +package com.skyd.anivu.model.repository.download + +import android.Manifest +import android.content.Context +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker +import com.skyd.anivu.R +import com.skyd.anivu.ext.dataStore +import com.skyd.anivu.ext.getOrDefault +import com.skyd.anivu.model.preference.data.medialib.MediaLibLocationPreference +import com.skyd.anivu.model.worker.download.DownloadTorrentWorker +import com.skyd.anivu.model.worker.download.isTorrentMimetype +import com.skyd.anivu.ui.component.showToast +import java.io.File + +object DownloadStarter { + fun download(context: Context, url: String, type: String? = null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + if (granted == PermissionChecker.PERMISSION_DENIED) { + context.getString(R.string.download_no_notification_permission_tip) + .showToast() + return + } + } + val isMagnetOrTorrent = + url.startsWith("magnet:") || isTorrentMimetype(type) || + Regex("^(http|https)://.*\\.torrent$").matches(url) + if (isMagnetOrTorrent) { + DownloadTorrentWorker.startWorker(context, url, requestId = null) + } else { + DownloadManager.getInstance(context).download( + url = url, + path = File(context.dataStore.getOrDefault(MediaLibLocationPreference)).path, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/model/repository/download/bt/BtDownloadManager.kt b/app/src/main/java/com/skyd/anivu/model/repository/download/bt/BtDownloadManager.kt new file mode 100644 index 00000000..c4e83e32 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/model/repository/download/bt/BtDownloadManager.kt @@ -0,0 +1,308 @@ +package com.skyd.anivu.model.repository.download.bt + +import androidx.work.WorkManager +import com.skyd.anivu.appContext +import com.skyd.anivu.ext.sampleWithoutFirst +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean.DownloadState +import com.skyd.anivu.model.bean.download.bt.DownloadLinkUuidMapBean +import com.skyd.anivu.model.bean.download.bt.SessionParamsBean +import com.skyd.anivu.model.bean.download.bt.TorrentFileBean +import com.skyd.anivu.model.db.dao.DownloadInfoDao +import com.skyd.anivu.model.db.dao.SessionParamsDao +import com.skyd.anivu.model.db.dao.TorrentFileDao +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.UUID + +object BtDownloadManager { + @EntryPoint + @InstallIn(SingletonComponent::class) + interface DownloadManagerPoint { + val downloadInfoDao: DownloadInfoDao + val sessionParamsDao: SessionParamsDao + val torrentFileDao: TorrentFileDao + } + + private val hiltEntryPoint = EntryPointAccessors.fromApplication( + appContext, DownloadManagerPoint::class.java + ) + + private val downloadInfoDao = hiltEntryPoint.downloadInfoDao + private val sessionParamsDao = hiltEntryPoint.sessionParamsDao + private val torrentFileDao = hiltEntryPoint.torrentFileDao + private val scope = CoroutineScope(Dispatchers.IO) + private val intentFlow = MutableSharedFlow() + private lateinit var downloadInfoMap: LinkedHashMap + private lateinit var downloadInfoListFlow: MutableStateFlow> + + init { + intentFlow + .onEachIntent() + .launchIn(scope) + } + + private fun Flow.onEachIntent(): Flow { + return merge( + filterIsInstance() + .sampleWithoutFirst(100) + .onEach { intent -> + downloadInfoDao.updateDownloadInfo(intent.btDownloadInfoBean) + putDownloadInfoToMap( + link = intent.btDownloadInfoBean.link, + newInfo = intent.btDownloadInfoBean, + ) + }.catch { it.printStackTrace() }, + + filterIsInstance() + .onEach { intent -> + sessionParamsDao.updateSessionParams( + SessionParamsBean( + link = intent.link, + data = intent.sessionStateData, + ) + ) + }.catch { it.printStackTrace() }, + + filterIsInstance() + .sampleWithoutFirst(1000) + .onEach { intent -> + val result = downloadInfoDao.updateDownloadProgress( + link = intent.link, + progress = intent.progress, + ) + if (result > 0) { + updateDownloadInfoMap(intent.link) { copy(progress = intent.progress) } + } + }.catch { it.printStackTrace() }, + + filterIsInstance() + .onEach { intent -> + val result = downloadInfoDao.updateDownloadState( + link = intent.link, + downloadState = intent.downloadState, + ) + if (result > 0) { + updateDownloadInfoMap(intent.link) { copy(downloadState = intent.downloadState) } + } + }.catch { it.printStackTrace() }, + + filterIsInstance() + .sampleWithoutFirst(1000) + .onEach { intent -> + val result = downloadInfoDao.updateDownloadSize( + link = intent.link, size = intent.size, + ) + if (result > 0) { + updateDownloadInfoMap(intent.link) { copy(size = intent.size) } + } + }.catch { it.printStackTrace() }, + + filterIsInstance() + .sampleWithoutFirst(200) + .onEach { intent -> + if (intent.name.isNullOrBlank()) return@onEach + val result = downloadInfoDao.updateDownloadName( + link = intent.link, + name = intent.name, + ) + if (result > 0) { + updateDownloadInfoMap(intent.link) { copy(name = intent.name) } + } + }.catch { it.printStackTrace() }, + + filterIsInstance() + .onEach { intent -> + val result = downloadInfoDao.updateDownloadInfoRequestId( + link = intent.link, + downloadRequestId = intent.downloadRequestId, + ) + if (result > 0) { + updateDownloadInfoMap(intent.link) { + copy(downloadRequestId = intent.downloadRequestId) + } + } + }.catch { it.printStackTrace() }, + + filterIsInstance() + .onEach { intent -> torrentFileDao.updateTorrentFiles(intent.files) } + .catch { it.printStackTrace() }, + + filterIsInstance() + .sampleWithoutFirst(500) + .onEach { intent -> + val result = downloadInfoDao.updateDownloadDescription( + link = intent.link, + description = intent.description, + ) + if (result > 0) { + updateDownloadInfoMap(intent.link) { copy(description = intent.description) } + } + } + .catch { it.printStackTrace() }, + ) + } + + fun sendIntent(intent: BtDownloadManagerIntent) = scope.launch { + intentFlow.emit(intent) + } + + suspend fun getDownloadInfoList(): Flow> { + checkDownloadInfoMapInitialized() + return downloadInfoListFlow + } + + suspend fun getDownloadInfo(link: String): BtDownloadInfoBean? { + checkDownloadInfoMapInitialized() + return downloadInfoMap[link] + } + + fun getDownloadLinkByUuid(uuid: String): String? { + return downloadInfoDao.getDownloadLinkByUuid(uuid) + } + + fun getDownloadUuidByLink(link: String): String? { + return downloadInfoDao.getDownloadUuidByLink(link) + } + + fun setDownloadLinkUuidMap(bean: DownloadLinkUuidMapBean) { + return downloadInfoDao.setDownloadLinkUuidMap(bean) + } + + suspend fun getDownloadName(link: String): String? { + checkDownloadInfoMapInitialized() + return downloadInfoMap[link]?.name + } + + suspend fun getDownloadProgress(link: String): Float? { + checkDownloadInfoMapInitialized() + return downloadInfoMap[link]?.progress + } + + suspend fun getDownloadState(link: String): DownloadState? { + checkDownloadInfoMapInitialized() + return downloadInfoMap[link]?.downloadState + } + + suspend fun containsDownloadInfo(link: String): Boolean { + checkDownloadInfoMapInitialized() + return downloadInfoMap.containsKey(link) + } + + fun getSessionParams(link: String): SessionParamsBean? { + return sessionParamsDao.getSessionParams(link) + } + + fun getTorrentFilesByLink(link: String): List { + return torrentFileDao.getTorrentFilesByLink(link = link) + } + + suspend fun deleteDownloadInfo(link: String): Int { + checkDownloadInfoMapInitialized() + removeDownloadInfoFromMap(link) + return downloadInfoDao.deleteDownloadInfo(link) + } + + fun deleteSessionParams(link: String): Int { + return sessionParamsDao.deleteSessionParams(link) + } + + fun removeDownloadLinkUuidMap(link: String): Int { + return downloadInfoDao.removeDownloadLinkUuidMap(link) + } + + // update --------------------------- + + private suspend fun checkDownloadInfoMapInitialized() { + withContext(Dispatchers.IO) { + if (!this@BtDownloadManager::downloadInfoMap.isInitialized) { + initDownloadInfoList() + } + } + } + + private suspend fun initDownloadInfoList() { + val workManager = WorkManager.getInstance(appContext) + downloadInfoMap = downloadInfoDao.getAllDownloadListFlow().first().let { list -> + val newList = list.map { + when (it.downloadState) { + DownloadState.Downloading -> { + if (workManager.getWorkInfoById(UUID.fromString(it.downloadRequestId)) + .get()?.state?.isFinished == true + ) return@map it + val result = downloadInfoDao.updateDownloadState( + link = it.link, + downloadState = DownloadState.Paused, + ) + if (result > 0) { + it.copy(downloadState = DownloadState.Paused) + } else it + } + + DownloadState.Seeding -> { + if (workManager.getWorkInfoById(UUID.fromString(it.downloadRequestId)) + .get()?.state?.isFinished == true + ) return@map it + val result = downloadInfoDao.updateDownloadState( + link = it.link, + downloadState = DownloadState.SeedingPaused, + ) + if (result > 0) { + it.copy(downloadState = DownloadState.SeedingPaused) + } else it + } + + else -> it + } + } + + linkedMapOf(*(newList.map { it.link to it }.toTypedArray())) + } + downloadInfoListFlow = MutableStateFlow(downloadInfoMap.values.toList()) + } + + private suspend fun updateFlow() { + checkDownloadInfoMapInitialized() + downloadInfoListFlow.emit(downloadInfoMap.values.toList()) + } + + private suspend fun putDownloadInfoToMap( + link: String, + newInfo: BtDownloadInfoBean, + ) { + checkDownloadInfoMapInitialized() + downloadInfoMap[link] = newInfo + updateFlow() + } + + private suspend fun updateDownloadInfoMap( + link: String, + newInfo: BtDownloadInfoBean.() -> BtDownloadInfoBean, + ) { + checkDownloadInfoMapInitialized() + val downloadInfo = downloadInfoMap[link] ?: return + downloadInfoMap[link] = downloadInfo.newInfo() + updateFlow() + } + + private suspend fun removeDownloadInfoFromMap(link: String) { + checkDownloadInfoMapInitialized() + downloadInfoMap.remove(link) + updateFlow() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadManagerIntent.kt b/app/src/main/java/com/skyd/anivu/model/repository/download/bt/BtDownloadManagerIntent.kt similarity index 64% rename from app/src/main/java/com/skyd/anivu/model/repository/download/DownloadManagerIntent.kt rename to app/src/main/java/com/skyd/anivu/model/repository/download/bt/BtDownloadManagerIntent.kt index f0eae705..70108100 100644 --- a/app/src/main/java/com/skyd/anivu/model/repository/download/DownloadManagerIntent.kt +++ b/app/src/main/java/com/skyd/anivu/model/repository/download/bt/BtDownloadManagerIntent.kt @@ -1,16 +1,17 @@ -package com.skyd.anivu.model.repository.download +package com.skyd.anivu.model.repository.download.bt import com.skyd.anivu.base.mvi.MviIntent -import com.skyd.anivu.model.bean.download.DownloadInfoBean -import com.skyd.anivu.model.bean.download.DownloadInfoBean.DownloadState -import com.skyd.anivu.model.bean.download.TorrentFileBean +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean.DownloadState +import com.skyd.anivu.model.bean.download.bt.TorrentFileBean -sealed interface DownloadManagerIntent : MviIntent { - data class UpdateDownloadInfo(val downloadInfoBean: DownloadInfoBean) : DownloadManagerIntent +sealed interface BtDownloadManagerIntent : MviIntent { + data class UpdateDownloadInfo(val btDownloadInfoBean: BtDownloadInfoBean) : + BtDownloadManagerIntent data class UpdateSessionParams( val link: String, val sessionStateData: ByteArray, - ) : DownloadManagerIntent { + ) : BtDownloadManagerIntent { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -30,18 +31,19 @@ sealed interface DownloadManagerIntent : MviIntent { } } - data class UpdateDownloadProgress(val link: String, val progress: Float) : DownloadManagerIntent + data class UpdateDownloadProgress(val link: String, val progress: Float) : + BtDownloadManagerIntent data class UpdateDownloadState( val link: String, val downloadState: DownloadState, - ) : DownloadManagerIntent + ) : BtDownloadManagerIntent - data class UpdateDownloadSize(val link: String, val size: Long) : DownloadManagerIntent - data class UpdateDownloadName(val link: String, val name: String?) : DownloadManagerIntent + data class UpdateDownloadSize(val link: String, val size: Long) : BtDownloadManagerIntent + data class UpdateDownloadName(val link: String, val name: String?) : BtDownloadManagerIntent data class UpdateDownloadInfoRequestId(val link: String, val downloadRequestId: String) : - DownloadManagerIntent + BtDownloadManagerIntent - data class UpdateTorrentFiles(val files: List) : DownloadManagerIntent + data class UpdateTorrentFiles(val files: List) : BtDownloadManagerIntent data class UpdateDownloadDescription(val link: String, val description: String) : - DownloadManagerIntent + BtDownloadManagerIntent } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/model/repository/feed/FeedRepository.kt b/app/src/main/java/com/skyd/anivu/model/repository/feed/FeedRepository.kt index a65077a7..75145a5b 100644 --- a/app/src/main/java/com/skyd/anivu/model/repository/feed/FeedRepository.kt +++ b/app/src/main/java/com/skyd/anivu/model/repository/feed/FeedRepository.kt @@ -36,7 +36,7 @@ class FeedRepository @Inject constructor( private val reorderGroupRepository: ReorderGroupRepository, private val rssHelper: RssHelper, ) : BaseRepository() { - suspend fun requestGroupAnyList(): Flow> { + fun requestGroupAnyList(): Flow> { return combine( groupDao.getGroupWithFeeds(), groupDao.getGroupIds(), diff --git a/app/src/main/java/com/skyd/anivu/model/worker/download/DownloadTorrentWorker.kt b/app/src/main/java/com/skyd/anivu/model/worker/download/DownloadTorrentWorker.kt index bedcd3be..fcdfdf26 100644 --- a/app/src/main/java/com/skyd/anivu/model/worker/download/DownloadTorrentWorker.kt +++ b/app/src/main/java/com/skyd/anivu/model/worker/download/DownloadTorrentWorker.kt @@ -7,7 +7,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.os.Build import android.util.Log import androidx.annotation.RequiresApi @@ -44,16 +44,16 @@ import com.skyd.anivu.ext.saveTo import com.skyd.anivu.ext.toDecodedUrl import com.skyd.anivu.ext.toPercentage import com.skyd.anivu.ext.validateFileName -import com.skyd.anivu.model.bean.download.DownloadInfoBean -import com.skyd.anivu.model.bean.download.DownloadLinkUuidMapBean -import com.skyd.anivu.model.bean.download.PeerInfoBean +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean +import com.skyd.anivu.model.bean.download.bt.DownloadLinkUuidMapBean +import com.skyd.anivu.model.bean.download.bt.PeerInfoBean import com.skyd.anivu.model.preference.data.medialib.MediaLibLocationPreference import com.skyd.anivu.model.preference.transmission.SeedingWhenCompletePreference -import com.skyd.anivu.model.repository.download.DownloadManager -import com.skyd.anivu.model.repository.download.DownloadManagerIntent import com.skyd.anivu.model.repository.download.DownloadRepository +import com.skyd.anivu.model.repository.download.bt.BtDownloadManager +import com.skyd.anivu.model.repository.download.bt.BtDownloadManagerIntent import com.skyd.anivu.model.service.HttpService -import com.skyd.anivu.model.worker.download.DownloadTorrentWorker.Companion.DownloadWorkStarter +import com.skyd.anivu.model.worker.download.DownloadTorrentWorker.Companion.BtDownloadWorkStarter import com.skyd.anivu.ui.activity.MainActivity import com.skyd.anivu.ui.component.showToast import com.skyd.anivu.ui.screen.download.DOWNLOAD_SCREEN_DEEP_LINK_DATA @@ -123,7 +123,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : private fun initData(): Boolean = runBlocking { torrentLinkUuid = inputData.getString(TORRENT_LINK_UUID) ?: return@runBlocking false - DownloadManager.apply { + BtDownloadManager.apply { torrentLink = getDownloadLinkByUuid(torrentLinkUuid) ?: return@runBlocking false name = getDownloadName(link = torrentLink) progress = getDownloadProgress(link = torrentLink) ?: 0f @@ -155,7 +155,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : removeWorkerFromFlow(id.toString()) return Result.success( workDataOf( - STATE to (DownloadManager.getDownloadState(link = torrentLink) + STATE to (BtDownloadManager.getDownloadState(link = torrentLink) ?.ordinal ?: 0), TORRENT_LINK_UUID to torrentLinkUuid, ) @@ -190,14 +190,14 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : howToDownload(saveDir = saveDir) }.onFailure { it.printStackTrace() - pauseWorker(handle = null, state = DownloadInfoBean.DownloadState.ErrorPaused) + pauseWorker(handle = null, state = BtDownloadInfoBean.DownloadState.ErrorPaused) continuation.resumeWithException(it) } } private fun howToDownload(saveDir: File) = runBlocking { sessionManager.apply { - val lastSessionParams = DownloadManager + val lastSessionParams = BtDownloadManager .getSessionParams(link = torrentLink) val sessionParams = if (lastSessionParams == null) SessionParams() else SessionParams(lastSessionParams.data) @@ -213,39 +213,39 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : start(sessionParams) startDht() - if (DownloadManager.containsDownloadInfo(link = torrentLink)) { - DownloadManager.sendIntent( - DownloadManagerIntent.UpdateDownloadInfoRequestId( + if (BtDownloadManager.containsDownloadInfo(link = torrentLink)) { + BtDownloadManager.sendIntent( + BtDownloadManagerIntent.UpdateDownloadInfoRequestId( link = torrentLink, downloadRequestId = id.toString(), ) ) } - var newDownloadState: DownloadInfoBean.DownloadState? = null - when (DownloadManager.getDownloadState(link = torrentLink)) { + var newDownloadState: BtDownloadInfoBean.DownloadState? = null + when (BtDownloadManager.getDownloadState(link = torrentLink)) { null, // 重新下载 - DownloadInfoBean.DownloadState.Seeding, - DownloadInfoBean.DownloadState.SeedingPaused, - DownloadInfoBean.DownloadState.Completed -> { + BtDownloadInfoBean.DownloadState.Seeding, + BtDownloadInfoBean.DownloadState.SeedingPaused, + BtDownloadInfoBean.DownloadState.Completed -> { val resumeData = readResumeData(id.toString()) if (resumeData != null) { swig().async_add_torrent(resumeData) } - newDownloadState = DownloadInfoBean.DownloadState.Seeding + newDownloadState = BtDownloadInfoBean.DownloadState.Seeding } - DownloadInfoBean.DownloadState.Init -> { + BtDownloadInfoBean.DownloadState.Init -> { downloadByMagnetOrTorrent(torrentLink, saveDir) - newDownloadState = DownloadInfoBean.DownloadState.Downloading + newDownloadState = BtDownloadInfoBean.DownloadState.Downloading } - DownloadInfoBean.DownloadState.Downloading, - DownloadInfoBean.DownloadState.ErrorPaused, - DownloadInfoBean.DownloadState.StorageMovedFailed, - DownloadInfoBean.DownloadState.Paused -> { + BtDownloadInfoBean.DownloadState.Downloading, + BtDownloadInfoBean.DownloadState.ErrorPaused, + BtDownloadInfoBean.DownloadState.StorageMovedFailed, + BtDownloadInfoBean.DownloadState.Paused -> { downloadByMagnetOrTorrent(torrentLink, saveDir) - newDownloadState = DownloadInfoBean.DownloadState.Downloading + newDownloadState = BtDownloadInfoBean.DownloadState.Downloading } } updateDownloadState( @@ -339,7 +339,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : ForegroundInfo( notificationId, notification, - FOREGROUND_SERVICE_TYPE_SPECIAL_USE + FOREGROUND_SERVICE_TYPE_DATA_SYNC ) } else { ForegroundInfo(notificationId, notification) @@ -366,7 +366,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : // Download error pauseWorker( handle = alert.handle(), - state = DownloadInfoBean.DownloadState.ErrorPaused, + state = BtDownloadInfoBean.DownloadState.ErrorPaused, ) continuation.resumeWithException(RuntimeException(alert.message())) } @@ -376,7 +376,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : // 文件错误,例如存储空间已满 pauseWorker( handle = alert.handle(), - state = DownloadInfoBean.DownloadState.ErrorPaused, + state = BtDownloadInfoBean.DownloadState.ErrorPaused, ) continuation.resumeWithException(RuntimeException(alert.message())) } @@ -387,7 +387,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : updateDownloadStateAndSessionParams( link = torrentLink, sessionStateData = sessionManager.saveState() ?: byteArrayOf(), - downloadState = DownloadInfoBean.DownloadState.Seeding, + downloadState = BtDownloadInfoBean.DownloadState.Seeding, ) } @@ -403,7 +403,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : alert.handle().saveResumeData() pauseWorker( handle = alert.handle(), - state = DownloadInfoBean.DownloadState.StorageMovedFailed, + state = BtDownloadInfoBean.DownloadState.StorageMovedFailed, ) } @@ -417,7 +417,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : updateDownloadStateAndSessionParams( link = torrentLink, sessionStateData = sessionManager.saveState() ?: byteArrayOf(), - downloadState = DownloadInfoBean.DownloadState.Completed, + downloadState = BtDownloadInfoBean.DownloadState.Completed, ) // Do not seeding when complete if (!applicationContext.dataStore.getOrDefault(SeedingWhenCompletePreference)) { @@ -458,7 +458,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : updateDownloadStateAndSessionParams( link = torrentLink, sessionStateData = sessionManager.saveState() ?: byteArrayOf(), - downloadState = DownloadInfoBean.DownloadState.Seeding, + downloadState = BtDownloadInfoBean.DownloadState.Seeding, ) } description = alert.state.toDisplayString(context = applicationContext) @@ -504,8 +504,8 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : private fun pauseWorker( handle: TorrentHandle?, - state: DownloadInfoBean.DownloadState = getWhatPausedState( - runBlocking { DownloadManager.getDownloadState(link = torrentLink) } + state: BtDownloadInfoBean.DownloadState = getWhatPausedState( + runBlocking { BtDownloadManager.getDownloadState(link = torrentLink) } ) ) { if (!sessionManager.isRunning || sessionIsStopping) { @@ -572,12 +572,12 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : appContext, WorkerEntryPoint::class.java ) - fun interface DownloadWorkStarter { + fun interface BtDownloadWorkStarter { fun start(torrentLink: String, requestId: String?) } @Composable - fun rememberDownloadWorkStarter(): DownloadWorkStarter { + fun rememberBtDownloadWorkStarter(): BtDownloadWorkStarter { val context = LocalContext.current var currentTorrentLink: String? by rememberSaveable { mutableStateOf(null) } var currentRequestId: String? by rememberSaveable { mutableStateOf(null) } @@ -594,7 +594,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : } } return remember { - DownloadWorkStarter { torrentLink, requestId -> + BtDownloadWorkStarter { torrentLink, requestId -> currentTorrentLink = torrentLink currentRequestId = requestId storagePermissionState.launchPermissionRequest() @@ -602,7 +602,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : } } else { return remember { - DownloadWorkStarter { torrentLink, requestId -> + BtDownloadWorkStarter { torrentLink, requestId -> startWorker(context, torrentLink, requestId) } } @@ -621,10 +621,10 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : } coroutineScope.launch { var torrentLinkUuid = - DownloadManager.getDownloadUuidByLink(torrentLink) + BtDownloadManager.getDownloadUuidByLink(torrentLink) if (torrentLinkUuid == null) { torrentLinkUuid = UUID.randomUUID().toString() - DownloadManager.setDownloadLinkUuidMap( + BtDownloadManager.setDownloadLinkUuidMap( DownloadLinkUuidMapBean( link = torrentLink, uuid = torrentLinkUuid, @@ -675,7 +675,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : val workerState = getWorkInfoById(requestUuid).get()?.state if (workerState == null || workerState.isFinished) { coroutineScope.launch { - val state = DownloadManager.getDownloadState(link) + val state = BtDownloadManager.getDownloadState(link) updateDownloadState( link = link, downloadState = getWhatPausedState(state), diff --git a/app/src/main/java/com/skyd/anivu/model/worker/download/Util.kt b/app/src/main/java/com/skyd/anivu/model/worker/download/Util.kt index 40d8e4cc..2d24e7a7 100644 --- a/app/src/main/java/com/skyd/anivu/model/worker/download/Util.kt +++ b/app/src/main/java/com/skyd/anivu/model/worker/download/Util.kt @@ -11,8 +11,8 @@ import com.skyd.anivu.ext.getOrDefault import com.skyd.anivu.ext.ifNullOfBlank import com.skyd.anivu.ext.toDecodedUrl import com.skyd.anivu.ext.validateFileName -import com.skyd.anivu.model.bean.download.DownloadInfoBean -import com.skyd.anivu.model.bean.download.TorrentFileBean +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean +import com.skyd.anivu.model.bean.download.bt.TorrentFileBean import com.skyd.anivu.model.preference.proxy.ProxyHostnamePreference import com.skyd.anivu.model.preference.proxy.ProxyModePreference import com.skyd.anivu.model.preference.proxy.ProxyPasswordPreference @@ -20,8 +20,8 @@ import com.skyd.anivu.model.preference.proxy.ProxyPortPreference import com.skyd.anivu.model.preference.proxy.ProxyTypePreference import com.skyd.anivu.model.preference.proxy.ProxyUsernamePreference import com.skyd.anivu.model.preference.proxy.UseProxyPreference -import com.skyd.anivu.model.repository.download.DownloadManager -import com.skyd.anivu.model.repository.download.DownloadManagerIntent +import com.skyd.anivu.model.repository.download.bt.BtDownloadManager +import com.skyd.anivu.model.repository.download.bt.BtDownloadManagerIntent import kotlinx.coroutines.runBlocking import org.libtorrent4j.FileStorage import org.libtorrent4j.SettingsPack @@ -161,25 +161,25 @@ internal fun toSettingsPackProxyType(proxyType: String): settings_pack.proxy_typ } } -internal fun getWhatPausedState(oldState: DownloadInfoBean.DownloadState?) = +internal fun getWhatPausedState(oldState: BtDownloadInfoBean.DownloadState?) = when (oldState) { - DownloadInfoBean.DownloadState.Seeding, - DownloadInfoBean.DownloadState.Completed, - DownloadInfoBean.DownloadState.SeedingPaused -> { - DownloadInfoBean.DownloadState.SeedingPaused + BtDownloadInfoBean.DownloadState.Seeding, + BtDownloadInfoBean.DownloadState.Completed, + BtDownloadInfoBean.DownloadState.SeedingPaused -> { + BtDownloadInfoBean.DownloadState.SeedingPaused } else -> { - DownloadInfoBean.DownloadState.Paused + BtDownloadInfoBean.DownloadState.Paused } } internal fun updateDownloadState( link: String, - downloadState: DownloadInfoBean.DownloadState, + downloadState: BtDownloadInfoBean.DownloadState, ) { - DownloadManager.sendIntent( - DownloadManagerIntent.UpdateDownloadState( + BtDownloadManager.sendIntent( + BtDownloadManagerIntent.UpdateDownloadState( link = link, downloadState = downloadState, ) @@ -189,23 +189,23 @@ internal fun updateDownloadState( internal fun updateDownloadStateAndSessionParams( link: String, sessionStateData: ByteArray, - downloadState: DownloadInfoBean.DownloadState, + downloadState: BtDownloadInfoBean.DownloadState, ) { - DownloadManager.sendIntent( - DownloadManagerIntent.UpdateDownloadState( + BtDownloadManager.sendIntent( + BtDownloadManagerIntent.UpdateDownloadState( link = link, downloadState = downloadState, ) ) - DownloadManager.sendIntent( - DownloadManagerIntent.UpdateSessionParams( + BtDownloadManager.sendIntent( + BtDownloadManagerIntent.UpdateSessionParams( link = link, sessionStateData = sessionStateData, ) ) } internal fun updateDescriptionInfoToDb(link: String, description: String) { - DownloadManager.sendIntent( - DownloadManagerIntent.UpdateDownloadDescription( + BtDownloadManager.sendIntent( + BtDownloadManagerIntent.UpdateDownloadDescription( link = link, description = description, ) @@ -229,13 +229,13 @@ internal fun updateTorrentFilesToDb( ) } }.onFailure { it.printStackTrace() } - DownloadManager.sendIntent(DownloadManagerIntent.UpdateTorrentFiles(list)) + BtDownloadManager.sendIntent(BtDownloadManagerIntent.UpdateTorrentFiles(list)) } internal fun updateNameInfoToDb(link: String, name: String?) { if (name.isNullOrBlank()) return - DownloadManager.sendIntent( - DownloadManagerIntent.UpdateDownloadName( + BtDownloadManager.sendIntent( + BtDownloadManagerIntent.UpdateDownloadName( link = link, name = name, ) @@ -243,8 +243,8 @@ internal fun updateNameInfoToDb(link: String, name: String?) { } internal fun updateProgressInfoToDb(link: String, progress: Float) { - DownloadManager.sendIntent( - DownloadManagerIntent.UpdateDownloadProgress( + BtDownloadManager.sendIntent( + BtDownloadManagerIntent.UpdateDownloadProgress( link = link, progress = progress, ) @@ -252,8 +252,8 @@ internal fun updateProgressInfoToDb(link: String, progress: Float) { } internal fun updateSizeInfoToDb(link: String, size: Long) { - DownloadManager.sendIntent( - DownloadManagerIntent.UpdateDownloadSize( + BtDownloadManager.sendIntent( + BtDownloadManagerIntent.UpdateDownloadSize( link = link, size = size, ) @@ -272,12 +272,12 @@ internal fun addNewDownloadInfoToDbIfNotExists( downloadRequestId: String, ) = runBlocking { if (!forceAdd) { - val video = DownloadManager.getDownloadInfo(link = link) + val video = BtDownloadManager.getDownloadInfo(link = link) if (video != null) return@runBlocking } - DownloadManager.sendIntent( - DownloadManagerIntent.UpdateDownloadInfo( - DownloadInfoBean( + BtDownloadManager.sendIntent( + BtDownloadManagerIntent.UpdateDownloadInfo( + BtDownloadInfoBean( link = link, name = name.ifNullOfBlank { link.substringAfterLast('/') diff --git a/app/src/main/java/com/skyd/anivu/ui/activity/MainActivity.kt b/app/src/main/java/com/skyd/anivu/ui/activity/MainActivity.kt index 3a0bd8ae..80a394f9 100644 --- a/app/src/main/java/com/skyd/anivu/ui/activity/MainActivity.kt +++ b/app/src/main/java/com/skyd/anivu/ui/activity/MainActivity.kt @@ -135,6 +135,7 @@ import com.skyd.anivu.ui.screen.settings.transmission.TRANSMISSION_SCREEN_ROUTE import com.skyd.anivu.ui.screen.settings.transmission.TransmissionScreen import com.skyd.anivu.ui.screen.settings.transmission.proxy.PROXY_SCREEN_ROUTE import com.skyd.anivu.ui.screen.settings.transmission.proxy.ProxyScreen +import com.skyd.downloader.notification.NotificationConst import dagger.hilt.android.AndroidEntryPoint import kotlinx.serialization.json.Json @@ -177,7 +178,9 @@ class MainActivity : BaseComposeActivity() { private fun handleIntent(intent: Intent?, navController: NavController) { intent ?: return val data = intent.data - if (Intent.ACTION_VIEW == intent.action && data != null) { + if (intent.extras?.containsKey(NotificationConst.KEY_DOWNLOAD_REQUEST_ID) == true) { + openDownloadScreen(navController = navController) + } else if (Intent.ACTION_VIEW == intent.action && data != null) { val scheme = data.scheme var url: String? = null when (scheme) { diff --git a/app/src/main/java/com/skyd/anivu/ui/component/AniVuTextField.kt b/app/src/main/java/com/skyd/anivu/ui/component/AniVuTextField.kt index fcf25e1b..2cd2bc5c 100644 --- a/app/src/main/java/com/skyd/anivu/ui/component/AniVuTextField.kt +++ b/app/src/main/java/com/skyd/anivu/ui/component/AniVuTextField.kt @@ -52,6 +52,7 @@ fun AniVuTextField( enabled: Boolean = true, readOnly: Boolean = false, maxLines: Int = Int.MAX_VALUE, + singleLine: Boolean = maxLines == 1, style: AniVuTextFieldStyle = AniVuTextFieldStyle.toEnum(LocalTextFieldStyle.current), autoRequestFocus: Boolean = true, onValueChange: (String) -> Unit, @@ -136,7 +137,7 @@ fun AniVuTextField( visualTransformation = newVisualTransformation, placeholder = newPlaceholder, isError = errorMessage.isNotEmpty(), - singleLine = maxLines == 1, + singleLine = singleLine, trailingIcon = newTrailingIcon, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, @@ -154,7 +155,7 @@ fun AniVuTextField( visualTransformation = newVisualTransformation, placeholder = newPlaceholder, isError = errorMessage.isNotEmpty(), - singleLine = maxLines == 1, + singleLine = singleLine, trailingIcon = newTrailingIcon, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, diff --git a/app/src/main/java/com/skyd/anivu/ui/component/ClipboardTextField.kt b/app/src/main/java/com/skyd/anivu/ui/component/ClipboardTextField.kt index b3fcd704..fbd8b586 100644 --- a/app/src/main/java/com/skyd/anivu/ui/component/ClipboardTextField.kt +++ b/app/src/main/java/com/skyd/anivu/ui/component/ClipboardTextField.kt @@ -25,6 +25,7 @@ fun ClipboardTextField( value: String = "", label: String = "", maxLines: Int = Int.MAX_VALUE, + singleLine: Boolean = maxLines == 1, style: AniVuTextFieldStyle = AniVuTextFieldStyle.toEnum(LocalTextFieldStyle.current), autoRequestFocus: Boolean = true, onValueChange: (String) -> Unit = {}, @@ -45,6 +46,7 @@ fun ClipboardTextField( value = value, label = label, maxLines = maxLines, + singleLine = singleLine, style = style, autoRequestFocus = autoRequestFocus, onValueChange = onValueChange, diff --git a/app/src/main/java/com/skyd/anivu/ui/component/dialog/TextFieldDialog.kt b/app/src/main/java/com/skyd/anivu/ui/component/dialog/TextFieldDialog.kt index 4861f000..e32a480d 100644 --- a/app/src/main/java/com/skyd/anivu/ui/component/dialog/TextFieldDialog.kt +++ b/app/src/main/java/com/skyd/anivu/ui/component/dialog/TextFieldDialog.kt @@ -24,6 +24,7 @@ fun TextFieldDialog( visible: Boolean = true, readOnly: Boolean = false, maxLines: Int = Int.MAX_VALUE, + singleLine: Boolean = maxLines == 1, style: AniVuTextFieldStyle = AniVuTextFieldStyle.toEnum(LocalTextFieldStyle.current), icon: @Composable (() -> Unit)? = null, titleText: String? = null, @@ -46,6 +47,7 @@ fun TextFieldDialog( visible = visible, readOnly = readOnly, maxLines = maxLines, + singleLine = singleLine, style = style, icon = icon, title = if (titleText == null) null else { @@ -73,6 +75,7 @@ fun TextFieldDialog( visible: Boolean = true, readOnly: Boolean = false, maxLines: Int = Int.MAX_VALUE, + singleLine: Boolean = maxLines == 1, style: AniVuTextFieldStyle = AniVuTextFieldStyle.toEnum(LocalTextFieldStyle.current), icon: @Composable (() -> Unit)? = null, title: @Composable (() -> Unit)? = null, @@ -104,6 +107,7 @@ fun TextFieldDialog( readOnly = readOnly, value = value, maxLines = maxLines, + singleLine = singleLine, style = style, onValueChange = onValueChange, placeholder = placeholder, diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/about/license/LicenseScreen.kt b/app/src/main/java/com/skyd/anivu/ui/screen/about/license/LicenseScreen.kt index 7dc0fa00..e7293630 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/about/license/LicenseScreen.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/about/license/LicenseScreen.kt @@ -187,5 +187,10 @@ private fun getLicenseList(): List { license = "Apache-2.0", link = "https://github.com/mdewilde/opml-parser", ), + LicenseBean( + name = "Ketch", + license = "Apache-2.0", + link = "https://github.com/khushpanchal/Ketch", + ), ).sortedBy { it.name } } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/article/Article1Item.kt b/app/src/main/java/com/skyd/anivu/ui/screen/article/Article1Item.kt index 6c22779f..e4bd9eec 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/article/Article1Item.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/article/Article1Item.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -302,6 +303,7 @@ private fun Article1ItemContent( AniVuImage( modifier = Modifier .width(100.dp) + .fillMaxHeight() .heightIn(min = 70.dp, max = 120.dp) .layout { measurable, constraints -> if (constraints.maxHeight == Constraints.Infinity) { diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/article/enclosure/EnclosureBottomSheet.kt b/app/src/main/java/com/skyd/anivu/ui/screen/article/enclosure/EnclosureBottomSheet.kt index 9e9ffe60..8b74b828 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/article/enclosure/EnclosureBottomSheet.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/article/enclosure/EnclosureBottomSheet.kt @@ -39,9 +39,8 @@ import com.skyd.anivu.model.bean.LinkEnclosureBean import com.skyd.anivu.model.bean.article.ArticleWithEnclosureBean import com.skyd.anivu.model.bean.article.EnclosureBean import com.skyd.anivu.model.preference.rss.ParseLinkTagAsEnclosurePreference -import com.skyd.anivu.model.worker.download.DownloadTorrentWorker.Companion.rememberDownloadWorkStarter +import com.skyd.anivu.model.repository.download.DownloadStarter import com.skyd.anivu.model.worker.download.doIfMagnetOrTorrentLink -import com.skyd.anivu.model.worker.download.isTorrentMimetype import com.skyd.anivu.ui.activity.PlayActivity import com.skyd.anivu.ui.component.AniVuIconButton @@ -68,7 +67,7 @@ fun EnclosureBottomSheet( sheetState: SheetState = rememberModalBottomSheetState(), dataList: List, ) { - val downloadWorkStarter = rememberDownloadWorkStarter() + val context = LocalContext.current val onDownload: (Any) -> Unit = remember { { val url = when (it) { @@ -77,7 +76,11 @@ fun EnclosureBottomSheet( else -> null } if (!url.isNullOrBlank()) { - downloadWorkStarter.start(torrentLink = url, requestId = null) + DownloadStarter.download( + context = context, + url = url, + type = (it as? EnclosureBean)?.type, + ) } } } @@ -117,10 +120,7 @@ private fun EnclosureItem( onDownload: (EnclosureBean) -> Unit, ) { val context = LocalContext.current - val isMagnetOrTorrent = rememberSaveable { - data.url.startsWith("magnet:") || isTorrentMimetype(data.type) || - Regex("^(http|https)://.*\\.torrent$").matches(data.url) - } + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text( @@ -159,13 +159,11 @@ private fun EnclosureItem( contentDescription = stringResource(id = R.string.play), ) } - if (isMagnetOrTorrent) { - AniVuIconButton( - onClick = { onDownload(data) }, - imageVector = Icons.Outlined.Download, - contentDescription = stringResource(id = R.string.download), - ) - } + AniVuIconButton( + onClick = { onDownload(data) }, + imageVector = Icons.Outlined.Download, + contentDescription = stringResource(id = R.string.download), + ) } } diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/download/BtDownloadItem.kt b/app/src/main/java/com/skyd/anivu/ui/screen/download/BtDownloadItem.kt new file mode 100644 index 00000000..0e1becb9 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ui/screen/download/BtDownloadItem.kt @@ -0,0 +1,233 @@ +package com.skyd.anivu.ui.screen.download + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.CloudUpload +import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.skyd.anivu.R +import com.skyd.anivu.ext.fileSize +import com.skyd.anivu.ext.toPercentage +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean +import com.skyd.anivu.ui.component.AniVuIconButton + +@Composable +fun BtDownloadItem( + data: BtDownloadInfoBean, + onPause: (BtDownloadInfoBean) -> Unit, + onResume: (BtDownloadInfoBean) -> Unit, + onCancel: (BtDownloadInfoBean) -> Unit, +) { + val context = LocalContext.current + var description by remember { mutableStateOf(data.description) } + var pauseButtonIcon by remember { mutableStateOf(Icons.Outlined.Pause) } + var pauseButtonContentDescription by rememberSaveable { mutableStateOf("") } + var pauseButtonEnabled by rememberSaveable { mutableStateOf(true) } + var cancelButtonEnabled by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(data.downloadState) { + when (data.downloadState) { + BtDownloadInfoBean.DownloadState.Seeding -> { + pauseButtonEnabled = true + pauseButtonIcon = Icons.Outlined.Pause + pauseButtonContentDescription = context.getString(R.string.download_pause) + description = context.getString(R.string.download_seeding) + } + + BtDownloadInfoBean.DownloadState.Downloading -> { + pauseButtonEnabled = true + pauseButtonIcon = Icons.Outlined.Pause + pauseButtonContentDescription = context.getString(R.string.download_pause) + description = context.getString(R.string.downloading) + } + + BtDownloadInfoBean.DownloadState.StorageMovedFailed, + BtDownloadInfoBean.DownloadState.ErrorPaused -> { + pauseButtonEnabled = true + pauseButtonIcon = Icons.Outlined.Refresh + pauseButtonContentDescription = context.getString(R.string.download_retry) + description = context.getString(R.string.download_error_paused) + } + + BtDownloadInfoBean.DownloadState.SeedingPaused -> { + pauseButtonEnabled = true + pauseButtonIcon = Icons.Outlined.CloudUpload + pauseButtonContentDescription = + context.getString(R.string.download_click_to_seeding) + description = context.getString(R.string.download_paused) + } + + BtDownloadInfoBean.DownloadState.Paused -> { + pauseButtonEnabled = true + pauseButtonIcon = Icons.Outlined.PlayArrow + pauseButtonContentDescription = context.getString(R.string.download) + description = context.getString(R.string.download_paused) + } + + BtDownloadInfoBean.DownloadState.Init -> { + pauseButtonEnabled = false + pauseButtonIcon = Icons.Outlined.PlayArrow + pauseButtonContentDescription = context.getString(R.string.download) + description = context.getString(R.string.download_initializing) + } + + BtDownloadInfoBean.DownloadState.Completed -> { + pauseButtonEnabled = true + pauseButtonIcon = Icons.Outlined.CloudUpload + pauseButtonContentDescription = + context.getString(R.string.download_click_to_seeding) + description = context.getString(R.string.download_completed) + } + } + } + + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = data.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(6.dp)) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Row { + description?.let { desc -> + Text( + modifier = Modifier.padding(end = 12.dp), + text = desc, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = stringResource( + R.string.download_peer_count, + data.peerInfoList.count() + ), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Row { + Text( + modifier = Modifier.alignByBaseline(), + text = data.progress.toPercentage(), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = Modifier + .padding(start = 12.dp) + .alignByBaseline(), + text = stringResource( + R.string.download_download_payload_rate, + data.downloadPayloadRate.toLong().fileSize(context) + "/s" + ), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = Modifier + .padding(start = 12.dp) + .alignByBaseline(), + text = stringResource( + R.string.download_upload_payload_rate, + data.uploadPayloadRate.toLong().fileSize(context) + "/s" + ), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + AniVuIconButton( + enabled = pauseButtonEnabled, + onClick = { + when (data.downloadState) { + BtDownloadInfoBean.DownloadState.Seeding, + BtDownloadInfoBean.DownloadState.Downloading -> onPause(data) + + BtDownloadInfoBean.DownloadState.SeedingPaused, + BtDownloadInfoBean.DownloadState.Paused -> onResume(data) + + BtDownloadInfoBean.DownloadState.Completed, + BtDownloadInfoBean.DownloadState.StorageMovedFailed, + BtDownloadInfoBean.DownloadState.ErrorPaused -> onResume(data) + + else -> Unit + } + }, + imageVector = pauseButtonIcon, + contentDescription = pauseButtonContentDescription, + ) + AniVuIconButton( + enabled = cancelButtonEnabled, + onClick = { + onCancel(data) + pauseButtonEnabled = false + cancelButtonEnabled = false + }, + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(id = R.string.delete) + ) + } + ProgressIndicator( + modifier = Modifier + .padding(top = 6.dp) + .fillMaxWidth(), + data = data, + ) + } +} + +@Composable +private fun ProgressIndicator( + modifier: Modifier = Modifier, + data: BtDownloadInfoBean +) { + when (data.downloadState) { + BtDownloadInfoBean.DownloadState.Downloading, + BtDownloadInfoBean.DownloadState.StorageMovedFailed, + BtDownloadInfoBean.DownloadState.ErrorPaused, + BtDownloadInfoBean.DownloadState.Paused -> { + LinearProgressIndicator(modifier = modifier, progress = { data.progress }) + } + + BtDownloadInfoBean.DownloadState.Init -> LinearProgressIndicator(modifier = modifier) + BtDownloadInfoBean.DownloadState.Seeding, + BtDownloadInfoBean.DownloadState.SeedingPaused, + BtDownloadInfoBean.DownloadState.Completed -> LinearProgressIndicator( + modifier = modifier, + progress = { 1f }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadItem.kt b/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadItem.kt index a1f988e3..22337a98 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadItem.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadItem.kt @@ -1,5 +1,6 @@ package com.skyd.anivu.ui.screen.download +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -8,12 +9,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Close -import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,83 +31,70 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.skyd.anivu.R import com.skyd.anivu.ext.fileSize -import com.skyd.anivu.ext.toPercentage import com.skyd.anivu.model.bean.download.DownloadInfoBean import com.skyd.anivu.ui.component.AniVuIconButton +import com.skyd.downloader.Status @Composable fun DownloadItem( data: DownloadInfoBean, onPause: (DownloadInfoBean) -> Unit, onResume: (DownloadInfoBean) -> Unit, - onCancel: (DownloadInfoBean) -> Unit, + onRetry: (DownloadInfoBean) -> Unit, + onDelete: (DownloadInfoBean) -> Unit, ) { val context = LocalContext.current - var description by remember { mutableStateOf(data.description) } + var description by remember { mutableStateOf(context.getString(R.string.download_initializing)) } var pauseButtonIcon by remember { mutableStateOf(Icons.Outlined.Pause) } var pauseButtonContentDescription by rememberSaveable { mutableStateOf("") } var pauseButtonEnabled by rememberSaveable { mutableStateOf(true) } var cancelButtonEnabled by rememberSaveable { mutableStateOf(true) } - LaunchedEffect(data.downloadState) { - when (data.downloadState) { - DownloadInfoBean.DownloadState.Seeding -> { - pauseButtonEnabled = true - pauseButtonIcon = Icons.Outlined.Pause - pauseButtonContentDescription = context.getString(R.string.download_pause) - description = context.getString(R.string.download_seeding) - } - - DownloadInfoBean.DownloadState.Downloading -> { + LaunchedEffect(data.status) { + when (data.status) { + Status.Downloading -> { pauseButtonEnabled = true pauseButtonIcon = Icons.Outlined.Pause pauseButtonContentDescription = context.getString(R.string.download_pause) description = context.getString(R.string.downloading) } - DownloadInfoBean.DownloadState.StorageMovedFailed, - DownloadInfoBean.DownloadState.ErrorPaused -> { + Status.Failed -> { pauseButtonEnabled = true pauseButtonIcon = Icons.Outlined.Refresh pauseButtonContentDescription = context.getString(R.string.download_retry) description = context.getString(R.string.download_error_paused) } - DownloadInfoBean.DownloadState.SeedingPaused -> { - pauseButtonEnabled = true - pauseButtonIcon = Icons.Outlined.CloudUpload - pauseButtonContentDescription = - context.getString(R.string.download_click_to_seeding) - description = context.getString(R.string.download_paused) - } - - DownloadInfoBean.DownloadState.Paused -> { + Status.Paused -> { pauseButtonEnabled = true pauseButtonIcon = Icons.Outlined.PlayArrow pauseButtonContentDescription = context.getString(R.string.download) description = context.getString(R.string.download_paused) } - DownloadInfoBean.DownloadState.Init -> { + Status.Init, + Status.Started, + Status.Queued -> { pauseButtonEnabled = false pauseButtonIcon = Icons.Outlined.PlayArrow pauseButtonContentDescription = context.getString(R.string.download) description = context.getString(R.string.download_initializing) } - DownloadInfoBean.DownloadState.Completed -> { - pauseButtonEnabled = true - pauseButtonIcon = Icons.Outlined.CloudUpload - pauseButtonContentDescription = - context.getString(R.string.download_click_to_seeding) + Status.Success -> { + pauseButtonEnabled = false + pauseButtonIcon = Icons.Outlined.PlayArrow + pauseButtonContentDescription = context.getString(R.string.delete) description = context.getString(R.string.download_completed) } + } } Column(modifier = Modifier.padding(16.dp)) { Text( - text = data.name, + text = data.fileName, style = MaterialTheme.typography.bodyMedium, maxLines = 4, overflow = TextOverflow.Ellipsis, @@ -114,21 +102,10 @@ fun DownloadItem( Spacer(modifier = Modifier.height(6.dp)) Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { - Row { - description?.let { desc -> - Text( - modifier = Modifier.padding(end = 12.dp), - text = desc, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + description.let { desc -> Text( - text = stringResource( - R.string.download_peer_count, - data.peerInfoList.count() - ), + modifier = Modifier.padding(end = 12.dp), + text = desc, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -138,7 +115,7 @@ fun DownloadItem( Row { Text( modifier = Modifier.alignByBaseline(), - text = data.progress.toPercentage(), + text = "${if (data.totalBytes == 0L) 0 else (data.downloadedBytes * 100 / data.totalBytes)}%", style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -149,19 +126,7 @@ fun DownloadItem( .alignByBaseline(), text = stringResource( R.string.download_download_payload_rate, - data.downloadPayloadRate.toLong().fileSize(context) + "/s" - ), - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - modifier = Modifier - .padding(start = 12.dp) - .alignByBaseline(), - text = stringResource( - R.string.download_upload_payload_rate, - data.uploadPayloadRate.toLong().fileSize(context) + "/s" + (data.speedInBytePerMs * 1000).toLong().fileSize(context) + "/s" ), style = MaterialTheme.typography.labelMedium, maxLines = 1, @@ -172,18 +137,26 @@ fun DownloadItem( AniVuIconButton( enabled = pauseButtonEnabled, onClick = { - when (data.downloadState) { - DownloadInfoBean.DownloadState.Seeding, - DownloadInfoBean.DownloadState.Downloading -> onPause(data) + when (data.status) { + Status.Downloading -> { + onPause(data) + pauseButtonEnabled = false + } - DownloadInfoBean.DownloadState.SeedingPaused, - DownloadInfoBean.DownloadState.Paused -> onResume(data) + Status.Paused -> { + onResume(data) + pauseButtonEnabled = false + } - DownloadInfoBean.DownloadState.Completed, - DownloadInfoBean.DownloadState.StorageMovedFailed, - DownloadInfoBean.DownloadState.ErrorPaused -> onResume(data) + Status.Failed -> { + onRetry(data) + pauseButtonEnabled = false + } - else -> Unit + Status.Started, + Status.Init, + Status.Queued, + Status.Success -> Unit } }, imageVector = pauseButtonIcon, @@ -192,7 +165,7 @@ fun DownloadItem( AniVuIconButton( enabled = cancelButtonEnabled, onClick = { - onCancel(data) + onDelete(data) pauseButtonEnabled = false cancelButtonEnabled = false }, @@ -214,18 +187,26 @@ private fun ProgressIndicator( modifier: Modifier = Modifier, data: DownloadInfoBean ) { - when (data.downloadState) { - DownloadInfoBean.DownloadState.Downloading, - DownloadInfoBean.DownloadState.StorageMovedFailed, - DownloadInfoBean.DownloadState.ErrorPaused, - DownloadInfoBean.DownloadState.Paused -> { - LinearProgressIndicator(modifier = modifier, progress = { data.progress }) + when (data.status) { + Status.Init, + Status.Downloading, + Status.Paused, + Status.Failed -> { + val animatedProgress by animateFloatAsState( + targetValue = if (data.totalBytes == 0L) 0f else data.downloadedBytes.toFloat() / data.totalBytes, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "progressIndicatorAnimatedProgress" + ) + LinearProgressIndicator( + modifier = modifier, + progress = { animatedProgress }, + ) } - DownloadInfoBean.DownloadState.Init -> LinearProgressIndicator(modifier = modifier) - DownloadInfoBean.DownloadState.Seeding, - DownloadInfoBean.DownloadState.SeedingPaused, - DownloadInfoBean.DownloadState.Completed -> LinearProgressIndicator( + Status.Started, + Status.Queued -> LinearProgressIndicator(modifier = modifier) + + Status.Success -> LinearProgressIndicator( modifier = modifier, progress = { 1f }, ) diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadPartialStateChange.kt b/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadPartialStateChange.kt index c356a75a..3158368c 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadPartialStateChange.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadPartialStateChange.kt @@ -1,6 +1,7 @@ package com.skyd.anivu.ui.screen.download import com.skyd.anivu.model.bean.download.DownloadInfoBean +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean internal sealed interface DownloadPartialStateChange { @@ -17,7 +18,10 @@ internal sealed interface DownloadPartialStateChange { override fun reduce(oldState: DownloadState): DownloadState { return when (this) { is Success -> oldState.copy( - downloadListState = DownloadListState.Success(downloadInfoBeanList = downloadInfoBeanList), + downloadListState = DownloadListState.Success( + downloadInfoBeanList = downloadInfoBeanList, + btDownloadInfoBeanList = btDownloadInfoBeanList, + ), loadingDialog = false, ) @@ -33,7 +37,11 @@ internal sealed interface DownloadPartialStateChange { } } - data class Success(val downloadInfoBeanList: List) : DownloadListResult + data class Success( + val downloadInfoBeanList: List, + val btDownloadInfoBeanList: List, + ) : DownloadListResult + data class Failed(val msg: String) : DownloadListResult data object Loading : DownloadListResult } diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadScreen.kt b/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadScreen.kt index 5b88d7b7..7ca1bac8 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadScreen.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadScreen.kt @@ -1,17 +1,27 @@ package com.skyd.anivu.ui.screen.download import android.os.Bundle +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerScope +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Download import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -25,7 +35,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -34,12 +46,12 @@ import androidx.navigation.navOptions import com.skyd.anivu.R import com.skyd.anivu.base.mvi.getDispatcher import com.skyd.anivu.ext.navigate -import com.skyd.anivu.ext.plus -import com.skyd.anivu.ext.showSnackbar import com.skyd.anivu.model.bean.download.DownloadInfoBean +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean +import com.skyd.anivu.model.repository.download.DownloadManager +import com.skyd.anivu.model.repository.download.DownloadStarter import com.skyd.anivu.model.worker.download.DownloadTorrentWorker -import com.skyd.anivu.model.worker.download.DownloadTorrentWorker.Companion.rememberDownloadWorkStarter -import com.skyd.anivu.model.worker.download.doIfMagnetOrTorrentLink +import com.skyd.anivu.model.worker.download.DownloadTorrentWorker.Companion.rememberBtDownloadWorkStarter import com.skyd.anivu.ui.component.AniVuFloatingActionButton import com.skyd.anivu.ui.component.AniVuTopBar import com.skyd.anivu.ui.component.AniVuTopBarStyle @@ -47,6 +59,7 @@ import com.skyd.anivu.ui.component.CircularProgressPlaceholder import com.skyd.anivu.ui.component.EmptyPlaceholder import com.skyd.anivu.ui.component.deeplink.DeepLinkData import com.skyd.anivu.ui.component.dialog.TextFieldDialog +import kotlinx.coroutines.launch const val DOWNLOAD_SCREEN_ROUTE = "downloadScreen" @@ -106,15 +119,54 @@ fun DownloadScreen(downloadLink: String? = null, viewModel: DownloadViewModel = DownloadListState.Init, DownloadListState.Loading -> CircularProgressPlaceholder(contentPadding = paddingValues) - is DownloadListState.Success -> DownloadList( - downloadInfoBeanList = downloadListState.downloadInfoBeanList, - nestedScrollConnection = scrollBehavior.nestedScrollConnection, - contentPadding = paddingValues + PaddingValues(bottom = fabHeight + 16.dp) - ) + is DownloadListState.Success -> { + val listContentPadding = PaddingValues( + bottom = fabHeight + 16.dp, + start = paddingValues.calculateStartPadding(LocalLayoutDirection.current), + end = paddingValues.calculateEndPadding(LocalLayoutDirection.current), + ) + val nestedScrollConnection = scrollBehavior.nestedScrollConnection + val pagerState = rememberPagerState(pageCount = { 2 }) + val tabs = listOf Unit>>( + stringResource(R.string.download_screen_download_tasks) to { + DownloadList( + downloadInfoBeanList = downloadListState.downloadInfoBeanList, + nestedScrollConnection = nestedScrollConnection, + contentPadding = listContentPadding, + ) + }, + stringResource(R.string.download_screen_bt_tasks) to { + BtDownloadList( + btDownloadInfoBeanList = downloadListState.btDownloadInfoBeanList, + nestedScrollConnection = nestedScrollConnection, + contentPadding = listContentPadding, + ) + } + ) + Column(modifier = Modifier.padding(top = paddingValues.calculateTopPadding())) { + PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { + tabs.forEachIndexed { index, (title, _) -> + Tab( + selected = pagerState.currentPage == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, + text = { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + HorizontalPager(state = pagerState) { index -> + tabs[index].second.invoke(this) + } + } + } } } - val downloadWorkStarter = rememberDownloadWorkStarter() TextFieldDialog( visible = openLinkDialog != null, icon = { Icon(imageVector = Icons.Outlined.Download, contentDescription = null) }, @@ -125,17 +177,7 @@ fun DownloadScreen(downloadLink: String? = null, viewModel: DownloadViewModel = onDismissRequest = { openLinkDialog = null }, onConfirm = { text -> openLinkDialog = null - doIfMagnetOrTorrentLink( - link = text, - onMagnet = { downloadWorkStarter.start(torrentLink = it, requestId = null) }, - onTorrent = { downloadWorkStarter.start(torrentLink = it, requestId = null) }, - onUnsupported = { - snackbarHostState.showSnackbar( - scope = scope, - message = context.getString(R.string.download_screen_unsupported_link) - ) - }, - ) + DownloadStarter.download(context = context, url = text) }, ) } @@ -148,17 +190,53 @@ private fun DownloadList( ) { if (downloadInfoBeanList.isNotEmpty()) { val context = LocalContext.current - val downloadWorkStarter = rememberDownloadWorkStarter() LazyColumn( - modifier = Modifier.nestedScroll(nestedScrollConnection), + modifier = Modifier + .fillMaxHeight() + .nestedScroll(nestedScrollConnection), contentPadding = contentPadding, ) { itemsIndexed( items = downloadInfoBeanList, - key = { _, item -> item.link }, + key = { _, item -> item.id }, ) { index, item -> + val downloadManager = remember { DownloadManager.getInstance(context) } if (index > 0) HorizontalDivider() DownloadItem( + data = item, + onPause = { downloadManager.pause(item.id) }, + onResume = { downloadManager.resume(item.id) }, + onRetry = { downloadManager.retry(item.id) }, + onDelete = { downloadManager.delete(item.id) }, + ) + } + } + } else { + EmptyPlaceholder(contentPadding = contentPadding) + } +} + +@Composable +private fun BtDownloadList( + btDownloadInfoBeanList: List, + nestedScrollConnection: NestedScrollConnection, + contentPadding: PaddingValues, +) { + if (btDownloadInfoBeanList.isNotEmpty()) { + val context = LocalContext.current + val btDownloadWorkStarter = rememberBtDownloadWorkStarter() + LazyColumn( + modifier = Modifier + .fillMaxHeight() + .nestedScroll(nestedScrollConnection), + contentPadding = contentPadding, + ) { + itemsIndexed( + items = btDownloadInfoBeanList, + key = { _, item -> item.link }, + ) { index, item -> + if (index > 0) HorizontalDivider() + BtDownloadItem( data = item, onPause = { DownloadTorrentWorker.pause( @@ -168,7 +246,7 @@ private fun DownloadList( ) }, onResume = { video -> - downloadWorkStarter.start( + btDownloadWorkStarter.start( torrentLink = video.link, requestId = video.downloadRequestId, ) diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadState.kt b/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadState.kt index e5a1a004..79672369 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadState.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadState.kt @@ -2,6 +2,7 @@ package com.skyd.anivu.ui.screen.download import com.skyd.anivu.base.mvi.MviViewState import com.skyd.anivu.model.bean.download.DownloadInfoBean +import com.skyd.anivu.model.bean.download.bt.BtDownloadInfoBean data class DownloadState( val downloadListState: DownloadListState, @@ -16,7 +17,11 @@ data class DownloadState( } sealed interface DownloadListState { - data class Success(val downloadInfoBeanList: List) : DownloadListState + data class Success( + val downloadInfoBeanList: List, + val btDownloadInfoBeanList: List, + ) : DownloadListState + data object Init : DownloadListState data object Loading : DownloadListState data class Failed(val msg: String) : DownloadListState diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadViewModel.kt b/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadViewModel.kt index efd55fb3..eebb8cbc 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadViewModel.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/download/DownloadViewModel.kt @@ -8,10 +8,10 @@ import com.skyd.anivu.model.repository.download.DownloadRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.flatMapConcat -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.take @@ -41,8 +41,14 @@ class DownloadViewModel @Inject constructor( private fun Flow.toReadPartialStateChangeFlow(): Flow { return merge( filterIsInstance().flatMapConcat { - downloadRepo.requestDownloadingVideos().map { - DownloadPartialStateChange.DownloadListResult.Success(downloadInfoBeanList = it) + combine( + downloadRepo.requestDownloadTasksList(), + downloadRepo.requestBtDownloadTasksList(), + ) { downloadTasks, btDownloadTasks -> + DownloadPartialStateChange.DownloadListResult.Success( + downloadInfoBeanList = downloadTasks, + btDownloadInfoBeanList = btDownloadTasks, + ) }.startWith(DownloadPartialStateChange.DownloadListResult.Loading) .catchMap { DownloadPartialStateChange.DownloadListResult.Failed(it.message.toString()) } }, diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/media/list/EditMediaSheet.kt b/app/src/main/java/com/skyd/anivu/ui/screen/media/list/EditMediaSheet.kt index d0539826..4677862f 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/media/list/EditMediaSheet.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/media/list/EditMediaSheet.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.DriveFileRenameOutline import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -34,44 +35,51 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.skyd.anivu.R import com.skyd.anivu.ext.fileSize import com.skyd.anivu.ext.openWith import com.skyd.anivu.ext.toUri +import com.skyd.anivu.model.bean.MediaBean import com.skyd.anivu.model.bean.MediaGroupBean import com.skyd.anivu.model.bean.MediaGroupBean.Companion.isDefaultGroup import com.skyd.anivu.ui.component.dialog.DeleteWarningDialog +import com.skyd.anivu.ui.component.dialog.TextFieldDialog import com.skyd.anivu.ui.screen.feed.SheetChip import java.io.File @Composable fun EditMediaSheet( onDismissRequest: () -> Unit, - file: File, + mediaBean: MediaBean, currentGroup: MediaGroupBean, groups: List, - onDelete: (File) -> Unit, + onRename: (MediaBean, String) -> Unit, + onDelete: (MediaBean) -> Unit, onGroupChange: (MediaGroupBean) -> Unit, openCreateGroupDialog: () -> Unit, ) { val context = LocalContext.current ModalBottomSheet(onDismissRequest = onDismissRequest) { + var openRenameInputDialog by rememberSaveable { mutableStateOf(null) } + Column( modifier = Modifier .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp) ) { - InfoArea(file = file) + InfoArea(file = mediaBean.file) Spacer(modifier = Modifier.height(20.dp)) // Options OptionArea( - onOpenWith = { file.toUri(context).openWith(context) }, + onOpenWith = { mediaBean.file.toUri(context).openWith(context) }, + onRenameClicked = { openRenameInputDialog = mediaBean.file.name }, onDelete = { - onDelete(file) + onDelete(mediaBean) onDismissRequest() }, ) @@ -86,6 +94,22 @@ fun EditMediaSheet( ) Spacer(modifier = Modifier.height(16.dp)) } + + if (openRenameInputDialog != null) { + TextFieldDialog( + titleText = stringResource(R.string.rename), + value = openRenameInputDialog.orEmpty(), + onValueChange = { openRenameInputDialog = it }, + singleLine = false, + onConfirm = { + onRename(mediaBean, it.replace(Regex("[\\n\\r]"), "")) + openRenameInputDialog = null + onDismissRequest() + }, + imeAction = ImeAction.Done, + onDismissRequest = { openRenameInputDialog = null } + ) + } } } @@ -124,6 +148,7 @@ private fun InfoArea(file: File) { internal fun OptionArea( deleteWarningText: String = stringResource(id = R.string.media_screen_delete_file_warning), onOpenWith: (() -> Unit)? = null, + onRenameClicked: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, ) { var openDeleteWarningDialog by rememberSaveable { mutableStateOf(false) } @@ -147,6 +172,13 @@ internal fun OptionArea( onClick = onOpenWith, ) } + if (onRenameClicked != null) { + SheetChip( + icon = Icons.Outlined.DriveFileRenameOutline, + text = stringResource(id = R.string.rename), + onClick = onRenameClicked, + ) + } if (onDelete != null) { SheetChip( icon = Icons.Outlined.Delete, diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaList.kt b/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaList.kt index bae9e06f..980ac5eb 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaList.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaList.kt @@ -35,8 +35,8 @@ import com.skyd.anivu.base.mvi.getDispatcher import com.skyd.anivu.ext.activity import com.skyd.anivu.ext.plus import com.skyd.anivu.ext.toUri -import com.skyd.anivu.model.bean.MediaGroupBean import com.skyd.anivu.model.bean.MediaBean +import com.skyd.anivu.model.bean.MediaGroupBean import com.skyd.anivu.ui.activity.PlayActivity import com.skyd.anivu.ui.component.CircularProgressPlaceholder import com.skyd.anivu.ui.component.EmptyPlaceholder @@ -116,6 +116,9 @@ internal fun MediaList( path = it.file.path ) }, + onRename = { oldMedia, newName -> + dispatch(MediaListIntent.RenameFile(oldMedia.file, newName)) + }, onRemove = { dispatch(MediaListIntent.DeleteFile(it.file)) }, contentPadding = innerPadding + contentPadding + fabPadding, ) @@ -152,6 +155,7 @@ private fun MediaList( groupInfo: GroupInfo?, onPlay: (MediaBean) -> Unit, onOpenDir: (MediaBean) -> Unit, + onRename: (MediaBean, String) -> Unit, onRemove: (MediaBean) -> Unit, contentPadding: PaddingValues = PaddingValues(0.dp), ) { @@ -184,11 +188,12 @@ private fun MediaList( val videoBean = openEditMediaDialog!! EditMediaSheet( onDismissRequest = { openEditMediaDialog = null }, - file = videoBean.file, + mediaBean = videoBean, currentGroup = groupInfo!!.group, groups = groups, + onRename = onRename, onDelete = { - onRemove(videoBean) + onRemove(it) openEditMediaDialog = null }, onGroupChange = { diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaListIntent.kt b/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaListIntent.kt index 15e13e09..b551dc61 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaListIntent.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaListIntent.kt @@ -10,4 +10,5 @@ sealed interface MediaListIntent : MviIntent { data class Refresh(val path: String?, val group: MediaGroupBean?) : MediaListIntent data class DeleteFile(val file: File) : MediaListIntent + data class RenameFile(val file: File, val newName: String) : MediaListIntent } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaListPartialStateChange.kt b/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaListPartialStateChange.kt index 4dac41e6..22f2b93d 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaListPartialStateChange.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaListPartialStateChange.kt @@ -1,8 +1,8 @@ package com.skyd.anivu.ui.screen.media.list import androidx.compose.ui.util.fastFirstOrNull -import com.skyd.anivu.model.bean.MediaGroupBean import com.skyd.anivu.model.bean.MediaBean +import com.skyd.anivu.model.bean.MediaGroupBean import java.io.File @@ -75,4 +75,36 @@ internal sealed interface MediaListPartialStateChange { data class Success(val file: File) : DeleteFileResult data class Failed(val msg: String) : DeleteFileResult } + + sealed interface RenameFileResult : MediaListPartialStateChange { + override fun reduce(oldState: MediaListState): MediaListState { + return when (this) { + is Success -> { + val listState = oldState.listState + oldState.copy( + listState = if (listState is ListState.Success) { + ListState.Success(listState.list.toMutableList().apply { + val oldIndex = indexOfFirst { it.file == oldFile } + if (oldIndex in indices) { + val old = get(oldIndex) + removeAt(oldIndex) + add(oldIndex, old.copy(file = newFile)) + } + }) + } else { + listState + }, + loadingDialog = false, + ) + } + + is Failed -> oldState.copy( + loadingDialog = false, + ) + } + } + + data class Success(val oldFile: File, val newFile: File) : RenameFileResult + data class Failed(val msg: String) : RenameFileResult + } } diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaListViewModel.kt b/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaListViewModel.kt index cbf61db7..ffceb2c8 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaListViewModel.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaListViewModel.kt @@ -82,6 +82,14 @@ class MediaListViewModel @Inject constructor( }.startWith(MediaListPartialStateChange.LoadingDialog.Show) .catchMap { MediaListPartialStateChange.DeleteFileResult.Failed(it.message.toString()) } }, + filterIsInstance().flatMapConcat { intent -> + mediaRepo.renameFile(intent.file, intent.newName).map { newFile -> + MediaListPartialStateChange.RenameFileResult.Success( + oldFile = intent.file, newFile = newFile!! + ) + }.startWith(MediaListPartialStateChange.LoadingDialog.Show) + .catchMap { MediaListPartialStateChange.RenameFileResult.Failed(it.message.toString()) } + }, ) } } \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d62dd10f..b249964d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -187,7 +187,7 @@ 复制日志 所有文章 新建下载 - 磁力链 / 种子链接 + 下载链接 不支持的链接 相对日期 完整日期 @@ -312,7 +312,9 @@ 缓存 最大前向缓存大小 最大保留缓存大小 - 下载任务 + BT 下载任务 + 下载任务 + AniVu 下载任务通知 快,但不精确 精确,但慢 进度调整选项 @@ -350,6 +352,11 @@ 配置文章更新提醒规则 文章通知 名称 + 重命名 + 下载任务 + BT 任务 + 继续下载 + 取消下载 已读 %d 项 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 40ff0953..6dc3654c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -186,7 +186,7 @@ 複製日誌 所有文章 新增 - 磁力 / 種子連結 + 下載連結 不支援的連結 相對 完整 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ed3612aa..a5f15eee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -194,7 +194,7 @@ Copy log All articles Add - Magnet / Torrent link + Download link Unsupported link Relative Full @@ -319,7 +319,9 @@ Cache Max cache size Max back cache size - Download task + BT download task + Download task + AniVu download task notification Fast, but not precise Precise, but slow Seek option @@ -357,6 +359,11 @@ Configure article update notification rules Article notification Name + Rename + Download Tasks + BT Tasks + Resume + Cancel Read %d item Read %d items diff --git a/build.gradle.kts b/build.gradle.kts index 41665a81..c06dd05e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,4 +7,5 @@ plugins { alias(libs.plugins.kotlin.serialization) alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.android.library) apply false } \ No newline at end of file diff --git a/doc/image/en/ic_download_screen.jpg b/doc/image/en/ic_download_screen.jpg index 4b9fca537ad355c7fa7c80bbc067b0baabd343a6..34a87d67f073fa3a0b97e0d0f2be9dfc9f28d138 100644 GIT binary patch literal 64697 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 diff --git a/doc/readme/README-zh-rCN.md b/doc/readme/README-zh-rCN.md index ee125cab..31e90681 100644 --- a/doc/readme/README-zh-rCN.md +++ b/doc/readme/README-zh-rCN.md @@ -41,7 +41,7 @@ 1. **订阅** RSS、**更新** RSS、**阅读** RSS 2. **自动更新 RSS 订阅** -3. **下载** RSS 文章中的 **BT 种子或磁力链接**附件(enclosure 标签) +3. **下载** RSS 文章中的附件(enclosure 标签),支持 **BT 种子或磁力链接** 4. 已下载**文件做种** 5. **播放**媒体附件或已下载的**媒体文件** 6. **更改播放速度**、设置**音轨**、**字幕轨道**等 @@ -50,9 +50,10 @@ 9. 支持**搜索已获取的 RSS 订阅或文章** 10. **播放**手机中的**其他视频** 11. 支持**自定义 MPV** 播放器 -12. 支持通过 **OPML 导入导出**订阅 -13. 支持**深色模式** -14. ...... +12. 支持 Android 原生**画中画** +13. 支持通过 **OPML 导入导出**订阅 +14. 支持**深色模式** +15. ...... ## 🤩应用截图 diff --git a/downloader/.gitignore b/downloader/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/downloader/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/downloader/build.gradle.kts b/downloader/build.gradle.kts new file mode 100644 index 00000000..7ece7341 --- /dev/null +++ b/downloader/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.skyd.downloader" + compileSdk = 34 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + implementation(libs.retrofit2) + implementation(libs.kotlinx.serialization.json) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/downloader/consumer-rules.pro b/downloader/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/downloader/proguard-rules.pro b/downloader/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/downloader/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/downloader/src/androidTest/java/com/skyd/downloader/ExampleInstrumentedTest.kt b/downloader/src/androidTest/java/com/skyd/downloader/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..380e1222 --- /dev/null +++ b/downloader/src/androidTest/java/com/skyd/downloader/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.skyd.downloader + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.skyd.downloader.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/downloader/src/main/AndroidManifest.xml b/downloader/src/main/AndroidManifest.xml new file mode 100644 index 00000000..13381bd0 --- /dev/null +++ b/downloader/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/downloader/src/main/java/com/skyd/downloader/Downloader.kt b/downloader/src/main/java/com/skyd/downloader/Downloader.kt new file mode 100644 index 00000000..07fb62e3 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/Downloader.kt @@ -0,0 +1,194 @@ +package com.skyd.downloader + +import android.app.Application +import androidx.work.WorkManager +import com.skyd.downloader.db.DatabaseInstance +import com.skyd.downloader.db.DownloadEntity +import com.skyd.downloader.download.DownloadManager +import com.skyd.downloader.download.DownloadRequest +import com.skyd.downloader.download.DownloadTask.Companion.ETAG_HEADER +import com.skyd.downloader.net.RetrofitInstance +import com.skyd.downloader.util.FileUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +class Downloader private constructor( + application: Application, + val notificationConfig: NotificationConfig, +) { + private val downloadManager = DownloadManager( + context = application, + downloadDao = DatabaseInstance.getInstance(application).downloadDao(), + workManager = WorkManager.getInstance(application), + notificationConfig = notificationConfig, + ) + + /** + * Download the content + * + * @param url Download url of the content + * @param path Download path to store the downloaded file + * @param fileName Name of the file to be downloaded + * @return Unique Download ID associated with current download + */ + fun download( + url: String, + path: String, + fileName: String = FileUtil.getFileNameFromUrl(url), + ): Int { + require(url.isNotEmpty() && path.isNotEmpty() && fileName.isNotEmpty()) { + "Missing ${if (url.isEmpty()) "url" else if (path.isEmpty()) "path" else "fileName"}" + } + val downloadRequest = DownloadRequest( + url = url, + path = path, + fileName = fileName, + ) + downloadManager.downloadAsync(downloadRequest) + return downloadRequest.id + } + + /** + * Observe all downloads + * + * @return [Flow] of List of [DownloadEntity] + */ + fun observeDownloads(): Flow> { + return downloadManager.observeAllDownloads() + } + + /** + * Observe download with given [id] + * + * @param id Unique Download ID of the download + * @return [Flow] of List of [DownloadEntity] + */ + fun observeDownloadById(id: Int): Flow { + return downloadManager.observeDownloadById(id) + } + + /** + * Pause download with given [id] + * + * @param id Unique Download ID of the download + */ + fun pause(id: Int) { + downloadManager.pauseAsync(id) + } + + /** + * Pause all the downloads + * + */ + fun pauseAll() { + downloadManager.pauseAllAsync() + } + + /** + * Resume download with given [id] + * + * @param id Unique Download ID of the download + */ + fun resume(id: Int) { + downloadManager.resumeAsync(id) + } + + /** + * Resume all the downloads + * + */ + fun resumeAll() { + downloadManager.resumeAllAsync() + } + + /** + * Retry download with given [id] + * + * @param id Unique Download ID of the download + */ + fun retry(id: Int) { + downloadManager.retryAsync(id) + } + + /** + * Retry all the downloads + * + */ + fun retryAll() { + downloadManager.retryAllAsync() + } + + /** + * Clear all entries from database and delete all the files + * + * @param deleteFile delete the actual file from the system + */ + fun clearAllDb(deleteFile: Boolean = true) { + downloadManager.clearAllDbAsync(deleteFile) + } + + /** + * Clear entries from database and delete files on or before [timeInMillis] + * + * @param timeInMillis timestamp in millisecond + * @param deleteFile delete the actual file from the system + */ + fun clearDb(timeInMillis: Long, deleteFile: Boolean = true) { + downloadManager.clearDbAsync(timeInMillis, deleteFile) + } + + /** + * Clear entry from database and delete file with given [id] + * + * @param id Unique Download ID of the download + * @param deleteFile delete the actual file from the system + */ + fun clearDb(id: Int, deleteFile: Boolean = true) { + downloadManager.clearDbAsync(id, deleteFile) + } + + /** + * Suspend function to make headers only api call to get and compare ETag string of content + * + * @param url Download Url + * @param eTag Existing ETag of content + * @return Boolean to compare existing and newly fetched ETag of the content + */ + suspend fun isContentValid( + url: String, + eTag: String + ): Boolean = withContext(Dispatchers.IO) { + RetrofitInstance.getDownloadService().getHeadersOnly(url).headers().get(ETAG_HEADER) == eTag + } + + fun find(id: Int, onResult: (DownloadEntity?) -> Unit) = downloadManager.findAsync(id, onResult) + + /** + * Suspend function to get list of all Downloads + * + * @return List of [DownloadEntity] + */ + suspend fun getAllDownloads() = downloadManager.getAllDownloads() + + companion object { + @Volatile + private var instance: Downloader? = null + + fun instance() = instance!! + + fun init( + application: Application, + notificationConfig: NotificationConfig, + ): Downloader { + if (instance == null) { + synchronized(Downloader) { + if (instance == null) { + instance = Downloader(application, notificationConfig) + } + } + } + return instance!! + } + } +} diff --git a/downloader/src/main/java/com/skyd/downloader/NotificationConfig.kt b/downloader/src/main/java/com/skyd/downloader/NotificationConfig.kt new file mode 100644 index 00000000..bdaf0123 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/NotificationConfig.kt @@ -0,0 +1,17 @@ +package com.skyd.downloader + +import android.app.NotificationManager +import kotlinx.serialization.Serializable + +@Serializable +data class NotificationConfig( + val enabled: Boolean = true, + val channelName: String, + val channelDescription: String, + val importance: Int = NotificationManager.IMPORTANCE_LOW, + val smallIcon: Int, + val pauseText: Int, + val resumeText: Int, + val cancelText: Int, + val retryText: Int, +) diff --git a/downloader/src/main/java/com/skyd/downloader/Status.kt b/downloader/src/main/java/com/skyd/downloader/Status.kt new file mode 100644 index 00000000..b7315d08 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/Status.kt @@ -0,0 +1,11 @@ +package com.skyd.downloader + +enum class Status { + Init, + Queued, + Started, + Downloading, + Success, + Failed, + Paused, +} diff --git a/downloader/src/main/java/com/skyd/downloader/UserAction.kt b/downloader/src/main/java/com/skyd/downloader/UserAction.kt new file mode 100644 index 00000000..7970b137 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/UserAction.kt @@ -0,0 +1,10 @@ +package com.skyd.downloader + +internal enum class UserAction { + Init, + Start, + Pause, + Resume, + Cancel, + Retry, +} diff --git a/downloader/src/main/java/com/skyd/downloader/db/DatabaseInstance.kt b/downloader/src/main/java/com/skyd/downloader/db/DatabaseInstance.kt new file mode 100644 index 00000000..e3d91a27 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/db/DatabaseInstance.kt @@ -0,0 +1,24 @@ +package com.skyd.downloader.db + +import android.content.Context +import androidx.room.Room + +internal object DatabaseInstance { + @Volatile + private var instance: DownloadDatabase? = null + + fun getInstance(context: Context): DownloadDatabase { + if (instance == null) { + synchronized(DownloadDatabase::class) { + if (instance == null) { + instance = Room.databaseBuilder( + context.applicationContext, + DownloadDatabase::class.java, + "Downloader" + ).fallbackToDestructiveMigration().build() + } + } + } + return instance!! + } +} diff --git a/downloader/src/main/java/com/skyd/downloader/db/DownloadDao.kt b/downloader/src/main/java/com/skyd/downloader/db/DownloadDao.kt new file mode 100644 index 00000000..a0c2eea1 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/db/DownloadDao.kt @@ -0,0 +1,41 @@ +package com.skyd.downloader.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +internal interface DownloadDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: DownloadEntity) + + @Update + suspend fun update(entity: DownloadEntity) + + @Query("SELECT * FROM ${DownloadEntity.TABLE_NAME} WHERE id = :id") + suspend fun find(id: Int): DownloadEntity? + + @Query("DELETE FROM ${DownloadEntity.TABLE_NAME} WHERE id = :id") + suspend fun remove(id: Int) + + @Query("DELETE FROM ${DownloadEntity.TABLE_NAME}") + suspend fun deleteAll() + + @Query("SELECT * FROM ${DownloadEntity.TABLE_NAME} ORDER BY timeQueued ASC") + fun getAllEntityFlow(): Flow> + + @Query("SELECT * FROM ${DownloadEntity.TABLE_NAME} WHERE createTime <= :timeMillis ORDER BY timeQueued ASC") + fun getEntityTillTimeFlow(timeMillis: Long): Flow> + + @Query("SELECT * FROM ${DownloadEntity.TABLE_NAME} WHERE id = :id ORDER BY timeQueued ASC") + fun getEntityByIdFlow(id: Int): Flow + + @Query("SELECT * FROM ${DownloadEntity.TABLE_NAME} ORDER BY timeQueued ASC") + suspend fun getAllEntity(): List + + @Query("SELECT * FROM ${DownloadEntity.TABLE_NAME} WHERE createTime <= :timeMillis ORDER BY timeQueued ASC") + suspend fun getEntityTillTime(timeMillis: Long): List +} diff --git a/downloader/src/main/java/com/skyd/downloader/db/DownloadDatabase.kt b/downloader/src/main/java/com/skyd/downloader/db/DownloadDatabase.kt new file mode 100644 index 00000000..fe01ffd4 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/db/DownloadDatabase.kt @@ -0,0 +1,9 @@ +package com.skyd.downloader.db + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database(entities = [DownloadEntity::class], version = 1) +internal abstract class DownloadDatabase : RoomDatabase() { + abstract fun downloadDao(): DownloadDao +} diff --git a/downloader/src/main/java/com/skyd/downloader/db/DownloadEntity.kt b/downloader/src/main/java/com/skyd/downloader/db/DownloadEntity.kt new file mode 100644 index 00000000..72b867c6 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/db/DownloadEntity.kt @@ -0,0 +1,29 @@ +package com.skyd.downloader.db + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.skyd.downloader.Status +import com.skyd.downloader.UserAction + +@Entity(tableName = DownloadEntity.TABLE_NAME) +data class DownloadEntity( + @PrimaryKey + var id: Int = 0, + var url: String = "", + var path: String = "", + var fileName: String = "", + var timeQueued: Long = 0, + var status: String = Status.Init.toString(), + var totalBytes: Long = 0, + var downloadedBytes: Long = 0, + var speedInBytePerMs: Float = 0f, + var eTag: String = "", + var workerUuid: String = "", + var createTime: Long = 0, + var userAction: String = UserAction.Init.toString(), + var failureReason: String = "" +) { + companion object { + const val TABLE_NAME = "Download" + } +} diff --git a/downloader/src/main/java/com/skyd/downloader/download/DownloadManager.kt b/downloader/src/main/java/com/skyd/downloader/download/DownloadManager.kt new file mode 100644 index 00000000..f0a4e729 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/download/DownloadManager.kt @@ -0,0 +1,213 @@ +package com.skyd.downloader.download + +import android.content.Context +import android.util.Log +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.skyd.downloader.NotificationConfig +import com.skyd.downloader.Status +import com.skyd.downloader.UserAction +import com.skyd.downloader.db.DownloadDao +import com.skyd.downloader.db.DownloadEntity +import com.skyd.downloader.download.DownloadRequest.Companion.toDownloadRequest +import com.skyd.downloader.util.FileUtil.deleteDownloadFileIfExists +import com.skyd.downloader.util.NotificationUtil.removeNotification +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.UUID + +internal class DownloadManager( + private val context: Context, + private val downloadDao: DownloadDao, + private val notificationConfig: NotificationConfig, + private val workManager: WorkManager, +) { + companion object { + const val TAG = "DownloadManager" + } + + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + Log.i(TAG, "Exception in DownloadManager Scope: ${throwable.message}") + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) + + private suspend fun download(downloadRequest: DownloadRequest) { + val downloadWorkRequest = OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putInt(DownloadWorker.INPUT_DATA_ID_KEY, downloadRequest.id) + .putString( + DownloadWorker.INPUT_DATA_NOTIFICATION_CONFIG_KEY, + Json.encodeToString(notificationConfig) + ) + .build() + ) + .build() + var oldDownloadEntity = downloadDao.find(downloadRequest.id) + // Checks if download id already present in database + if (oldDownloadEntity != null) { + oldDownloadEntity = oldDownloadEntity.copy(userAction = UserAction.Start.toString()) + downloadDao.update(oldDownloadEntity) + + // In case new download request is generated for already existing id in database + // and work is not in progress, replace the uuid in database + if (oldDownloadEntity.workerUuid != downloadWorkRequest.id.toString() && + oldDownloadEntity.status != Status.Queued.toString() && + oldDownloadEntity.status != Status.Downloading.toString() && + oldDownloadEntity.status != Status.Started.toString() + ) { + downloadDao.update( + oldDownloadEntity.copy( + workerUuid = downloadWorkRequest.id.toString(), + status = Status.Queued.toString(), + ) + ) + } + } else { + downloadDao.insert( + DownloadEntity( + url = downloadRequest.url, + path = downloadRequest.path, + fileName = downloadRequest.fileName, + id = downloadRequest.id, + timeQueued = System.currentTimeMillis(), + status = Status.Queued.toString(), + workerUuid = downloadWorkRequest.id.toString(), + userAction = UserAction.Start.toString(), + ) + ) + + deleteDownloadFileIfExists(downloadRequest.path, downloadRequest.fileName) + } + + workManager.enqueueUniqueWork( + downloadRequest.id.toString(), + ExistingWorkPolicy.KEEP, + downloadWorkRequest + ) + } + + private suspend fun resume(id: Int) { + val downloadEntity = downloadDao.find(id) + if (downloadEntity != null) { + downloadDao.update(downloadEntity.copy(userAction = UserAction.Resume.toString())) + download(downloadEntity.toDownloadRequest()) + } + } + + private suspend fun pause(id: Int) { + val downloadEntity = downloadDao.find(id) + if (downloadEntity != null) { + downloadDao.update(downloadEntity.copy(userAction = UserAction.Pause.toString())) + } + workManager.cancelUniqueWork(id.toString()) + } + + private suspend fun retry(id: Int) { + val downloadEntity = downloadDao.find(id) + if (downloadEntity != null) { + downloadDao.update(downloadEntity.copy(userAction = UserAction.Retry.toString())) + download(downloadEntity.toDownloadRequest()) + } + } + + private suspend fun findDownloadEntityFromUUID(uuid: UUID): DownloadEntity? { + return downloadDao.getAllEntity().find { it.workerUuid == uuid.toString() } + } + + fun resumeAsync(id: Int) = scope.launch { + resume(id) + } + + fun resumeAllAsync() = scope.launch { + downloadDao.getAllEntity().forEach { + resume(it.id) + } + } + + fun pauseAsync(id: Int) = scope.launch { + pause(id) + } + + fun pauseAllAsync() = scope.launch { + downloadDao.getAllEntity().forEach { + pause(it.id) + } + } + + fun retryAsync(id: Int) = scope.launch { + retry(id) + } + + fun retryAllAsync() = scope.launch { + downloadDao.getAllEntity().forEach { + retry(it.id) + } + } + + fun clearDbAsync(id: Int, deleteFile: Boolean) = scope.launch { + workManager.cancelUniqueWork(id.toString()) + val downloadEntity = downloadDao.find(id) + val path = downloadEntity?.path + val fileName = downloadEntity?.fileName + if (path != null && fileName != null && deleteFile) { + deleteDownloadFileIfExists(path, fileName) + } + removeNotification(context, id) + downloadDao.remove(id) + } + + fun clearAllDbAsync(deleteFile: Boolean) = scope.launch { + downloadDao.getAllEntity().forEach { + workManager.cancelUniqueWork(it.id.toString()) + val downloadEntity = downloadDao.find(it.id) + val path = downloadEntity?.path + val fileName = downloadEntity?.fileName + if (path != null && fileName != null && deleteFile) { + deleteDownloadFileIfExists(path, fileName) + } + removeNotification(context, it.id) + } + downloadDao.deleteAll() + } + + fun clearDbAsync(timeInMillis: Long, deleteFile: Boolean) = scope.launch { + downloadDao.getEntityTillTime(timeInMillis).forEach { + workManager.cancelUniqueWork(it.id.toString()) + val downloadEntity = downloadDao.find(it.id) + val path = downloadEntity?.path + val fileName = downloadEntity?.fileName + if (path != null && fileName != null && deleteFile) { + deleteDownloadFileIfExists(path, fileName) + } + downloadDao.remove(it.id) + removeNotification(context, it.id) + } + } + + fun downloadAsync(downloadRequest: DownloadRequest) = scope.launch { + download(downloadRequest) + } + + fun observeDownloadById(id: Int): Flow = downloadDao + .getEntityByIdFlow(id).filterNotNull().distinctUntilChanged() + + fun observeAllDownloads(): Flow> = downloadDao.getAllEntityFlow() + + fun findAsync(id: Int, onResult: (DownloadEntity?) -> Unit) = scope.launch { + onResult(downloadDao.find(id)) + } + + suspend fun getAllDownloads(): List = downloadDao.getAllEntity() +} diff --git a/downloader/src/main/java/com/skyd/downloader/download/DownloadRequest.kt b/downloader/src/main/java/com/skyd/downloader/download/DownloadRequest.kt new file mode 100644 index 00000000..deaae330 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/download/DownloadRequest.kt @@ -0,0 +1,21 @@ +package com.skyd.downloader.download + +import com.skyd.downloader.db.DownloadEntity +import com.skyd.downloader.util.FileUtil.getUniqueId + + +internal data class DownloadRequest( + val url: String, + val path: String, + val fileName: String, + val id: Int = getUniqueId(url, path, fileName), +) { + companion object { + internal fun DownloadEntity.toDownloadRequest() = DownloadRequest( + url = url, + path = path, + fileName = fileName, + id = id, + ) + } +} \ No newline at end of file diff --git a/downloader/src/main/java/com/skyd/downloader/download/DownloadTask.kt b/downloader/src/main/java/com/skyd/downloader/download/DownloadTask.kt new file mode 100644 index 00000000..b3a477a3 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/download/DownloadTask.kt @@ -0,0 +1,108 @@ +package com.skyd.downloader.download + +import com.skyd.downloader.net.DownloadService +import com.skyd.downloader.util.FileUtil +import com.skyd.downloader.util.FileUtil.tempFile +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +internal class DownloadTask( + private var url: String, + private var path: String, + private var fileName: String, + private val downloadService: DownloadService, +) { + + companion object { + private const val VALUE_200 = 200 + private const val VALUE_299 = 299 + private const val TIME_TO_TRIGGER_PROGRESS = 500 + + private const val RANGE_HEADER = "Range" + private const val HTTP_RANGE_NOT_SATISFY = 416 + internal const val ETAG_HEADER = "ETag" + } + + suspend fun download( + headers: MutableMap = mutableMapOf(), + onStart: suspend (Long) -> Unit, + onProgress: suspend (Long, Long, Float) -> Unit + ): Long { + var rangeStart = 0L + val file = File(path, fileName) + val tempFile = file.tempFile + + if (tempFile.exists()) { + rangeStart = tempFile.length() + } + + if (rangeStart != 0L) { + headers[RANGE_HEADER] = "bytes=$rangeStart-" + } + + var response = downloadService.getUrl(url, headers) + if (response.code() == HTTP_RANGE_NOT_SATISFY) { + FileUtil.deleteDownloadFileIfExists(path, fileName) + headers.remove(RANGE_HEADER) + rangeStart = 0 + response = downloadService.getUrl(url, headers) + } + + val responseBody = response.body() + + if (response.code() !in VALUE_200..VALUE_299 || + responseBody == null + ) { + throw IOException( + "Something went wrong, response code: ${response.code()}, responseBody null: ${responseBody == null}" + ) + } + + var totalBytes = responseBody.contentLength() + if (totalBytes < 0) throw IOException("Content Length is wrong: $totalBytes") + totalBytes += rangeStart + + var progressBytes = 0L + + responseBody.byteStream().use { inputStream -> + FileOutputStream(tempFile, true).use { outputStream -> + if (rangeStart != 0L) { + progressBytes = rangeStart + } + + onStart.invoke(totalBytes) + + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = inputStream.read(buffer) + var tempBytes = 0L + var progressInvokeTime = System.currentTimeMillis() + var speed: Float + + while (bytes >= 0) { + outputStream.write(buffer, 0, bytes) + progressBytes += bytes + tempBytes += bytes + bytes = inputStream.read(buffer) + val finalTime = System.currentTimeMillis() + if (finalTime - progressInvokeTime >= TIME_TO_TRIGGER_PROGRESS) { + speed = tempBytes.toFloat() / ((finalTime - progressInvokeTime).toFloat()) + tempBytes = 0L + progressInvokeTime = System.currentTimeMillis() + if (progressBytes > totalBytes) progressBytes = totalBytes + onProgress.invoke( + progressBytes, + totalBytes, + speed + ) + } + } + onProgress.invoke(totalBytes, totalBytes, 0F) + } + } + + require(tempFile.renameTo(file)) { "Temp file rename failed" } + + return totalBytes + } +} diff --git a/downloader/src/main/java/com/skyd/downloader/download/DownloadWorker.kt b/downloader/src/main/java/com/skyd/downloader/download/DownloadWorker.kt new file mode 100644 index 00000000..af1b86d8 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/download/DownloadWorker.kt @@ -0,0 +1,167 @@ +package com.skyd.downloader.download + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.skyd.downloader.NotificationConfig +import com.skyd.downloader.Status +import com.skyd.downloader.UserAction +import com.skyd.downloader.db.DatabaseInstance +import com.skyd.downloader.net.RetrofitInstance +import com.skyd.downloader.notification.DownloadNotificationManager +import com.skyd.downloader.util.FileUtil +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +internal class DownloadWorker( + private val context: Context, + workerParameters: WorkerParameters +) : CoroutineWorker(context, workerParameters) { + + companion object { + internal const val INPUT_DATA_ID_KEY = "id" + internal const val INPUT_DATA_NOTIFICATION_CONFIG_KEY = "notificationConfig" + + const val KEY_EXCEPTION = "keyException" + const val EXCEPTION_NO_ENTITY = "No DownloadEntity" + + const val KEY_STATE = "keyState" + const val KEY_DOWNLOADED_BYTES = "keyDownloadedBytes" + const val DOWNLOADING_STATE = "downloading" + const val STARTED_STATE = "started" + + private val scope = CoroutineScope(Dispatchers.IO) + } + + private var downloadNotificationManager: DownloadNotificationManager? = null + private val downloadDao = DatabaseInstance.getInstance(context).downloadDao() + + override suspend fun doWork(): Result { + val entityId = inputData.keyValueMap[INPUT_DATA_ID_KEY] as Int + val downloadRequest = downloadDao.find(entityId) ?: return Result.failure( + workDataOf(KEY_EXCEPTION to EXCEPTION_NO_ENTITY) + ) + + val notificationConfig: NotificationConfig = runCatching { + Json.decodeFromString( + inputData.getString(INPUT_DATA_NOTIFICATION_CONFIG_KEY).orEmpty() + ) + }.getOrNull() ?: return Result.failure(workDataOf(KEY_EXCEPTION to EXCEPTION_NO_ENTITY)) + + val id = downloadRequest.id + val url = downloadRequest.url + val dirPath = downloadRequest.path + val fileName = downloadRequest.fileName + + downloadNotificationManager = DownloadNotificationManager( + context = context, + notificationConfig = notificationConfig, + requestId = id, + fileName = fileName + ) + + val downloadService = RetrofitInstance.getDownloadService() + + return try { + downloadNotificationManager?.sendUpdateNotification()?.let { setForeground(it) } + + val latestETag = downloadService.getHeadersOnly(url).headers() + .get(DownloadTask.ETAG_HEADER).orEmpty() + val existingETag = downloadDao.find(id)?.eTag.orEmpty() + if (latestETag != existingETag) { + FileUtil.deleteDownloadFileIfExists(path = dirPath, name = fileName) + downloadDao.find(id)?.copy(eTag = latestETag)?.let { downloadDao.update(it) } + } + + var progressPercentage = 0 + + val totalLength = DownloadTask( + url = url, + path = dirPath, + fileName = fileName, + downloadService = downloadService + ).download( + onStart = { length -> + downloadDao.find(id)?.copy( + totalBytes = length, + status = Status.Started.toString(), + )?.let { downloadDao.update(it) } + + setProgress(workDataOf(KEY_STATE to STARTED_STATE)) + }, + onProgress = { downloadedBytes, length, speed -> + val progress = if (length != 0L) { + ((downloadedBytes * 100) / length).toInt() + } else { + 0 + } + + if (progressPercentage != progress) { + progressPercentage = progress + downloadDao.find(id)?.copy( + downloadedBytes = downloadedBytes, + speedInBytePerMs = speed, + status = Status.Downloading.toString(), + )?.let { downloadDao.update(it) } + } + + setProgress( + workDataOf( + KEY_STATE to DOWNLOADING_STATE, + KEY_DOWNLOADED_BYTES to downloadedBytes + ) + ) + downloadNotificationManager?.sendUpdateNotification( + downloadedBytes = downloadedBytes, + speedInBPerMs = speed, + totalBytes = length, + )?.let { setForeground(it) } + } + ) + + downloadDao.find(id)?.copy( + totalBytes = totalLength, + status = Status.Success.toString(), + )?.let { downloadDao.update(it) } + + downloadNotificationManager?.sendDownloadSuccessNotification(totalLength) + + Result.success() + } catch (e: Exception) { + scope.launch { + if (e is CancellationException) { + var downloadEntity = downloadDao.find(id) + if (downloadEntity?.userAction == UserAction.Pause.toString()) { + downloadEntity = downloadEntity.copy(status = Status.Paused.toString()) + downloadDao.update(downloadEntity) + downloadNotificationManager?.sendDownloadPausedNotification( + downloadedBytes = downloadEntity.downloadedBytes, + totalBytes = downloadEntity.totalBytes, + ) + } else { + downloadDao.remove(id) + FileUtil.deleteDownloadFileIfExists(dirPath, fileName) + downloadNotificationManager?.sendDownloadCancelledNotification() + } + } else { + var downloadEntity = downloadDao.find(id) + if (downloadEntity != null) { + downloadEntity = downloadEntity.copy( + status = Status.Failed.toString(), + failureReason = e.message.orEmpty(), + ) + downloadDao.update(downloadEntity) + downloadNotificationManager?.sendDownloadFailedNotification( + downloadedBytes = downloadEntity.downloadedBytes + ) + } + } + } + Result.failure(workDataOf(KEY_EXCEPTION to e.message)) + } + } +} diff --git a/downloader/src/main/java/com/skyd/downloader/net/DownloadService.kt b/downloader/src/main/java/com/skyd/downloader/net/DownloadService.kt new file mode 100644 index 00000000..11648ba6 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/net/DownloadService.kt @@ -0,0 +1,23 @@ +package com.skyd.downloader.net + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.HEAD +import retrofit2.http.HeaderMap +import retrofit2.http.Streaming +import retrofit2.http.Url + +internal interface DownloadService { + @Streaming + @GET + suspend fun getUrl( + @Url url: String, + @HeaderMap headers: Map + ): Response + + @HEAD + suspend fun getHeadersOnly( + @Url url: String, + ): Response +} diff --git a/downloader/src/main/java/com/skyd/downloader/net/RetrofitInstance.kt b/downloader/src/main/java/com/skyd/downloader/net/RetrofitInstance.kt new file mode 100644 index 00000000..db315ee8 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/net/RetrofitInstance.kt @@ -0,0 +1,34 @@ +package com.skyd.downloader.net + +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit + +internal object RetrofitInstance { + + @Volatile + private var downloadService: DownloadService? = null + + fun getDownloadService( + okHttpClient: OkHttpClient = + OkHttpClient + .Builder() + .connectTimeout(10000L, TimeUnit.MILLISECONDS) + .readTimeout(10000L, TimeUnit.MILLISECONDS) + .build() + ): DownloadService { + if (downloadService == null) { + synchronized(this) { + if (downloadService == null) { + downloadService = Retrofit + .Builder() + .baseUrl("http://localhost/") + .client(okHttpClient) + .build() + .create(DownloadService::class.java) + } + } + } + return downloadService!! + } +} diff --git a/downloader/src/main/java/com/skyd/downloader/notification/DownloadNotificationManager.kt b/downloader/src/main/java/com/skyd/downloader/notification/DownloadNotificationManager.kt new file mode 100644 index 00000000..dd69cff3 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/notification/DownloadNotificationManager.kt @@ -0,0 +1,327 @@ +package com.skyd.downloader.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.work.ForegroundInfo +import com.skyd.downloader.NotificationConfig +import com.skyd.downloader.R +import com.skyd.downloader.download.DownloadWorker +import com.skyd.downloader.util.TextUtil + +/** + * Download notification manager: Responsible for showing the in progress notification for each downloads. + * Whenever the download is cancelled or paused or failed (terminating state), WorkManager cancels the + * ongoing notification and this class sends the broadcast to show terminating state notifications. + * + * Notification ID = Download request ID for each download. + * + * @property context Application context + * @property notificationConfig [NotificationConfig] + * @property requestId Unique ID for current download + * @property fileName File name of the download + * @constructor Create empty Download notification manager + */ +internal class DownloadNotificationManager( + private val context: Context, + private val notificationConfig: NotificationConfig, + private val requestId: Int, + private val fileName: String +) { + + private var foregroundInfo: ForegroundInfo? = null + private lateinit var notificationBuilder: NotificationCompat.Builder + + private val notificationId = requestId // notification id is same as request id + + init { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel() + } + initNotificationBuilder() + } + + private fun initNotificationBuilder() { + // Open Application (Send the unique download request id in intent) + val intentOpen = + context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra(NotificationConst.KEY_DOWNLOAD_REQUEST_ID, requestId) + } + + // Dismiss Notification + val intentDismiss = Intent(context, NotificationReceiver::class.java).apply { + action = NotificationConst.ACTION_NOTIFICATION_DISMISSED + putExtra(NotificationConst.KEY_NOTIFICATION_ID, notificationId) + } + + // Pause Notification + val intentPause = Intent(context, NotificationReceiver::class.java).apply { + action = NotificationConst.ACTION_NOTIFICATION_PAUSE_CLICK + putExtra(NotificationConst.KEY_NOTIFICATION_ID, notificationId) + putExtra(NotificationConst.KEY_DOWNLOAD_REQUEST_ID, requestId) + } + + // Cancel Notification + val intentCancel = Intent(context, NotificationReceiver::class.java).apply { + action = NotificationConst.ACTION_NOTIFICATION_CANCEL_CLICK + putExtra(NotificationConst.KEY_NOTIFICATION_ID, notificationId) + putExtra(NotificationConst.KEY_DOWNLOAD_REQUEST_ID, requestId) + } + + notificationBuilder = NotificationCompat.Builder( + context, NotificationConst.NOTIFICATION_CHANNEL_ID + ) + .setSmallIcon(notificationConfig.smallIcon).setOnlyAlertOnce(true) + .setOngoing(true) + .addAction( + -1, + context.getString(notificationConfig.pauseText), + getBroadcastPendingIntent(intentPause), + ) + .addAction( + -1, + context.getString(notificationConfig.cancelText), + getBroadcastPendingIntent(intentCancel), + ) + .setContentIntent( + PendingIntent.getActivity( + context.applicationContext, + notificationId, + intentOpen, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + .setDeleteIntent(getBroadcastPendingIntent(intentDismiss)) + } + + private fun getBroadcastPendingIntent(intent: Intent) = PendingIntent.getBroadcast( + context.applicationContext, + notificationId, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + + /** + * Send update notification: Shows the current in progress download notification, which cannot be dismissed + * + * @param downloadedBytes current downloaded bytes + * @param speedInBPerMs current download speed in byte per second + * @param totalBytes current length of download file in bytes + * @return ForegroundInfo to be set in Worker + */ + fun sendUpdateNotification( + downloadedBytes: Long = 0L, + speedInBPerMs: Float = 0F, + totalBytes: Long = 0L, + ): ForegroundInfo? { + val progress = if (totalBytes != 0L) { + ((downloadedBytes * 100) / totalBytes).toInt() + } else { + 0 + } + + foregroundInfo = ForegroundInfo( + notificationId, + notificationBuilder + .setContentTitle(context.getString(R.string.downloading_title, fileName)) + .setProgress(NotificationConst.MAX_VALUE_PROGRESS, progress, false) + .setContentText( + setContentTextNotification(speedInBPerMs, downloadedBytes, totalBytes) + ) + .setSubText("$progress%") + .build(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + } + ) + return foregroundInfo + } + + /** + * Set content text notification + * + * @param speedInBPerMs speed in byte per second of download + * @param downloadedBytes current downloaded bytes + * @param length total size of progress + * @return Return the text to be displayed on in-progress download notification + */ + private fun setContentTextNotification( + speedInBPerMs: Float, + downloadedBytes: Long, + length: Long + ): String { + val speedText = TextUtil.getSpeedText(speedInBPerMs) + val downloadedText = TextUtil.getTotalLengthText(downloadedBytes) + val lengthText = TextUtil.getTotalLengthText(length) + + val parts = mutableListOf() + + if (speedText.isNotEmpty()) { + parts.add(speedText) + } + if (lengthText.isNotEmpty()) { + parts.add("$downloadedText / $lengthText") + } + + return parts.joinToString(" ") + } + + /** + * Send broadcast to show download success notification + * + * @param totalLength + */ + fun sendDownloadSuccessNotification(totalLength: Long) { + context.applicationContext.sendBroadcast( + Intent(context, NotificationReceiver::class.java).apply { + putExtra( + NotificationConst.KEY_NOTIFICATION_CHANNEL_NAME, + notificationConfig.channelName + ) + putExtra( + NotificationConst.KEY_NOTIFICATION_CHANNEL_IMPORTANCE, + notificationConfig.importance + ) + putExtra( + NotificationConst.KEY_NOTIFICATION_CHANNEL_DESCRIPTION, + notificationConfig.channelDescription + ) + putExtra( + NotificationConst.KEY_NOTIFICATION_SMALL_ICON, + notificationConfig.smallIcon + ) + putExtra(NotificationConst.KEY_FILE_NAME, fileName) + putExtra(NotificationConst.KEY_TOTAL_BYTES, totalLength) + putExtra(NotificationConst.KEY_DOWNLOAD_REQUEST_ID, requestId) + putExtra(NotificationConst.KEY_NOTIFICATION_ID, notificationId) + action = NotificationConst.ACTION_DOWNLOAD_COMPLETED + } + ) + } + + /** + * Send broadcast to show download failed notification + * + * @param downloadedBytes current downloaded bytes + */ + fun sendDownloadFailedNotification(downloadedBytes: Long) { + context.applicationContext.sendBroadcast( + Intent(context, NotificationReceiver::class.java).apply { + putExtra( + NotificationConst.KEY_NOTIFICATION_CHANNEL_NAME, + notificationConfig.channelName + ) + putExtra( + NotificationConst.KEY_NOTIFICATION_CHANNEL_IMPORTANCE, + notificationConfig.importance + ) + putExtra( + NotificationConst.KEY_NOTIFICATION_CHANNEL_DESCRIPTION, + notificationConfig.channelDescription + ) + putExtra( + NotificationConst.KEY_NOTIFICATION_SMALL_ICON, + notificationConfig.smallIcon + ) + putExtra(NotificationConst.KEY_FILE_NAME, fileName) + putExtra(NotificationConst.KEY_DOWNLOAD_REQUEST_ID, requestId) + putExtra(NotificationConst.KEY_NOTIFICATION_ID, notificationId) + putExtra(DownloadWorker.KEY_DOWNLOADED_BYTES, downloadedBytes) + action = NotificationConst.ACTION_DOWNLOAD_FAILED + } + ) + } + + /** + * Send broadcast to show download cancelled notification + * + */ + fun sendDownloadCancelledNotification() { + context.applicationContext.sendBroadcast( + Intent(context, NotificationReceiver::class.java).apply { + putExtra( + NotificationConst.KEY_NOTIFICATION_CHANNEL_NAME, + notificationConfig.channelName + ) + putExtra( + NotificationConst.KEY_NOTIFICATION_CHANNEL_IMPORTANCE, + notificationConfig.importance + ) + putExtra( + NotificationConst.KEY_NOTIFICATION_CHANNEL_DESCRIPTION, + notificationConfig.channelDescription + ) + putExtra( + NotificationConst.KEY_NOTIFICATION_SMALL_ICON, + notificationConfig.smallIcon + ) + putExtra(NotificationConst.KEY_FILE_NAME, fileName) + putExtra(NotificationConst.KEY_DOWNLOAD_REQUEST_ID, requestId) + putExtra(NotificationConst.KEY_NOTIFICATION_ID, notificationId) + action = NotificationConst.ACTION_DOWNLOAD_CANCELLED + } + ) + } + + /** + * Send broadcast to show download paused notification + * + * @param downloadedBytes current downloaded bytes + * @param totalBytes total bytes + */ + fun sendDownloadPausedNotification( + downloadedBytes: Long, + totalBytes: Long, + ) { + context.applicationContext.sendBroadcast( + Intent(context, NotificationReceiver::class.java).apply { + putExtra( + NotificationConst.KEY_NOTIFICATION_CHANNEL_NAME, + notificationConfig.channelName + ) + putExtra( + NotificationConst.KEY_NOTIFICATION_CHANNEL_IMPORTANCE, + notificationConfig.importance + ) + putExtra( + NotificationConst.KEY_NOTIFICATION_CHANNEL_DESCRIPTION, + notificationConfig.channelDescription + ) + putExtra( + NotificationConst.KEY_NOTIFICATION_SMALL_ICON, + notificationConfig.smallIcon + ) + putExtra(NotificationConst.KEY_FILE_NAME, fileName) + putExtra(DownloadWorker.KEY_DOWNLOADED_BYTES, downloadedBytes) + putExtra(NotificationConst.KEY_TOTAL_BYTES, totalBytes) + putExtra(NotificationConst.KEY_DOWNLOAD_REQUEST_ID, requestId) + putExtra(NotificationConst.KEY_NOTIFICATION_ID, notificationId) + action = NotificationConst.ACTION_DOWNLOAD_PAUSED + } + ) + } + + /** + * Create notification channel for File downloads + * + */ + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel() { + val channel = NotificationChannel( + NotificationConst.NOTIFICATION_CHANNEL_ID, + notificationConfig.channelName, + notificationConfig.importance + ) + channel.description = notificationConfig.channelDescription + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } +} diff --git a/downloader/src/main/java/com/skyd/downloader/notification/NotificationConst.kt b/downloader/src/main/java/com/skyd/downloader/notification/NotificationConst.kt new file mode 100644 index 00000000..4af114c3 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/notification/NotificationConst.kt @@ -0,0 +1,32 @@ +package com.skyd.downloader.notification + +import android.app.NotificationManager + +object NotificationConst { + const val NOTIFICATION_CHANNEL_ID = "downloadChannel" + const val KEY_NOTIFICATION_CHANNEL_NAME = "keyNotificationChannelName" + const val DEFAULT_VALUE_NOTIFICATION_CHANNEL_NAME = "File Download" + const val KEY_NOTIFICATION_CHANNEL_DESCRIPTION = "keyNotificationChannelDescription" + const val DEFAULT_VALUE_NOTIFICATION_CHANNEL_DESCRIPTION = "Notify file download status" + const val KEY_NOTIFICATION_CHANNEL_IMPORTANCE = "keyNotificationChannelImportance" + const val DEFAULT_VALUE_NOTIFICATION_CHANNEL_IMPORTANCE = NotificationManager.IMPORTANCE_LOW + const val KEY_NOTIFICATION_SMALL_ICON = "keySmallNotificationIcon" + const val DEFAULT_VALUE_NOTIFICATION_SMALL_ICON = -1 + const val KEY_NOTIFICATION_ID = "keyNotificationId" + const val KEY_DOWNLOAD_REQUEST_ID = "keyDownloadRequestId" + const val KEY_FILE_NAME = "keyFileName" + const val KEY_TOTAL_BYTES = "keyTotalBytes" + + const val MAX_VALUE_PROGRESS = 100 + + // Actions + const val ACTION_NOTIFICATION_DISMISSED = "ACTION_NOTIFICATION_DISMISSED" + const val ACTION_DOWNLOAD_COMPLETED = "ACTION_DOWNLOAD_COMPLETED" + const val ACTION_DOWNLOAD_FAILED = "ACTION_DOWNLOAD_FAILED" + const val ACTION_DOWNLOAD_CANCELLED = "ACTION_DOWNLOAD_CANCELLED" + const val ACTION_DOWNLOAD_PAUSED = "ACTION_NOTIFICATION_PAUSED" + const val ACTION_NOTIFICATION_RESUME_CLICK = "ACTION_NOTIFICATION_RESUME_CLICK" + const val ACTION_NOTIFICATION_RETRY_CLICK = "ACTION_NOTIFICATION_RETRY_CLICK" + const val ACTION_NOTIFICATION_PAUSE_CLICK = "ACTION_NOTIFICATION_PAUSE_CLICK" + const val ACTION_NOTIFICATION_CANCEL_CLICK = "ACTION_NOTIFICATION_CANCEL_CLICK" +} diff --git a/downloader/src/main/java/com/skyd/downloader/notification/NotificationReceiver.kt b/downloader/src/main/java/com/skyd/downloader/notification/NotificationReceiver.kt new file mode 100644 index 00000000..1572c776 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/notification/NotificationReceiver.kt @@ -0,0 +1,257 @@ +package com.skyd.downloader.notification + +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.skyd.downloader.Downloader +import com.skyd.downloader.R +import com.skyd.downloader.download.DownloadWorker +import com.skyd.downloader.util.TextUtil + +/** + * Notification receiver: Responsible for showing the terminating state notification (paused, cancelled, failed) + * It also handles the user action from notification (Pause, Resume, Cancel, Retry) + * + * Notification ID = (Unique Download Request ID + 1) for each download + * + * @constructor Create empty Notification receiver + */ +internal class NotificationReceiver : BroadcastReceiver() { + @SuppressLint("MissingPermission") + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null || intent == null) return + val downloader = Downloader.instance() + + val extras = intent.extras + when (intent.action) { + // Resume the download and dismiss the notification + NotificationConst.ACTION_NOTIFICATION_RESUME_CLICK -> { + val requestId = extras?.getInt(NotificationConst.KEY_DOWNLOAD_REQUEST_ID) + val nId = extras?.getInt(NotificationConst.KEY_NOTIFICATION_ID) + if (nId != null) NotificationManagerCompat.from(context).cancel(nId) + if (requestId != null) { + downloader.resume(requestId) + } + return + } + + // Retry the download and dismiss the notification + NotificationConst.ACTION_NOTIFICATION_RETRY_CLICK -> { + val requestId = extras?.getInt(NotificationConst.KEY_DOWNLOAD_REQUEST_ID) + val nId = extras?.getInt(NotificationConst.KEY_NOTIFICATION_ID) + if (nId != null) NotificationManagerCompat.from(context).cancel(nId) + if (requestId != null) { + downloader.retry(requestId) + } + return + } + + // Pause the download and dismiss the notification + NotificationConst.ACTION_NOTIFICATION_PAUSE_CLICK -> { + val requestId = extras?.getInt(NotificationConst.KEY_DOWNLOAD_REQUEST_ID) + val nId = extras?.getInt(NotificationConst.KEY_NOTIFICATION_ID) + if (nId != null) NotificationManagerCompat.from(context).cancel(nId) + if (requestId != null) { + downloader.pause(requestId) + } + return + } + + // Cancel the download and dismiss the notification + NotificationConst.ACTION_NOTIFICATION_CANCEL_CLICK -> { + val requestId = extras?.getInt(NotificationConst.KEY_DOWNLOAD_REQUEST_ID) + val nId = extras?.getInt(NotificationConst.KEY_NOTIFICATION_ID) + if (nId != null) NotificationManagerCompat.from(context).cancel(nId) + if (requestId != null) { + downloader.clearDb(requestId, deleteFile = true) + } + return + } + + // List of actions when notification gets triggered + else -> { + val notificationActionList = listOf( + NotificationConst.ACTION_DOWNLOAD_COMPLETED, + NotificationConst.ACTION_DOWNLOAD_FAILED, + NotificationConst.ACTION_DOWNLOAD_CANCELLED, + NotificationConst.ACTION_DOWNLOAD_PAUSED + ) + + if (intent.action in notificationActionList) { + val notificationChannelName = + extras?.getString(NotificationConst.KEY_NOTIFICATION_CHANNEL_NAME) + ?: NotificationConst.DEFAULT_VALUE_NOTIFICATION_CHANNEL_NAME + val notificationImportance = + extras?.getInt(NotificationConst.KEY_NOTIFICATION_CHANNEL_IMPORTANCE) + ?: NotificationConst.DEFAULT_VALUE_NOTIFICATION_CHANNEL_IMPORTANCE + val notificationChannelDescription = + extras?.getString(NotificationConst.KEY_NOTIFICATION_CHANNEL_DESCRIPTION) + ?: NotificationConst.DEFAULT_VALUE_NOTIFICATION_CHANNEL_DESCRIPTION + val notificationSmallIcon = + extras?.getInt(NotificationConst.KEY_NOTIFICATION_SMALL_ICON) + ?: NotificationConst.DEFAULT_VALUE_NOTIFICATION_SMALL_ICON + val fileName = extras?.getString(NotificationConst.KEY_FILE_NAME).orEmpty() + val totalBytes = extras?.getLong(NotificationConst.KEY_TOTAL_BYTES) ?: 0L + val currentProgress = if (totalBytes != 0L) { + (((extras?.getLong(DownloadWorker.KEY_DOWNLOADED_BYTES) + ?: 0) * 100) / totalBytes).toInt() + } else 0 + val requestId = extras?.getInt(NotificationConst.KEY_DOWNLOAD_REQUEST_ID) ?: -1 + + val notificationId = requestId + 1 // unique id for the notification + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel( + context = context, + notificationChannelName = notificationChannelName, + notificationImportance = notificationImportance, + notificationChannelDescription = notificationChannelDescription + ) + } + + // Open Application (Send the unique download request id in intent) + val intentOpen = + context.packageManager.getLaunchIntentForPackage(context.packageName) + intentOpen?.flags = + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + intentOpen?.putExtra(NotificationConst.KEY_DOWNLOAD_REQUEST_ID, requestId) + val pendingIntentOpen = PendingIntent.getActivity( + context.applicationContext, + notificationId, + intentOpen, + PendingIntent.FLAG_IMMUTABLE + ) + + // Resume Notification + val intentResume = Intent(context, NotificationReceiver::class.java).apply { + action = NotificationConst.ACTION_NOTIFICATION_RESUME_CLICK + } + intentResume.putExtra(NotificationConst.KEY_NOTIFICATION_ID, notificationId) + intentResume.putExtra(NotificationConst.KEY_DOWNLOAD_REQUEST_ID, requestId) + val pendingIntentResume = PendingIntent.getBroadcast( + context.applicationContext, + notificationId, + intentResume, + PendingIntent.FLAG_IMMUTABLE + ) + + // Retry Notification + val intentRetry = Intent(context, NotificationReceiver::class.java).apply { + action = NotificationConst.ACTION_NOTIFICATION_RETRY_CLICK + } + intentRetry.putExtra(NotificationConst.KEY_NOTIFICATION_ID, notificationId) + intentRetry.putExtra(NotificationConst.KEY_DOWNLOAD_REQUEST_ID, requestId) + val pendingIntentRetry = PendingIntent.getBroadcast( + context.applicationContext, + notificationId, + intentRetry, + PendingIntent.FLAG_IMMUTABLE + ) + + // Cancel Notification + val intentCancel = Intent(context, NotificationReceiver::class.java).apply { + action = NotificationConst.ACTION_NOTIFICATION_CANCEL_CLICK + } + intentCancel.putExtra(NotificationConst.KEY_NOTIFICATION_ID, notificationId) + intentCancel.putExtra(NotificationConst.KEY_DOWNLOAD_REQUEST_ID, requestId) + val pendingIntentCancel = PendingIntent.getBroadcast( + context.applicationContext, + notificationId, + intentCancel, + PendingIntent.FLAG_IMMUTABLE + ) + + var notificationBuilder = NotificationCompat.Builder( + context, + NotificationConst.NOTIFICATION_CHANNEL_ID + ) + .setSmallIcon(notificationSmallIcon) + .setContentText( + when (intent.action) { + NotificationConst.ACTION_DOWNLOAD_COMPLETED -> context.getString( + R.string.download_successful, + TextUtil.getTotalLengthText(totalBytes) + ) + + NotificationConst.ACTION_DOWNLOAD_FAILED -> + context.getString(R.string.download_failed) + + NotificationConst.ACTION_DOWNLOAD_PAUSED -> + context.getString(R.string.download_paused) + + else -> context.getString(R.string.download_cancelled) + } + ) + .setContentTitle(fileName) + .setContentIntent(pendingIntentOpen) + .setOnlyAlertOnce(true) + .setOngoing(false) + .setAutoCancel(true) + + // add retry and cancel button for failed download + if (intent.action == NotificationConst.ACTION_DOWNLOAD_FAILED) { + notificationBuilder = notificationBuilder.addAction( + -1, + context.getString(downloader.notificationConfig.retryText), + pendingIntentRetry + ).setProgress( + NotificationConst.MAX_VALUE_PROGRESS, + currentProgress, + false + ).addAction( + -1, + context.getString(downloader.notificationConfig.cancelText), + pendingIntentCancel + ).setSubText("$currentProgress%") + } + // add resume and cancel button for paused download + if (intent.action == NotificationConst.ACTION_DOWNLOAD_PAUSED) { + notificationBuilder = notificationBuilder.addAction( + -1, + context.getString(downloader.notificationConfig.resumeText), + pendingIntentResume, + ).setProgress( + NotificationConst.MAX_VALUE_PROGRESS, + currentProgress, + false + ).addAction( + -1, + context.getString(downloader.notificationConfig.cancelText), + pendingIntentCancel + ).setSubText("$currentProgress%") + } + + val notification = notificationBuilder.build() + NotificationManagerCompat.from(context).notify(notificationId, notification) + } + } + } + } + + /** + * Create notification channel for File downloads + */ + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel( + context: Context, + notificationChannelName: String, + notificationImportance: Int, + notificationChannelDescription: String + ) { + val channel = NotificationChannel( + NotificationConst.NOTIFICATION_CHANNEL_ID, + notificationChannelName, + notificationImportance + ) + channel.description = notificationChannelDescription + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } +} diff --git a/downloader/src/main/java/com/skyd/downloader/util/FileUtil.kt b/downloader/src/main/java/com/skyd/downloader/util/FileUtil.kt new file mode 100644 index 00000000..2c0b2468 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/util/FileUtil.kt @@ -0,0 +1,46 @@ +package com.skyd.downloader.util + +import android.os.Environment +import android.webkit.URLUtil +import java.io.File +import java.security.MessageDigest +import kotlin.experimental.and + +internal object FileUtil { + + internal val File.tempFile + get() = File("$absolutePath.temp") + + fun getFileNameFromUrl(url: String): String { + return URLUtil.guessFileName(url, null, null) + } + + fun getDefaultDownloadPath(): String { + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path + } + + fun getUniqueId(url: String, dirPath: String, fileName: String): Int { + val string = url + File.separator + dirPath + File.separator + fileName + val hash: ByteArray = try { + MessageDigest.getInstance("MD5").digest(string.toByteArray(charset("UTF-8"))) + } catch (e: Exception) { + return getUniqueIdFallback(url, dirPath, fileName) + } + val hex = StringBuilder(hash.size * 2) + for (b in hash) { + if (b and 0xFF.toByte() < 0x10) hex.append("0") + hex.append(Integer.toHexString((b and 0xFF.toByte()).toInt())) + } + return hex.toString().hashCode() + } + + private fun getUniqueIdFallback(url: String, dirPath: String, fileName: String): Int { + return (url.hashCode() * 31 + dirPath.hashCode()) * 31 + fileName.hashCode() + } + + fun deleteDownloadFileIfExists(path: String, name: String) { + val file = File(path, name) + file.deleteRecursively() + file.tempFile.deleteRecursively() + } +} diff --git a/downloader/src/main/java/com/skyd/downloader/util/NotificationUtil.kt b/downloader/src/main/java/com/skyd/downloader/util/NotificationUtil.kt new file mode 100644 index 00000000..12d42318 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/util/NotificationUtil.kt @@ -0,0 +1,13 @@ +package com.skyd.downloader.util + +import android.content.Context +import androidx.core.app.NotificationManagerCompat + +internal object NotificationUtil { + fun removeNotification(context: Context, notificationId: Int) { + // Downloading, Paused notification + NotificationManagerCompat.from(context).cancel(notificationId) + // Cancelled, Failed, Success notification + NotificationManagerCompat.from(context).cancel(notificationId + 1) + } +} diff --git a/downloader/src/main/java/com/skyd/downloader/util/TextUtil.kt b/downloader/src/main/java/com/skyd/downloader/util/TextUtil.kt new file mode 100644 index 00000000..2a1b32a3 --- /dev/null +++ b/downloader/src/main/java/com/skyd/downloader/util/TextUtil.kt @@ -0,0 +1,29 @@ +package com.skyd.downloader.util + +internal object TextUtil { + fun getSpeedText(speedInBPerMs: Float): String { + var value = speedInBPerMs * 1000 + val units = arrayOf("B/s", "KB/s", "MB/s", "GB/s") + var unitIndex = 0 + + while (value >= 500 && unitIndex < units.size - 1) { + value /= 1024 + unitIndex++ + } + + return "%.2f %s".format(value, units[unitIndex]) + } + + fun getTotalLengthText(lengthInBytes: Long): String { + var value = lengthInBytes.toFloat() + val units = arrayOf("B", "KB", "MB", "GB") + var unitIndex = 0 + + while (value >= 500 && unitIndex < units.size - 1) { + value /= 1024 + unitIndex++ + } + + return "%.2f %s".format(value, units[unitIndex]) + } +} \ No newline at end of file diff --git a/downloader/src/main/res/values-zh-rCN/strings.xml b/downloader/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 00000000..bd620645 --- /dev/null +++ b/downloader/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,8 @@ + + + 下载中 %s + 下载成功(%s) + 下载失败 + 暂停下载 + 取消下载 + \ No newline at end of file diff --git a/downloader/src/main/res/values/strings.xml b/downloader/src/main/res/values/strings.xml new file mode 100644 index 00000000..a32b857d --- /dev/null +++ b/downloader/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Downloading %s + Download successful (%s) + Download failed + Download paused + Download cancelled + \ No newline at end of file diff --git a/downloader/src/test/java/com/skyd/downloader/ExampleUnitTest.kt b/downloader/src/test/java/com/skyd/downloader/ExampleUnitTest.kt new file mode 100644 index 00000000..2c15aec0 --- /dev/null +++ b/downloader/src/test/java/com/skyd/downloader/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.skyd.downloader + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fb7f3c87..05853426 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,6 @@ composeMaterial3 = "1.4.0-alpha04" okhttp3 = "4.12.0" rome = "2.1.0" room = "2.6.1" - kotlin = "2.0.21" [libraries] @@ -84,8 +83,9 @@ androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", vers [plugins] android-application = { id = "com.android.application", version = "8.7.2" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version = "2.51.1" } ksp = { id = "com.google.devtools.ksp", version = "2.0.21-1.0.25" } +android-library = { id = "com.android.library", version = "8.7.2" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 1d58174f..b2a61226 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,4 +21,4 @@ dependencyResolutionManagement { rootProject.name = "AniVu" include(":app") - \ No newline at end of file +include(":downloader")