diff --git a/app/build.gradle.kts b/app/build.gradle.kts index deef9362..68c55a3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "com.skyd.anivu" minSdk = 24 targetSdk = 34 - versionCode = 2 - versionName = "1.0-beta04" + versionCode = 3 + versionName = "1.0-beta05" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/com/skyd/anivu/config/Const.kt b/app/src/main/java/com/skyd/anivu/config/Const.kt index 1e752911..24745ed7 100644 --- a/app/src/main/java/com/skyd/anivu/config/Const.kt +++ b/app/src/main/java/com/skyd/anivu/config/Const.kt @@ -25,4 +25,8 @@ object Const { val VIDEO_DIR = File(appContext.filesDir.path, "Video").apply { if (!exists()) mkdirs() } + + val TORRENT_RESUME_DATA_DIR = File(appContext.filesDir.path, "TorrentResumeData").apply { + if (!exists()) mkdirs() + } } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ext/ContextExt.kt b/app/src/main/java/com/skyd/anivu/ext/ContextExt.kt index 9953a0ca..d8dc3485 100644 --- a/app/src/main/java/com/skyd/anivu/ext/ContextExt.kt +++ b/app/src/main/java/com/skyd/anivu/ext/ContextExt.kt @@ -5,6 +5,7 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.ContextWrapper import android.content.Intent +import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.TypedArray @@ -81,6 +82,17 @@ fun Context.getAppVersionName(): String { return appVersionName } +fun Context.getAppName(): String? { + return try { + val packageInfo: PackageInfo = packageManager.getPackageInfo(packageName, 0) + val labelRes: Int = packageInfo.applicationInfo.labelRes + getString(labelRes) + } catch (e: Exception) { + e.printStackTrace() + null + } +} + fun Context.openBrowser(url: String) { try { val uri: Uri = Uri.parse(url) diff --git a/app/src/main/java/com/skyd/anivu/model/bean/DownloadInfoBean.kt b/app/src/main/java/com/skyd/anivu/model/bean/DownloadInfoBean.kt index 44e1d169..d1df6db5 100644 --- a/app/src/main/java/com/skyd/anivu/model/bean/DownloadInfoBean.kt +++ b/app/src/main/java/com/skyd/anivu/model/bean/DownloadInfoBean.kt @@ -3,10 +3,11 @@ package com.skyd.anivu.model.bean import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity -import androidx.room.ForeignKey +import androidx.room.Ignore import androidx.room.Index import com.skyd.anivu.base.BaseBean import com.skyd.anivu.ui.adapter.variety.Diff +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -43,11 +44,22 @@ data class DownloadInfoBean( @ColumnInfo(name = DOWNLOAD_REQUEST_ID_COLUMN) val downloadRequestId: String, ) : BaseBean, Parcelable, Diff { + @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 + Init, Downloading, Paused, Completed, ErrorPaused, StorageMovedFailed, Seeding, SeedingPaused } - companion object { const val LINK_COLUMN = "link" const val NAME_COLUMN = "name" @@ -61,6 +73,9 @@ data class DownloadInfoBean( 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" @@ -81,10 +96,49 @@ data class DownloadInfoBean( val list: MutableList = mutableListOf() if (progress != o.progress) list += PAYLOAD_PROGRESS if (description != o.description) list += PAYLOAD_DESCRIPTION + if (peerInfoList != o.peerInfoList) list += PAYLOAD_PEER_INFO + if (uploadPayloadRate != o.uploadPayloadRate) list += PAYLOAD_UPLOAD_PAYLOAD_RATE + if (downloadPayloadRate != o.downloadPayloadRate) list += PAYLOAD_DOWNLOAD_PAYLOAD_RATE if (downloadState != o.downloadState) list += PAYLOAD_DOWNLOAD_STATE if (name != o.name) list += PAYLOAD_NAME if (downloadingDirName != o.downloadingDirName) list += PAYLOAD_DOWNLOADING_DIR_NAME if (size != o.size) list += PAYLOAD_SIZE return list.ifEmpty { null } } + + 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 (downloadingDirName != other.downloadingDirName) 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 + downloadingDirName.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/PeerInfoBean.kt b/app/src/main/java/com/skyd/anivu/model/bean/PeerInfoBean.kt new file mode 100644 index 00000000..f4727dff --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/model/bean/PeerInfoBean.kt @@ -0,0 +1,41 @@ +package com.skyd.anivu.model.bean + +import android.os.Parcelable +import com.skyd.anivu.base.BaseBean +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import org.libtorrent4j.PeerInfo.ConnectionType + +@Parcelize +@Serializable +data class PeerInfoBean( + var client: String? = null, + var totalDownload: Long = 0, + var totalUpload: Long = 0, + var flags: Int = 0, + var source: Byte = 0, + var upSpeed: Int = 0, + var downSpeed: Int = 0, + var connectionType: ConnectionType? = null, + var progress: Float = 0f, + var progressPpm: Int = 0, + var ip: String? = null, +) : BaseBean, Parcelable { + companion object { + fun from(peerInfo: org.libtorrent4j.PeerInfo): PeerInfoBean { + return PeerInfoBean( + client = peerInfo.client(), + totalDownload = peerInfo.totalDownload(), + totalUpload = peerInfo.totalUpload(), + flags = peerInfo.flags(), + source = peerInfo.source(), + upSpeed = peerInfo.upSpeed(), + downSpeed = peerInfo.downSpeed(), + connectionType = peerInfo.connectionType(), + progress = peerInfo.progress(), + progressPpm = peerInfo.progressPpm(), + ip = peerInfo.ip() + ) + } + } +} \ No newline at end of file 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 ef68261c..40c4662a 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 @@ -5,7 +5,6 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters -import androidx.room.migration.Migration import com.skyd.anivu.model.bean.ArticleBean import com.skyd.anivu.model.bean.DownloadInfoBean import com.skyd.anivu.model.bean.DownloadLinkUuidMapBean @@ -47,7 +46,7 @@ abstract class AppDatabase : RoomDatabase() { @Volatile private var instance: AppDatabase? = null - private val migrations = arrayOf(Migration1To2()) + private val migrations = arrayOf(Migration1To2()) fun getInstance(context: Context): AppDatabase { return if (instance == null) { 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 204b3e14..d4b37c16 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 @@ -232,6 +232,22 @@ interface DownloadInfoDao { completedState: String = DownloadInfoBean.DownloadState.Completed.name ): Flow> + @Transaction + @Query( + """ + SELECT * FROM $DOWNLOAD_INFO_TABLE_NAME + """ + ) + fun getAllDownloadListFlow(): Flow> + + @Transaction + @Query( + """ + SELECT ${DownloadInfoBean.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 @@ -253,6 +269,15 @@ interface DownloadInfoDao { ) fun getDownloadLinkByUuid(uuid: String): String? + @Transaction + @Query( + """ + SELECT ${DownloadLinkUuidMapBean.UUID_COLUMN} FROM $DOWNLOAD_LINK_UUID_MAP_TABLE_NAME + WHERE ${DownloadLinkUuidMapBean.LINK_COLUMN} == :link + """ + ) + fun getDownloadUuidByLink(link: String): String? + @Transaction @Query( """ @@ -261,4 +286,13 @@ interface DownloadInfoDao { """ ) fun removeDownloadLinkByUuid(uuid: String): Int + + @Transaction + @Query( + """ + DELETE FROM $DOWNLOAD_LINK_UUID_MAP_TABLE_NAME + WHERE ${DownloadLinkUuidMapBean.LINK_COLUMN} == :link + """ + ) + fun removeDownloadLinkByLink(link: String): Int } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/model/repository/DownloadRepository.kt b/app/src/main/java/com/skyd/anivu/model/repository/DownloadRepository.kt index 200bbdef..0947c9a7 100644 --- a/app/src/main/java/com/skyd/anivu/model/repository/DownloadRepository.kt +++ b/app/src/main/java/com/skyd/anivu/model/repository/DownloadRepository.kt @@ -5,8 +5,11 @@ import com.skyd.anivu.config.Const import com.skyd.anivu.model.bean.DownloadInfoBean import com.skyd.anivu.model.db.dao.DownloadInfoDao import com.skyd.anivu.model.db.dao.SessionParamsDao +import com.skyd.anivu.model.worker.download.DownloadTorrentWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import java.io.File @@ -17,8 +20,22 @@ class DownloadRepository @Inject constructor( private val sessionParamsDao: SessionParamsDao, ) : BaseRepository() { fun requestDownloadingVideos(): Flow> { - return downloadInfoDao.getDownloadingListFlow() - .flowOn(Dispatchers.IO) + return combine( + downloadInfoDao.getAllDownloadListFlow().distinctUntilChanged(), + DownloadTorrentWorker.peerInfoMapFlow, + DownloadTorrentWorker.torrentStatusMapFlow, + ) { list, peerInfoMap, uploadPayloadRateMap -> + list.map { downloadInfoBean -> + downloadInfoBean.copy().apply { + peerInfoList = peerInfoMap.getOrDefault(downloadRequestId, emptyList()).toList() + val torrentStatus = uploadPayloadRateMap.get(downloadRequestId) + if (torrentStatus != null) { + uploadPayloadRate = torrentStatus.uploadPayloadRate() + downloadPayloadRate = torrentStatus.downloadPayloadRate() + } + } + } + }.flowOn(Dispatchers.IO) } suspend fun deleteDownloadTaskInfo( @@ -26,9 +43,17 @@ class DownloadRepository @Inject constructor( downloadingDirName: String, ): Flow { return flow { + if (downloadingDirName.isNotBlank()) { + File(Const.DOWNLOADING_VIDEO_DIR, downloadingDirName).deleteRecursively() + } + val requestUuid = downloadInfoDao.getDownloadInfo(link)?.downloadRequestId + if (!requestUuid.isNullOrBlank()) { + File(Const.TORRENT_RESUME_DATA_DIR, requestUuid).deleteRecursively() + } + // 这些最后删除,防止上面会使用 downloadInfoDao.deleteDownloadInfo(link) sessionParamsDao.deleteSessionParams(link) - File(Const.DOWNLOADING_VIDEO_DIR, downloadingDirName).deleteRecursively() + downloadInfoDao.removeDownloadLinkByLink(link) emit(Unit) }.flowOn(Dispatchers.IO) } 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 50c0996a..12a381f3 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 @@ -6,6 +6,7 @@ import android.content.Context import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE import android.database.sqlite.SQLiteConstraintException import android.os.Build +import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.navigation.NavDeepLinkBuilder @@ -21,18 +22,23 @@ import com.skyd.anivu.BuildConfig import com.skyd.anivu.R import com.skyd.anivu.appContext import com.skyd.anivu.config.Const +import com.skyd.anivu.ext.getAppName +import com.skyd.anivu.ext.getAppVersionName import com.skyd.anivu.ext.ifNullOfBlank import com.skyd.anivu.ext.saveTo import com.skyd.anivu.ext.toDecodedUrl import com.skyd.anivu.ext.validateFileName import com.skyd.anivu.model.bean.DownloadInfoBean import com.skyd.anivu.model.bean.DownloadLinkUuidMapBean +import com.skyd.anivu.model.bean.PeerInfoBean import com.skyd.anivu.model.bean.SessionParamsBean import com.skyd.anivu.model.db.dao.DownloadInfoDao import com.skyd.anivu.model.db.dao.SessionParamsDao import com.skyd.anivu.model.repository.DownloadRepository import com.skyd.anivu.model.service.HttpService import com.skyd.anivu.util.floatToPercentage +import com.skyd.anivu.util.torrent.readResumeData +import com.skyd.anivu.util.torrent.serializeResumeData import com.skyd.anivu.util.uniqueInt import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -43,31 +49,41 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import org.libtorrent4j.AlertListener +import org.libtorrent4j.MoveFlags import org.libtorrent4j.SessionManager import org.libtorrent4j.SessionParams import org.libtorrent4j.TorrentHandle import org.libtorrent4j.TorrentInfo +import org.libtorrent4j.TorrentStatus import org.libtorrent4j.alerts.Alert import org.libtorrent4j.alerts.FileErrorAlert import org.libtorrent4j.alerts.MetadataReceivedAlert +import org.libtorrent4j.alerts.PeerConnectAlert +import org.libtorrent4j.alerts.PeerDisconnectedAlert +import org.libtorrent4j.alerts.PeerInfoAlert +import org.libtorrent4j.alerts.SaveResumeDataAlert import org.libtorrent4j.alerts.StateChangedAlert +import org.libtorrent4j.alerts.StorageMovedAlert +import org.libtorrent4j.alerts.StorageMovedFailedAlert import org.libtorrent4j.alerts.TorrentAlert import org.libtorrent4j.alerts.TorrentErrorAlert import org.libtorrent4j.alerts.TorrentFinishedAlert +import org.libtorrent4j.swig.settings_pack import org.libtorrent4j.swig.torrent_flags_t import retrofit2.Retrofit import java.io.File import java.util.UUID import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.resumeWithException -import kotlin.random.Random class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : @@ -81,8 +97,9 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : } private var notificationContentText: String = "Starting Download" private var name: String? = null - private lateinit var tempDownloadingDirName: String + private var tempDownloadingDirName: String? = null private var description: String? = null + private var peerInfoList: MutableList = mutableListOf() private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -95,21 +112,23 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : coroutineContext.job.invokeOnCompletion { if (it is CancellationException) { this@DownloadTorrentWorker.pause(handle = null) + removeWorkerFromFlow(id.toString()) } } torrentLinkUuid = inputData.getString(TORRENT_LINK_UUID) ?: return@withContext Result.failure() - torrentLink = hiltEntryPoint.downloadInfoDao.getDownloadLinkByUuid(torrentLinkUuid) + val downloadInfoDao = hiltEntryPoint.downloadInfoDao + torrentLink = downloadInfoDao.getDownloadLinkByUuid(torrentLinkUuid) ?: return@withContext Result.failure() - name = hiltEntryPoint.downloadInfoDao.getDownloadName(link = torrentLink) - tempDownloadingDirName = hiltEntryPoint.downloadInfoDao - .getDownloadingDirName(link = torrentLink) - .ifNullOfBlank { "${System.currentTimeMillis()}_${Random.nextLong()}" } + name = downloadInfoDao.getDownloadName(link = torrentLink) + tempDownloadingDirName = downloadInfoDao.getDownloadingDirName(link = torrentLink) + progress = downloadInfoDao.getDownloadProgress(link = torrentLink) ?: 0f updateNotification() updateAllDownloadVideoInfoToDb() workerDownload() } hiltEntryPoint.downloadInfoDao.removeDownloadLinkByUuid(torrentLinkUuid) + removeWorkerFromFlow(id.toString()) return Result.success( workDataOf( STATE to (hiltEntryPoint.downloadInfoDao @@ -121,7 +140,8 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : private var sessionIsStopping: Boolean = false private suspend fun workerDownload( - saveDir: File = File(Const.DOWNLOADING_VIDEO_DIR, tempDownloadingDirName) + saveDir: File = if (tempDownloadingDirName.isNullOrBlank()) Const.DOWNLOADING_VIDEO_DIR + else File(Const.DOWNLOADING_VIDEO_DIR, tempDownloadingDirName!!) ) = suspendCancellableCoroutine { continuation -> if (!saveDir.exists() && !saveDir.mkdirs()) { continuation.resumeWithException(RuntimeException("Mkdirs failed: $saveDir")) @@ -159,7 +179,13 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : val sessionParams = if (lastSessionParams == null) SessionParams() else SessionParams(lastSessionParams.data) + sessionParams.settings = sessionParams.settings.setString( + settings_pack.string_types.user_agent.swigValue(), + "${applicationContext.getAppName() ?: "AniVu"}/${applicationContext.getAppVersionName()}" + ) + start(sessionParams) + startDht() if (hiltEntryPoint.downloadInfoDao.containsDownloadInfo(link = torrentLink) > 0) { hiltEntryPoint.downloadInfoDao.updateDownloadInfoRequestId( @@ -170,10 +196,16 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : when (hiltEntryPoint.downloadInfoDao.getDownloadState(link = torrentLink)) { null, // 重新下载 - DownloadInfoBean.DownloadState.Completed,/* -> { - stop() - continuation.resume(Unit, null) - }*/ + DownloadInfoBean.DownloadState.Seeding, + DownloadInfoBean.DownloadState.SeedingPaused, + DownloadInfoBean.DownloadState.Completed -> { + val resumeData = readResumeData(id.toString()) + if (resumeData != null) { + swig().async_add_torrent(resumeData) + } + updateDownloadStateAndSessionParams(DownloadInfoBean.DownloadState.Seeding) + } + DownloadInfoBean.DownloadState.Init -> { downloadByMagnetOrTorrent(torrentLink, saveDir) updateDownloadStateAndSessionParams(DownloadInfoBean.DownloadState.Downloading) @@ -181,6 +213,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : DownloadInfoBean.DownloadState.Downloading, DownloadInfoBean.DownloadState.ErrorPaused, + DownloadInfoBean.DownloadState.StorageMovedFailed, DownloadInfoBean.DownloadState.Paused -> { downloadByMagnetOrTorrent(torrentLink, saveDir) updateDownloadStateAndSessionParams(DownloadInfoBean.DownloadState.Downloading) @@ -192,9 +225,10 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : private fun downloadByMagnetOrTorrent( link: String, saveDir: File, + flags: torrent_flags_t = torrent_flags_t(), ) { if (link.startsWith("magnet:")) { - sessionManager.download(link, saveDir, torrent_flags_t()) + sessionManager.download(link, saveDir, flags) } else if ( (link.startsWith("http:") || link.startsWith("https:")) && link.endsWith(".torrent") @@ -206,7 +240,11 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : hiltEntryPoint.retrofit.create(HttpService::class.java) .requestGetResponseBody(link).execute().body()!!.byteStream() .use { it.saveTo(tempTorrentFile) } - sessionManager.download(TorrentInfo(tempTorrentFile), saveDir) + sessionManager.download( + TorrentInfo(tempTorrentFile), saveDir, + null, null, null, + flags + ) } else { error("Unsupported link: $link") } @@ -281,6 +319,10 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : private fun onAlert(continuation: CancellableContinuation, alert: Alert<*>) { when (alert) { + is SaveResumeDataAlert -> { + serializeResumeData(id.toString(), alert) + } + is TorrentErrorAlert -> { // 下载错误更新 this@DownloadTorrentWorker.pause( @@ -300,15 +342,30 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : continuation.resumeWithException(RuntimeException(alert.message())) } + is StorageMovedAlert -> { + alert.handle().saveResumeData() + updateNotificationAsync() // 更新Notification + updateDownloadStateAndSessionParams(DownloadInfoBean.DownloadState.Seeding) + } + + is StorageMovedFailedAlert -> { + // 文件移动,例如存储空间已满 + alert.handle().saveResumeData() + this@DownloadTorrentWorker.pause( + handle = alert.handle(), + state = DownloadInfoBean.DownloadState.StorageMovedFailed, + ) + } + is TorrentFinishedAlert -> { // 下载完成更新 val handle = alert.handle() progress = 1f name = handle.name + handle.saveResumeData() updateNotificationAsync() // 更新Notification + moveFromDownloadingDirToVideoDir(handle = handle) updateDownloadStateAndSessionParams(DownloadInfoBean.DownloadState.Completed) - moveFromDownloadingDirToVideoDir() - continuation.resume(Unit, null) } is MetadataReceivedAlert -> { @@ -321,6 +378,9 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : is StateChangedAlert -> { // 下载状态更新 + if (alert.state == TorrentStatus.State.SEEDING) { + updateDownloadStateAndSessionParams(DownloadInfoBean.DownloadState.Seeding) + } description = alert.state.toDisplayString(context = applicationContext) updateDescriptionInfoToDb() val handle = alert.handle() @@ -331,11 +391,27 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : } } + is PeerConnectAlert, + is PeerDisconnectedAlert, + is PeerInfoAlert -> { + val peerInfo = (alert as? TorrentAlert<*>) + ?.handle()?.peerInfo()?.map { PeerInfoBean.from(it) } + Log.e("TAG", "onAlert: ${peerInfo?.size}") + if (!peerInfo.isNullOrEmpty()) { + peerInfoMapFlow.tryEmit(peerInfoMapFlow.value.toMutableMap().apply { + put(id.toString(), peerInfo) + }) + } + } + is TorrentAlert<*> -> { // Log.e("TAG", "onAlert: ${alert}") // 下载进度更新 val handle = alert.handle() if (handle.isValid) { + torrentStatusMapFlow.tryEmit(torrentStatusMapFlow.value.toMutableMap().apply { + put(id.toString(), handle.status()) + }) if (progress != handle.status().progress()) { progress = handle.status().progress() updateNotificationAsync() // 更新Notification @@ -349,12 +425,20 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : private fun pause( handle: TorrentHandle?, - state: DownloadInfoBean.DownloadState = DownloadInfoBean.DownloadState.Paused + state: DownloadInfoBean.DownloadState = hiltEntryPoint.downloadInfoDao + .getDownloadState(link = torrentLink).let { + if (it == DownloadInfoBean.DownloadState.Seeding || + it == DownloadInfoBean.DownloadState.Completed || + it == DownloadInfoBean.DownloadState.SeedingPaused + ) DownloadInfoBean.DownloadState.Completed + else DownloadInfoBean.DownloadState.Paused + } ) { if (!sessionManager.isRunning || sessionIsStopping) { return } sessionIsStopping = true + updateSizeInfoToDb() updateDownloadStateAndSessionParams(state) if (handle != null) { handle.saveResumeData() @@ -366,13 +450,10 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : sessionManager.stop() } - private fun moveFromDownloadingDirToVideoDir() { - val downloadingDir = File(Const.DOWNLOADING_VIDEO_DIR, tempDownloadingDirName) - downloadingDir.listFiles()?.forEach { - it.copyRecursively(File(Const.VIDEO_DIR, it.name), true) - it.deleteRecursively() + private fun moveFromDownloadingDirToVideoDir(handle: TorrentHandle) { + if (handle.savePath() != Const.VIDEO_DIR.path && File(handle.savePath()).exists()) { + handle.moveStorage(Const.VIDEO_DIR.path, MoveFlags.ALWAYS_REPLACE_FILES) } - downloadingDir.deleteRecursively() } private fun updateDownloadStateAndSessionParams(downloadState: DownloadInfoBean.DownloadState) { @@ -483,7 +564,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : .toDecodedUrl() .validateFileName() }, - downloadingDirName = tempDownloadingDirName, + downloadingDirName = tempDownloadingDirName.orEmpty(), downloadDate = System.currentTimeMillis(), size = sessionManager.stats().totalDownload(), progress = progress, @@ -502,6 +583,18 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : private val coroutineScope = CoroutineScope(Dispatchers.IO) + val peerInfoMapFlow = MutableStateFlow(mutableMapOf>()) + val torrentStatusMapFlow = MutableStateFlow(mutableMapOf()) + + private fun removeWorkerFromFlow(requestId: String) { + peerInfoMapFlow.tryEmit( + peerInfoMapFlow.value.toMutableMap().apply { remove(requestId) } + ) + torrentStatusMapFlow.tryEmit( + torrentStatusMapFlow.value.toMutableMap().apply { remove(requestId) } + ) + } + @EntryPoint @InstallIn(SingletonComponent::class) interface WorkerEntryPoint { @@ -515,16 +608,22 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : appContext, WorkerEntryPoint::class.java ) - fun startWorker(context: Context, torrentLink: String) { - val torrentLinkUuid = UUID.randomUUID().toString() + fun startWorker(context: Context, torrentLink: String, requestId: String? = null) { coroutineScope.launch { - hiltEntryPoint.downloadInfoDao.setDownloadLinkUuidMap( - DownloadLinkUuidMapBean( - link = torrentLink, - uuid = torrentLinkUuid, + var torrentLinkUuid = + hiltEntryPoint.downloadInfoDao.getDownloadUuidByLink(torrentLink) + if (torrentLinkUuid == null) { + torrentLinkUuid = UUID.randomUUID().toString() + hiltEntryPoint.downloadInfoDao.setDownloadLinkUuidMap( + DownloadLinkUuidMapBean( + link = torrentLink, + uuid = torrentLinkUuid, + ) ) - ) + } + val sendLogsWorkRequest = OneTimeWorkRequestBuilder() + .run { if (requestId != null) setId(UUID.fromString(requestId)) else this } .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .setInputData(workDataOf(TORRENT_LINK_UUID to torrentLinkUuid)) .build() @@ -533,12 +632,52 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : ExistingWorkPolicy.KEEP, sendLogsWorkRequest ) + + val flow = WorkManager.getInstance(context) + .getWorkInfoByIdFlow(sendLogsWorkRequest.id) + coroutineScope.launch { + flow.filter { it == null || it.state.isFinished } + .onEach { + removeWorkerFromFlow(it.id.toString()) + }.collect { + cancel() + } + } } } - fun pause(context: Context, requestId: String) { - WorkManager.getInstance(context) - .cancelWorkById(UUID.fromString(requestId)) + fun pause( + context: Context, requestId: String, + link: String, + ) { + val uuid = UUID.fromString(requestId) + WorkManager.getInstance(context).apply { + if (getWorkInfoById(uuid).get().state.isFinished) { + coroutineScope.launch { + try { + val state = hiltEntryPoint.downloadInfoDao.getDownloadState(link) + val newState: DownloadInfoBean.DownloadState = + if (state == DownloadInfoBean.DownloadState.Seeding || + state == DownloadInfoBean.DownloadState.Completed || + state == DownloadInfoBean.DownloadState.SeedingPaused + ) { + DownloadInfoBean.DownloadState.SeedingPaused + } else { + DownloadInfoBean.DownloadState.Paused + } + hiltEntryPoint.downloadInfoDao.updateDownloadState( + link = link, + downloadState = newState, + ) + } catch (e: SQLiteConstraintException) { + // 捕获link外键约束异常 + e.printStackTrace() + } + } + } else { + cancelWorkById(uuid) + } + } } fun cancel( @@ -562,7 +701,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : cancel() } } - pause(context, requestId) + pause(context, requestId, link) } } } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/adapter/variety/proxy/Download1Proxy.kt b/app/src/main/java/com/skyd/anivu/ui/adapter/variety/proxy/Download1Proxy.kt index daff2533..83eb9825 100644 --- a/app/src/main/java/com/skyd/anivu/ui/adapter/variety/proxy/Download1Proxy.kt +++ b/app/src/main/java/com/skyd/anivu/ui/adapter/variety/proxy/Download1Proxy.kt @@ -7,6 +7,8 @@ import com.skyd.anivu.R import com.skyd.anivu.databinding.ItemDownload1Binding import com.skyd.anivu.ext.disable import com.skyd.anivu.ext.enable +import com.skyd.anivu.ext.fileSize +import com.skyd.anivu.ext.gone import com.skyd.anivu.model.bean.DownloadInfoBean import com.skyd.anivu.ui.adapter.variety.Download1ViewHolder import com.skyd.anivu.ui.adapter.variety.VarietyAdapter @@ -30,9 +32,16 @@ class Download1Proxy( if (data !is DownloadInfoBean) return@setOnClickListener when (data.downloadState) { + DownloadInfoBean.DownloadState.Seeding, DownloadInfoBean.DownloadState.Downloading -> onPause(data) + + DownloadInfoBean.DownloadState.SeedingPaused, DownloadInfoBean.DownloadState.Paused -> onResume(data) + + DownloadInfoBean.DownloadState.Completed, + DownloadInfoBean.DownloadState.StorageMovedFailed, DownloadInfoBean.DownloadState.ErrorPaused -> onResume(data) + else -> Unit } } @@ -58,6 +67,10 @@ class Download1Proxy( updateName(holder, data) updateProgress(holder, data) updateDescription(holder, data) + updateUploadPayloadRate(holder, data) + updateDownloadPayloadRate(holder, data) + updateSize(holder, data) + updatePeerInfo(holder, data) updateButtonProgressStateAndDescription(holder, data) } @@ -84,6 +97,22 @@ class Download1Proxy( updateDescription(holder, data) } + DownloadInfoBean.PAYLOAD_UPLOAD_PAYLOAD_RATE -> { + updateUploadPayloadRate(holder, data) + } + + DownloadInfoBean.PAYLOAD_DOWNLOAD_PAYLOAD_RATE -> { + updateDownloadPayloadRate(holder, data) + } + + DownloadInfoBean.PAYLOAD_SIZE -> { + updateSize(holder, data) + } + + DownloadInfoBean.PAYLOAD_PEER_INFO -> { + updatePeerInfo(holder, data) + } + DownloadInfoBean.PAYLOAD_DOWNLOAD_STATE -> { updateButtonProgressStateAndDescription(holder, data) } @@ -110,11 +139,14 @@ class Download1Proxy( holder.binding.apply { tvDownload1Progress.text = floatToPercentage(data.progress) when (data.downloadState) { + DownloadInfoBean.DownloadState.Seeding, + DownloadInfoBean.DownloadState.SeedingPaused, DownloadInfoBean.DownloadState.Downloading, + DownloadInfoBean.DownloadState.StorageMovedFailed, DownloadInfoBean.DownloadState.ErrorPaused, DownloadInfoBean.DownloadState.Paused -> { lpDownload1.isIndeterminate = false - lpDownload1.progress = (data.progress * 100).toInt() + lpDownload1.setProgress((data.progress * 100).toInt(), true) } DownloadInfoBean.DownloadState.Init -> { @@ -123,7 +155,7 @@ class Download1Proxy( DownloadInfoBean.DownloadState.Completed -> { lpDownload1.isIndeterminate = false - lpDownload1.progress = 100 + lpDownload1.setProgress(100, true) } } @@ -146,12 +178,61 @@ class Download1Proxy( } } + private fun updateUploadPayloadRate( + holder: Download1ViewHolder, + data: DownloadInfoBean, + ) { + holder.binding.tvDownload1UploadPayloadRate.text = holder.itemView.context.getString( + R.string.download_upload_payload_rate, + data.uploadPayloadRate.toLong().fileSize(holder.itemView.context) + "/s" + ) + } + + private fun updateDownloadPayloadRate( + holder: Download1ViewHolder, + data: DownloadInfoBean, + ) { + holder.binding.tvDownload1DownloadPayloadRate.text = holder.itemView.context.getString( + R.string.download_download_payload_rate, + data.downloadPayloadRate.toLong().fileSize(holder.itemView.context) + "/s" + ) + } + + private fun updateSize( + holder: Download1ViewHolder, + data: DownloadInfoBean, + ) { +// if (data.size != 0L) { +// holder.binding.tvDownload1Size.text = data.size.fileSize(holder.itemView.context) +// } else { +// holder.binding.tvDownload1Size.gone() +// } + } + + private fun updatePeerInfo( + holder: Download1ViewHolder, + data: DownloadInfoBean, + ) { + holder.binding.tvDownload1PeerCount.text = holder.itemView.context.getString( + R.string.download_peer_count, + data.peerInfoList.count() + ) + } + private fun updateButtonProgressStateAndDescription( holder: Download1ViewHolder, data: DownloadInfoBean, ) { holder.binding.apply { when (data.downloadState) { + DownloadInfoBean.DownloadState.Seeding -> { + btnDownload1Pause.enable() + btnDownload1Pause.setIconResource(R.drawable.ic_pause_24) + btnDownload1Cancel.enable() + tvDownload1Description.text = data.description + lpDownload1.isIndeterminate = false + } + DownloadInfoBean.DownloadState.Downloading -> { btnDownload1Pause.enable() btnDownload1Pause.setIconResource(R.drawable.ic_pause_24) @@ -160,6 +241,7 @@ class Download1Proxy( lpDownload1.isIndeterminate = false } + DownloadInfoBean.DownloadState.StorageMovedFailed, DownloadInfoBean.DownloadState.ErrorPaused -> { btnDownload1Pause.enable() btnDownload1Pause.setIconResource(R.drawable.ic_refresh_24) @@ -169,6 +251,7 @@ class Download1Proxy( lpDownload1.isIndeterminate = false } + DownloadInfoBean.DownloadState.SeedingPaused, DownloadInfoBean.DownloadState.Paused -> { btnDownload1Pause.enable() btnDownload1Pause.setIconResource(R.drawable.ic_play_arrow_24) @@ -188,8 +271,8 @@ class Download1Proxy( } DownloadInfoBean.DownloadState.Completed -> { - btnDownload1Pause.disable() - btnDownload1Pause.setIconResource(R.drawable.ic_play_arrow_24) + btnDownload1Pause.enable() + btnDownload1Pause.setIconResource(R.drawable.ic_cloud_upload_24) btnDownload1Cancel.enable() tvDownload1Description.text = holder.itemView.context.getString(R.string.download_completed) diff --git a/app/src/main/java/com/skyd/anivu/ui/fragment/download/DownloadFragment.kt b/app/src/main/java/com/skyd/anivu/ui/fragment/download/DownloadFragment.kt index 785e395a..2fd7d625 100644 --- a/app/src/main/java/com/skyd/anivu/ui/fragment/download/DownloadFragment.kt +++ b/app/src/main/java/com/skyd/anivu/ui/fragment/download/DownloadFragment.kt @@ -38,12 +38,14 @@ class DownloadFragment : BaseFragment() { DownloadTorrentWorker.pause( context = requireContext(), requestId = it.downloadRequestId, + link = it.link, ) }, onResume = { video -> DownloadTorrentWorker.startWorker( context = requireContext(), torrentLink = video.link, + requestId = video.downloadRequestId, ) }, onCancel = { video -> diff --git a/app/src/main/java/com/skyd/anivu/ui/player/PlayerGestureDetector.kt b/app/src/main/java/com/skyd/anivu/ui/player/PlayerGestureDetector.kt index 62488cff..0b4d89da 100644 --- a/app/src/main/java/com/skyd/anivu/ui/player/PlayerGestureDetector.kt +++ b/app/src/main/java/com/skyd/anivu/ui/player/PlayerGestureDetector.kt @@ -188,7 +188,7 @@ class PlayerGestureDetector( if (longPressed && event.pointerCount == 1) { handled = mListener.onLongPressUp() longPressed = false - } else if (singleMove > 0/* && event.pointerCount == 1*/) { + } else if (singleMove == 2/* && event.pointerCount == 1*/) { // 单指滑动了一段距离,第二个手指又落下滑动,然后两个手指抬起 // 这时候应该只响应单指滑动,因为它先产生的。 // 所以这时event.pointerCount可能不1,所以不能加&& event.pointerCount == 1 diff --git a/app/src/main/java/com/skyd/anivu/util/torrent/Util.kt b/app/src/main/java/com/skyd/anivu/util/torrent/Util.kt new file mode 100644 index 00000000..5c900dad --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/util/torrent/Util.kt @@ -0,0 +1,40 @@ +package com.skyd.anivu.util.torrent + +import android.util.Log +import com.skyd.anivu.config.Const +import org.libtorrent4j.Vectors +import org.libtorrent4j.alerts.SaveResumeDataAlert +import org.libtorrent4j.swig.add_torrent_params +import org.libtorrent4j.swig.error_code +import org.libtorrent4j.swig.libtorrent +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + + +fun serializeResumeData(name: String, alert: SaveResumeDataAlert) { + val resume = File(Const.TORRENT_RESUME_DATA_DIR, name) + if (!resume.exists()) resume.createNewFile() + val data = libtorrent.write_resume_data(alert.params().swig()).bencode() + try { + FileOutputStream(resume).use { it.write(Vectors.byte_vector2bytes(data)) } + } catch (e: IOException) { + Log.e("serializeResumeData", "Error saving resume data") + } +} + +fun readResumeData(name: String): add_torrent_params? { + val resume = File(Const.TORRENT_RESUME_DATA_DIR, name) + if (!resume.exists()) return null + try { + val data = resume.readBytes() + val ec = error_code() + val p: add_torrent_params = + libtorrent.read_resume_data_ex(Vectors.bytes2byte_vector(data), ec) + require(ec.value() == 0) { "Unable to read the resume data: " + ec.message() } + return p + } catch (e: Throwable) { + Log.w("readResumeData", "Unable to set resume data: $e") + } + return null +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_cloud_upload_24.xml b/app/src/main/res/drawable/ic_cloud_upload_24.xml new file mode 100644 index 00000000..538f38d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud_upload_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/item_download_1.xml b/app/src/main/res/layout/item_download_1.xml index 918392f9..05599069 100644 --- a/app/src/main/res/layout/item_download_1.xml +++ b/app/src/main/res/layout/item_download_1.xml @@ -26,17 +26,33 @@ + + + + + + 已暂停 初始化中… 已完成 + 用户:%d 播放器 检查文件中… diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index fedafdc6..41f6c617 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -9,6 +9,6 @@ 50dp 100dp 40dp - 40dp + 25dp \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2bcd5d94..1a58f75b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,9 @@ Paused Initializing… Completed + Peer(s): %d + %s ↑ + %s ↓ Player Checking files…