mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-28 11:58:50 +05:00
feat: Add TorrentEngine library and new API client
- Created complete TorrentEngine library module with LibTorrent4j - Full torrent management (add, pause, resume, remove) - Magnet link metadata extraction - File priority management (even during download) - Foreground service with persistent notification - Room database for state persistence - Reactive Flow API for UI updates - Integrated TorrentEngine with MainActivity via MethodChannel - addTorrent, getTorrents, pauseTorrent, resumeTorrent, removeTorrent - setFilePriority for dynamic file selection - Full JSON serialization for Flutter communication - Created new NeoMoviesApiClient for Go-based backend - Email verification flow (register, verify, resendCode) - Google OAuth support - Torrent search via RedAPI - Multiple player support (Alloha, Lumex, Vibix) - Enhanced reactions system (likes/dislikes) - All movies/TV shows endpoints - Updated dependencies and build configuration - Java 17 compatibility - Updated Kotlin coroutines to 1.9.0 - Fixed build_runner version conflict - Added torrentengine module to settings.gradle.kts - Added comprehensive documentation - TorrentEngine README with usage examples - DEVELOPMENT_SUMMARY with full implementation details - ProGuard rules for library This is a complete rewrite of torrent functionality as a reusable library.
This commit is contained in:
38
android/torrentengine/src/main/AndroidManifest.xml
Normal file
38
android/torrentengine/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Permissions for torrent engine -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
android:minSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application>
|
||||
<!-- Torrent Foreground Service -->
|
||||
<service
|
||||
android:name=".service.TorrentService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Work Manager for background tasks -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,552 @@
|
||||
package com.neomovies.torrentengine
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.neomovies.torrentengine.database.TorrentDao
|
||||
import com.neomovies.torrentengine.database.TorrentDatabase
|
||||
import com.neomovies.torrentengine.models.*
|
||||
import com.neomovies.torrentengine.service.TorrentService
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.libtorrent4j.*
|
||||
import org.libtorrent4j.alerts.*
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Main TorrentEngine class - the core of the torrent library
|
||||
* This is the main API that applications should use
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* val engine = TorrentEngine.getInstance(context)
|
||||
* engine.addTorrent(magnetUri, savePath)
|
||||
* ```
|
||||
*/
|
||||
class TorrentEngine private constructor(private val context: Context) {
|
||||
private val TAG = "TorrentEngine"
|
||||
|
||||
// LibTorrent session
|
||||
private var session: SessionManager? = null
|
||||
private var isSessionStarted = false
|
||||
|
||||
// Database
|
||||
private val database: TorrentDatabase = TorrentDatabase.getDatabase(context)
|
||||
private val torrentDao: TorrentDao = database.torrentDao()
|
||||
|
||||
// Coroutine scope
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Active torrent handles
|
||||
private val torrentHandles = mutableMapOf<String, TorrentHandle>()
|
||||
|
||||
// Settings
|
||||
private val settings = SettingsPack().apply {
|
||||
setInteger(SettingsPack.Key.ALERT_MASK.value(), Alert.Category.ALL.swig())
|
||||
setBoolean(SettingsPack.Key.ENABLE_DHT.value(), true)
|
||||
setBoolean(SettingsPack.Key.ENABLE_LSD.value(), true)
|
||||
setString(SettingsPack.Key.USER_AGENT.value(), "NeoMovies/1.0 libtorrent4j/2.1.0")
|
||||
}
|
||||
|
||||
init {
|
||||
startSession()
|
||||
restoreTorrents()
|
||||
startAlertListener()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start LibTorrent session
|
||||
*/
|
||||
private fun startSession() {
|
||||
try {
|
||||
session = SessionManager()
|
||||
session?.start(settings)
|
||||
isSessionStarted = true
|
||||
Log.d(TAG, "LibTorrent session started")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start session", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore torrents from database on startup
|
||||
*/
|
||||
private fun restoreTorrents() {
|
||||
scope.launch {
|
||||
try {
|
||||
val torrents = torrentDao.getAllTorrents()
|
||||
Log.d(TAG, "Restoring ${torrents.size} torrents from database")
|
||||
|
||||
torrents.forEach { torrent ->
|
||||
if (torrent.state in arrayOf(TorrentState.DOWNLOADING, TorrentState.SEEDING)) {
|
||||
// Resume active torrents
|
||||
addTorrentInternal(torrent.magnetUri, torrent.savePath, torrent)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to restore torrents", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start alert listener for torrent events
|
||||
*/
|
||||
private fun startAlertListener() {
|
||||
scope.launch {
|
||||
while (isActive && isSessionStarted) {
|
||||
try {
|
||||
session?.let { sess ->
|
||||
val alerts = sess.popAlerts()
|
||||
for (alert in alerts) {
|
||||
handleAlert(alert)
|
||||
}
|
||||
}
|
||||
delay(1000) // Check every second
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in alert listener", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle LibTorrent alerts
|
||||
*/
|
||||
private fun handleAlert(alert: Alert<*>) {
|
||||
when (alert.type()) {
|
||||
AlertType.METADATA_RECEIVED -> handleMetadataReceived(alert as MetadataReceivedAlert)
|
||||
AlertType.TORRENT_FINISHED -> handleTorrentFinished(alert as TorrentFinishedAlert)
|
||||
AlertType.TORRENT_ERROR -> handleTorrentError(alert as TorrentErrorAlert)
|
||||
AlertType.STATE_CHANGED -> handleStateChanged(alert as StateChangedAlert)
|
||||
AlertType.TORRENT_CHECKED -> handleTorrentChecked(alert as TorrentCheckedAlert)
|
||||
else -> { /* Ignore other alerts */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle metadata received (from magnet link)
|
||||
*/
|
||||
private fun handleMetadataReceived(alert: MetadataReceivedAlert) {
|
||||
scope.launch {
|
||||
try {
|
||||
val handle = alert.handle()
|
||||
val infoHash = handle.infoHash().toHex()
|
||||
|
||||
Log.d(TAG, "Metadata received for $infoHash")
|
||||
|
||||
// Extract file information
|
||||
val torrentInfo = handle.torrentFile()
|
||||
val files = mutableListOf<TorrentFile>()
|
||||
|
||||
for (i in 0 until torrentInfo.numFiles()) {
|
||||
val fileStorage = torrentInfo.files()
|
||||
files.add(
|
||||
TorrentFile(
|
||||
index = i,
|
||||
path = fileStorage.filePath(i),
|
||||
size = fileStorage.fileSize(i),
|
||||
priority = FilePriority.NORMAL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Update database
|
||||
val existingTorrent = torrentDao.getTorrent(infoHash)
|
||||
existingTorrent?.let {
|
||||
torrentDao.updateTorrent(
|
||||
it.copy(
|
||||
name = torrentInfo.name(),
|
||||
totalSize = torrentInfo.totalSize(),
|
||||
files = files,
|
||||
state = TorrentState.DOWNLOADING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
torrentHandles[infoHash] = handle
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling metadata", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle torrent finished
|
||||
*/
|
||||
private fun handleTorrentFinished(alert: TorrentFinishedAlert) {
|
||||
scope.launch {
|
||||
val handle = alert.handle()
|
||||
val infoHash = handle.infoHash().toHex()
|
||||
Log.d(TAG, "Torrent finished: $infoHash")
|
||||
|
||||
torrentDao.updateTorrentState(infoHash, TorrentState.FINISHED)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle torrent error
|
||||
*/
|
||||
private fun handleTorrentError(alert: TorrentErrorAlert) {
|
||||
scope.launch {
|
||||
val handle = alert.handle()
|
||||
val infoHash = handle.infoHash().toHex()
|
||||
val error = alert.error().message()
|
||||
|
||||
Log.e(TAG, "Torrent error: $infoHash - $error")
|
||||
torrentDao.setTorrentError(infoHash, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle state changed
|
||||
*/
|
||||
private fun handleStateChanged(alert: StateChangedAlert) {
|
||||
scope.launch {
|
||||
val handle = alert.handle()
|
||||
val infoHash = handle.infoHash().toHex()
|
||||
val state = when (alert.state()) {
|
||||
TorrentStatus.State.CHECKING_FILES -> TorrentState.CHECKING
|
||||
TorrentStatus.State.DOWNLOADING_METADATA -> TorrentState.METADATA_DOWNLOADING
|
||||
TorrentStatus.State.DOWNLOADING -> TorrentState.DOWNLOADING
|
||||
TorrentStatus.State.FINISHED, TorrentStatus.State.SEEDING -> TorrentState.SEEDING
|
||||
else -> TorrentState.STOPPED
|
||||
}
|
||||
|
||||
torrentDao.updateTorrentState(infoHash, state)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle torrent checked
|
||||
*/
|
||||
private fun handleTorrentChecked(alert: TorrentCheckedAlert) {
|
||||
scope.launch {
|
||||
val handle = alert.handle()
|
||||
val infoHash = handle.infoHash().toHex()
|
||||
Log.d(TAG, "Torrent checked: $infoHash")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add torrent from magnet URI
|
||||
*
|
||||
* @param magnetUri Magnet link
|
||||
* @param savePath Directory to save files
|
||||
* @return Info hash of the torrent
|
||||
*/
|
||||
suspend fun addTorrent(magnetUri: String, savePath: String): String {
|
||||
return withContext(Dispatchers.IO) {
|
||||
addTorrentInternal(magnetUri, savePath, null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to add torrent
|
||||
*/
|
||||
private suspend fun addTorrentInternal(
|
||||
magnetUri: String,
|
||||
savePath: String,
|
||||
existingTorrent: TorrentInfo?
|
||||
): String {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Parse magnet URI
|
||||
val error = ErrorCode()
|
||||
val params = SessionHandle.parseMagnetUri(magnetUri, error)
|
||||
|
||||
if (error.value() != 0) {
|
||||
throw Exception("Invalid magnet URI: ${error.message()}")
|
||||
}
|
||||
|
||||
val infoHash = params.infoHash().toHex()
|
||||
|
||||
// Check if already exists
|
||||
val existing = existingTorrent ?: torrentDao.getTorrent(infoHash)
|
||||
if (existing != null && torrentHandles.containsKey(infoHash)) {
|
||||
Log.d(TAG, "Torrent already exists: $infoHash")
|
||||
return@withContext infoHash
|
||||
}
|
||||
|
||||
// Set save path
|
||||
val saveDir = File(savePath)
|
||||
if (!saveDir.exists()) {
|
||||
saveDir.mkdirs()
|
||||
}
|
||||
params.savePath(saveDir.absolutePath)
|
||||
|
||||
// Add to session
|
||||
val handle = session?.swig()?.addTorrent(params, error)
|
||||
?: throw Exception("Session not initialized")
|
||||
|
||||
if (error.value() != 0) {
|
||||
throw Exception("Failed to add torrent: ${error.message()}")
|
||||
}
|
||||
|
||||
torrentHandles[infoHash] = TorrentHandle(handle)
|
||||
|
||||
// Save to database
|
||||
val torrentInfo = TorrentInfo(
|
||||
infoHash = infoHash,
|
||||
magnetUri = magnetUri,
|
||||
name = existingTorrent?.name ?: "Loading...",
|
||||
savePath = saveDir.absolutePath,
|
||||
state = TorrentState.METADATA_DOWNLOADING
|
||||
)
|
||||
torrentDao.insertTorrent(torrentInfo)
|
||||
|
||||
// Start foreground service
|
||||
startService()
|
||||
|
||||
Log.d(TAG, "Torrent added: $infoHash")
|
||||
infoHash
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to add torrent", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume torrent
|
||||
*/
|
||||
suspend fun resumeTorrent(infoHash: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
torrentHandles[infoHash]?.resume()
|
||||
torrentDao.updateTorrentState(infoHash, TorrentState.DOWNLOADING)
|
||||
startService()
|
||||
Log.d(TAG, "Torrent resumed: $infoHash")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to resume torrent", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause torrent
|
||||
*/
|
||||
suspend fun pauseTorrent(infoHash: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
torrentHandles[infoHash]?.pause()
|
||||
torrentDao.updateTorrentState(infoHash, TorrentState.STOPPED)
|
||||
Log.d(TAG, "Torrent paused: $infoHash")
|
||||
|
||||
// Stop service if no active torrents
|
||||
if (torrentDao.getActiveTorrents().isEmpty()) {
|
||||
stopService()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to pause torrent", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove torrent
|
||||
*
|
||||
* @param infoHash Torrent info hash
|
||||
* @param deleteFiles Whether to delete downloaded files
|
||||
*/
|
||||
suspend fun removeTorrent(infoHash: String, deleteFiles: Boolean = false) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val handle = torrentHandles[infoHash]
|
||||
if (handle != null) {
|
||||
session?.remove(handle)
|
||||
torrentHandles.remove(infoHash)
|
||||
}
|
||||
|
||||
if (deleteFiles) {
|
||||
val torrent = torrentDao.getTorrent(infoHash)
|
||||
torrent?.let {
|
||||
val dir = File(it.savePath)
|
||||
if (dir.exists()) {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
torrentDao.deleteTorrentByHash(infoHash)
|
||||
Log.d(TAG, "Torrent removed: $infoHash")
|
||||
|
||||
// Stop service if no active torrents
|
||||
if (torrentDao.getActiveTorrents().isEmpty()) {
|
||||
stopService()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove torrent", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set file priority in torrent
|
||||
* This allows selecting/deselecting files even after torrent is started
|
||||
*
|
||||
* @param infoHash Torrent info hash
|
||||
* @param fileIndex File index
|
||||
* @param priority File priority
|
||||
*/
|
||||
suspend fun setFilePriority(infoHash: String, fileIndex: Int, priority: FilePriority) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val handle = torrentHandles[infoHash] ?: return@withContext
|
||||
handle.filePriority(fileIndex, Priority.getValue(priority.value))
|
||||
|
||||
// Update database
|
||||
val torrent = torrentDao.getTorrent(infoHash) ?: return@withContext
|
||||
val updatedFiles = torrent.files.mapIndexed { index, file ->
|
||||
if (index == fileIndex) file.copy(priority = priority) else file
|
||||
}
|
||||
torrentDao.updateTorrent(torrent.copy(files = updatedFiles))
|
||||
|
||||
Log.d(TAG, "File priority updated: $infoHash, file $fileIndex, priority $priority")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set file priority", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple file priorities at once
|
||||
*/
|
||||
suspend fun setFilePriorities(infoHash: String, priorities: Map<Int, FilePriority>) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val handle = torrentHandles[infoHash] ?: return@withContext
|
||||
|
||||
priorities.forEach { (fileIndex, priority) ->
|
||||
handle.filePriority(fileIndex, Priority.getValue(priority.value))
|
||||
}
|
||||
|
||||
// Update database
|
||||
val torrent = torrentDao.getTorrent(infoHash) ?: return@withContext
|
||||
val updatedFiles = torrent.files.mapIndexed { index, file ->
|
||||
priorities[index]?.let { file.copy(priority = it) } ?: file
|
||||
}
|
||||
torrentDao.updateTorrent(torrent.copy(files = updatedFiles))
|
||||
|
||||
Log.d(TAG, "Multiple file priorities updated: $infoHash")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set file priorities", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get torrent info
|
||||
*/
|
||||
suspend fun getTorrent(infoHash: String): TorrentInfo? {
|
||||
return torrentDao.getTorrent(infoHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all torrents
|
||||
*/
|
||||
suspend fun getAllTorrents(): List<TorrentInfo> {
|
||||
return torrentDao.getAllTorrents()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get torrents as Flow (reactive updates)
|
||||
*/
|
||||
fun getAllTorrentsFlow(): Flow<List<TorrentInfo>> {
|
||||
return torrentDao.getAllTorrentsFlow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update torrent statistics
|
||||
*/
|
||||
private suspend fun updateTorrentStats() {
|
||||
withContext(Dispatchers.IO) {
|
||||
torrentHandles.forEach { (infoHash, handle) ->
|
||||
try {
|
||||
val status = handle.status()
|
||||
|
||||
torrentDao.updateTorrentProgress(
|
||||
infoHash,
|
||||
status.progress(),
|
||||
status.totalDone()
|
||||
)
|
||||
|
||||
torrentDao.updateTorrentSpeeds(
|
||||
infoHash,
|
||||
status.downloadRate(),
|
||||
status.uploadRate()
|
||||
)
|
||||
|
||||
torrentDao.updateTorrentPeers(
|
||||
infoHash,
|
||||
status.numPeers(),
|
||||
status.numSeeds()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error updating torrent stats for $infoHash", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic stats update
|
||||
*/
|
||||
fun startStatsUpdater() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
updateTorrentStats()
|
||||
delay(1000) // Update every second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start foreground service
|
||||
*/
|
||||
private fun startService() {
|
||||
try {
|
||||
val intent = Intent(context, TorrentService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start service", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop foreground service
|
||||
*/
|
||||
private fun stopService() {
|
||||
try {
|
||||
val intent = Intent(context, TorrentService::class.java)
|
||||
context.stopService(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to stop service", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown engine
|
||||
*/
|
||||
fun shutdown() {
|
||||
scope.cancel()
|
||||
session?.stop()
|
||||
isSessionStarted = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: TorrentEngine? = null
|
||||
|
||||
/**
|
||||
* Get TorrentEngine singleton instance
|
||||
*/
|
||||
fun getInstance(context: Context): TorrentEngine {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = TorrentEngine(context.applicationContext)
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.neomovies.torrentengine.database
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.neomovies.torrentengine.models.TorrentFile
|
||||
import com.neomovies.torrentengine.models.TorrentState
|
||||
|
||||
/**
|
||||
* Type converters for Room database
|
||||
*/
|
||||
class Converters {
|
||||
private val gson = Gson()
|
||||
|
||||
@TypeConverter
|
||||
fun fromTorrentState(value: TorrentState): String = value.name
|
||||
|
||||
@TypeConverter
|
||||
fun toTorrentState(value: String): TorrentState = TorrentState.valueOf(value)
|
||||
|
||||
@TypeConverter
|
||||
fun fromTorrentFileList(value: List<TorrentFile>): String = gson.toJson(value)
|
||||
|
||||
@TypeConverter
|
||||
fun toTorrentFileList(value: String): List<TorrentFile> {
|
||||
val listType = object : TypeToken<List<TorrentFile>>() {}.type
|
||||
return gson.fromJson(value, listType)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringList(value: List<String>): String = gson.toJson(value)
|
||||
|
||||
@TypeConverter
|
||||
fun toStringList(value: String): List<String> {
|
||||
val listType = object : TypeToken<List<String>>() {}.type
|
||||
return gson.fromJson(value, listType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.neomovies.torrentengine.database
|
||||
|
||||
import androidx.room.*
|
||||
import com.neomovies.torrentengine.models.TorrentInfo
|
||||
import com.neomovies.torrentengine.models.TorrentState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Data Access Object for torrent operations
|
||||
*/
|
||||
@Dao
|
||||
interface TorrentDao {
|
||||
/**
|
||||
* Get all torrents as Flow (reactive updates)
|
||||
*/
|
||||
@Query("SELECT * FROM torrents ORDER BY addedDate DESC")
|
||||
fun getAllTorrentsFlow(): Flow<List<TorrentInfo>>
|
||||
|
||||
/**
|
||||
* Get all torrents (one-time fetch)
|
||||
*/
|
||||
@Query("SELECT * FROM torrents ORDER BY addedDate DESC")
|
||||
suspend fun getAllTorrents(): List<TorrentInfo>
|
||||
|
||||
/**
|
||||
* Get torrent by info hash
|
||||
*/
|
||||
@Query("SELECT * FROM torrents WHERE infoHash = :infoHash")
|
||||
suspend fun getTorrent(infoHash: String): TorrentInfo?
|
||||
|
||||
/**
|
||||
* Get torrent by info hash as Flow
|
||||
*/
|
||||
@Query("SELECT * FROM torrents WHERE infoHash = :infoHash")
|
||||
fun getTorrentFlow(infoHash: String): Flow<TorrentInfo?>
|
||||
|
||||
/**
|
||||
* Get torrents by state
|
||||
*/
|
||||
@Query("SELECT * FROM torrents WHERE state = :state ORDER BY addedDate DESC")
|
||||
suspend fun getTorrentsByState(state: TorrentState): List<TorrentInfo>
|
||||
|
||||
/**
|
||||
* Get active torrents (downloading or seeding)
|
||||
*/
|
||||
@Query("SELECT * FROM torrents WHERE state IN ('DOWNLOADING', 'SEEDING', 'METADATA_DOWNLOADING') ORDER BY addedDate DESC")
|
||||
suspend fun getActiveTorrents(): List<TorrentInfo>
|
||||
|
||||
/**
|
||||
* Get active torrents as Flow
|
||||
*/
|
||||
@Query("SELECT * FROM torrents WHERE state IN ('DOWNLOADING', 'SEEDING', 'METADATA_DOWNLOADING') ORDER BY addedDate DESC")
|
||||
fun getActiveTorrentsFlow(): Flow<List<TorrentInfo>>
|
||||
|
||||
/**
|
||||
* Insert or update torrent
|
||||
*/
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertTorrent(torrent: TorrentInfo)
|
||||
|
||||
/**
|
||||
* Insert or update multiple torrents
|
||||
*/
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertTorrents(torrents: List<TorrentInfo>)
|
||||
|
||||
/**
|
||||
* Update torrent
|
||||
*/
|
||||
@Update
|
||||
suspend fun updateTorrent(torrent: TorrentInfo)
|
||||
|
||||
/**
|
||||
* Delete torrent
|
||||
*/
|
||||
@Delete
|
||||
suspend fun deleteTorrent(torrent: TorrentInfo)
|
||||
|
||||
/**
|
||||
* Delete torrent by info hash
|
||||
*/
|
||||
@Query("DELETE FROM torrents WHERE infoHash = :infoHash")
|
||||
suspend fun deleteTorrentByHash(infoHash: String)
|
||||
|
||||
/**
|
||||
* Delete all torrents
|
||||
*/
|
||||
@Query("DELETE FROM torrents")
|
||||
suspend fun deleteAllTorrents()
|
||||
|
||||
/**
|
||||
* Get total torrents count
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM torrents")
|
||||
suspend fun getTorrentsCount(): Int
|
||||
|
||||
/**
|
||||
* Update torrent state
|
||||
*/
|
||||
@Query("UPDATE torrents SET state = :state WHERE infoHash = :infoHash")
|
||||
suspend fun updateTorrentState(infoHash: String, state: TorrentState)
|
||||
|
||||
/**
|
||||
* Update torrent progress
|
||||
*/
|
||||
@Query("UPDATE torrents SET progress = :progress, downloadedSize = :downloadedSize WHERE infoHash = :infoHash")
|
||||
suspend fun updateTorrentProgress(infoHash: String, progress: Float, downloadedSize: Long)
|
||||
|
||||
/**
|
||||
* Update torrent speeds
|
||||
*/
|
||||
@Query("UPDATE torrents SET downloadSpeed = :downloadSpeed, uploadSpeed = :uploadSpeed WHERE infoHash = :infoHash")
|
||||
suspend fun updateTorrentSpeeds(infoHash: String, downloadSpeed: Int, uploadSpeed: Int)
|
||||
|
||||
/**
|
||||
* Update torrent peers/seeds
|
||||
*/
|
||||
@Query("UPDATE torrents SET numPeers = :numPeers, numSeeds = :numSeeds WHERE infoHash = :infoHash")
|
||||
suspend fun updateTorrentPeers(infoHash: String, numPeers: Int, numSeeds: Int)
|
||||
|
||||
/**
|
||||
* Set torrent error
|
||||
*/
|
||||
@Query("UPDATE torrents SET error = :error, state = 'ERROR' WHERE infoHash = :infoHash")
|
||||
suspend fun setTorrentError(infoHash: String, error: String)
|
||||
|
||||
/**
|
||||
* Clear torrent error
|
||||
*/
|
||||
@Query("UPDATE torrents SET error = NULL WHERE infoHash = :infoHash")
|
||||
suspend fun clearTorrentError(infoHash: String)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.neomovies.torrentengine.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.neomovies.torrentengine.models.TorrentInfo
|
||||
|
||||
/**
|
||||
* Room database for torrent persistence
|
||||
*/
|
||||
@Database(
|
||||
entities = [TorrentInfo::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class TorrentDatabase : RoomDatabase() {
|
||||
abstract fun torrentDao(): TorrentDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: TorrentDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): TorrentDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
TorrentDatabase::class.java,
|
||||
"torrent_database"
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.neomovies.torrentengine.models
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import com.neomovies.torrentengine.database.Converters
|
||||
|
||||
/**
|
||||
* Torrent information model
|
||||
* Represents a torrent download with all its metadata
|
||||
*/
|
||||
@Entity(tableName = "torrents")
|
||||
@TypeConverters(Converters::class)
|
||||
data class TorrentInfo(
|
||||
@PrimaryKey
|
||||
val infoHash: String,
|
||||
val magnetUri: String,
|
||||
val name: String,
|
||||
val totalSize: Long = 0,
|
||||
val downloadedSize: Long = 0,
|
||||
val uploadedSize: Long = 0,
|
||||
val downloadSpeed: Int = 0,
|
||||
val uploadSpeed: Int = 0,
|
||||
val progress: Float = 0f,
|
||||
val state: TorrentState = TorrentState.STOPPED,
|
||||
val numPeers: Int = 0,
|
||||
val numSeeds: Int = 0,
|
||||
val savePath: String,
|
||||
val files: List<TorrentFile> = emptyList(),
|
||||
val addedDate: Long = System.currentTimeMillis(),
|
||||
val finishedDate: Long? = null,
|
||||
val error: String? = null,
|
||||
val sequentialDownload: Boolean = false,
|
||||
val isPrivate: Boolean = false,
|
||||
val creator: String? = null,
|
||||
val comment: String? = null,
|
||||
val trackers: List<String> = emptyList()
|
||||
) {
|
||||
/**
|
||||
* Calculate ETA (Estimated Time of Arrival) in seconds
|
||||
*/
|
||||
fun getEta(): Long {
|
||||
if (downloadSpeed == 0) return Long.MAX_VALUE
|
||||
val remainingBytes = totalSize - downloadedSize
|
||||
return remainingBytes / downloadSpeed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted ETA string
|
||||
*/
|
||||
fun getFormattedEta(): String {
|
||||
val eta = getEta()
|
||||
if (eta == Long.MAX_VALUE) return "∞"
|
||||
|
||||
val hours = eta / 3600
|
||||
val minutes = (eta % 3600) / 60
|
||||
val seconds = eta % 60
|
||||
|
||||
return when {
|
||||
hours > 0 -> String.format("%dh %02dm", hours, minutes)
|
||||
minutes > 0 -> String.format("%dm %02ds", minutes, seconds)
|
||||
else -> String.format("%ds", seconds)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get share ratio
|
||||
*/
|
||||
fun getShareRatio(): Float {
|
||||
if (downloadedSize == 0L) return 0f
|
||||
return uploadedSize.toFloat() / downloadedSize.toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if torrent is active (downloading/seeding)
|
||||
*/
|
||||
fun isActive(): Boolean = state in arrayOf(
|
||||
TorrentState.DOWNLOADING,
|
||||
TorrentState.SEEDING,
|
||||
TorrentState.METADATA_DOWNLOADING
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if torrent has error
|
||||
*/
|
||||
fun hasError(): Boolean = error != null
|
||||
|
||||
/**
|
||||
* Get selected files count
|
||||
*/
|
||||
fun getSelectedFilesCount(): Int = files.count { it.priority > FilePriority.DONT_DOWNLOAD }
|
||||
|
||||
/**
|
||||
* Get total selected size
|
||||
*/
|
||||
fun getSelectedSize(): Long = files
|
||||
.filter { it.priority > FilePriority.DONT_DOWNLOAD }
|
||||
.sumOf { it.size }
|
||||
}
|
||||
|
||||
/**
|
||||
* Torrent state enumeration
|
||||
*/
|
||||
enum class TorrentState {
|
||||
/**
|
||||
* Torrent is stopped/paused
|
||||
*/
|
||||
STOPPED,
|
||||
|
||||
/**
|
||||
* Torrent is queued for download
|
||||
*/
|
||||
QUEUED,
|
||||
|
||||
/**
|
||||
* Downloading metadata from magnet link
|
||||
*/
|
||||
METADATA_DOWNLOADING,
|
||||
|
||||
/**
|
||||
* Checking files on disk
|
||||
*/
|
||||
CHECKING,
|
||||
|
||||
/**
|
||||
* Actively downloading
|
||||
*/
|
||||
DOWNLOADING,
|
||||
|
||||
/**
|
||||
* Download finished, now seeding
|
||||
*/
|
||||
SEEDING,
|
||||
|
||||
/**
|
||||
* Finished downloading and seeding
|
||||
*/
|
||||
FINISHED,
|
||||
|
||||
/**
|
||||
* Error occurred
|
||||
*/
|
||||
ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* File information within torrent
|
||||
*/
|
||||
data class TorrentFile(
|
||||
val index: Int,
|
||||
val path: String,
|
||||
val size: Long,
|
||||
val downloaded: Long = 0,
|
||||
val priority: FilePriority = FilePriority.NORMAL,
|
||||
val progress: Float = 0f
|
||||
) {
|
||||
/**
|
||||
* Get file name from path
|
||||
*/
|
||||
fun getName(): String = path.substringAfterLast('/')
|
||||
|
||||
/**
|
||||
* Get file extension
|
||||
*/
|
||||
fun getExtension(): String = path.substringAfterLast('.', "")
|
||||
|
||||
/**
|
||||
* Check if file is video
|
||||
*/
|
||||
fun isVideo(): Boolean = getExtension().lowercase() in VIDEO_EXTENSIONS
|
||||
|
||||
/**
|
||||
* Check if file is selected for download
|
||||
*/
|
||||
fun isSelected(): Boolean = priority > FilePriority.DONT_DOWNLOAD
|
||||
|
||||
companion object {
|
||||
private val VIDEO_EXTENSIONS = setOf(
|
||||
"mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg", "3gp"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File download priority
|
||||
*/
|
||||
enum class FilePriority(val value: Int) {
|
||||
/**
|
||||
* Don't download this file
|
||||
*/
|
||||
DONT_DOWNLOAD(0),
|
||||
|
||||
/**
|
||||
* Low priority
|
||||
*/
|
||||
LOW(1),
|
||||
|
||||
/**
|
||||
* Normal priority (default)
|
||||
*/
|
||||
NORMAL(4),
|
||||
|
||||
/**
|
||||
* High priority
|
||||
*/
|
||||
HIGH(6),
|
||||
|
||||
/**
|
||||
* Maximum priority (download first)
|
||||
*/
|
||||
MAXIMUM(7);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int): FilePriority = values().firstOrNull { it.value == value } ?: NORMAL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Torrent statistics for UI
|
||||
*/
|
||||
data class TorrentStats(
|
||||
val totalTorrents: Int = 0,
|
||||
val activeTorrents: Int = 0,
|
||||
val downloadingTorrents: Int = 0,
|
||||
val seedingTorrents: Int = 0,
|
||||
val pausedTorrents: Int = 0,
|
||||
val totalDownloadSpeed: Long = 0,
|
||||
val totalUploadSpeed: Long = 0,
|
||||
val totalDownloaded: Long = 0,
|
||||
val totalUploaded: Long = 0
|
||||
) {
|
||||
/**
|
||||
* Get formatted download speed
|
||||
*/
|
||||
fun getFormattedDownloadSpeed(): String = formatSpeed(totalDownloadSpeed)
|
||||
|
||||
/**
|
||||
* Get formatted upload speed
|
||||
*/
|
||||
fun getFormattedUploadSpeed(): String = formatSpeed(totalUploadSpeed)
|
||||
|
||||
private fun formatSpeed(speed: Long): String {
|
||||
return when {
|
||||
speed >= 1024 * 1024 -> String.format("%.1f MB/s", speed / (1024.0 * 1024.0))
|
||||
speed >= 1024 -> String.format("%.1f KB/s", speed / 1024.0)
|
||||
else -> "$speed B/s"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.neomovies.torrentengine.service
|
||||
|
||||
import android.app.*
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.neomovies.torrentengine.TorrentEngine
|
||||
import com.neomovies.torrentengine.models.TorrentState
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
/**
|
||||
* Foreground service for torrent downloads
|
||||
* This service shows a persistent notification that cannot be dismissed while torrents are active
|
||||
*/
|
||||
class TorrentService : Service() {
|
||||
private val TAG = "TorrentService"
|
||||
|
||||
private lateinit var torrentEngine: TorrentEngine
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
private val NOTIFICATION_ID = 1001
|
||||
private val CHANNEL_ID = "torrent_service_channel"
|
||||
private val CHANNEL_NAME = "Torrent Downloads"
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
torrentEngine = TorrentEngine.getInstance(applicationContext)
|
||||
torrentEngine.startStatsUpdater()
|
||||
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
|
||||
// Start observing torrents for notification updates
|
||||
observeTorrents()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// Service will restart if killed by system
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
// This service doesn't support binding
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notification channel for Android 8.0+
|
||||
*/
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shows download progress for torrents"
|
||||
setShowBadge(false)
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe torrents and update notification
|
||||
*/
|
||||
private fun observeTorrents() {
|
||||
scope.launch {
|
||||
torrentEngine.getAllTorrentsFlow().collect { torrents ->
|
||||
val activeTorrents = torrents.filter { it.isActive() }
|
||||
|
||||
if (activeTorrents.isEmpty()) {
|
||||
// Stop service if no active torrents
|
||||
stopSelf()
|
||||
} else {
|
||||
// Update notification
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.notify(NOTIFICATION_ID, createNotification(activeTorrents.size, torrents))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update notification
|
||||
*/
|
||||
private fun createNotification(activeTorrentsCount: Int = 0, allTorrents: List<com.neomovies.torrentengine.models.TorrentInfo> = emptyList()): Notification {
|
||||
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setOngoing(true) // Cannot be dismissed
|
||||
.setContentIntent(pendingIntent)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
|
||||
if (activeTorrentsCount == 0) {
|
||||
// Initial notification
|
||||
builder.setContentTitle("Torrent Service")
|
||||
.setContentText("Ready to download")
|
||||
} else {
|
||||
// Calculate total stats
|
||||
val downloadingTorrents = allTorrents.filter { it.state == TorrentState.DOWNLOADING }
|
||||
val totalDownloadSpeed = allTorrents.sumOf { it.downloadSpeed.toLong() }
|
||||
val totalUploadSpeed = allTorrents.sumOf { it.uploadSpeed.toLong() }
|
||||
|
||||
val speedText = buildString {
|
||||
if (totalDownloadSpeed > 0) {
|
||||
append("↓ ${formatSpeed(totalDownloadSpeed)}")
|
||||
}
|
||||
if (totalUploadSpeed > 0) {
|
||||
if (isNotEmpty()) append(" ")
|
||||
append("↑ ${formatSpeed(totalUploadSpeed)}")
|
||||
}
|
||||
}
|
||||
|
||||
builder.setContentTitle("$activeTorrentsCount active torrent(s)")
|
||||
.setContentText(speedText)
|
||||
|
||||
// Add big text style with details
|
||||
val bigText = buildString {
|
||||
if (downloadingTorrents.isNotEmpty()) {
|
||||
appendLine("Downloading:")
|
||||
downloadingTorrents.take(3).forEach { torrent ->
|
||||
appendLine("• ${torrent.name}")
|
||||
appendLine(" ${String.format("%.1f%%", torrent.progress * 100)} - ↓ ${formatSpeed(torrent.downloadSpeed.toLong())}")
|
||||
}
|
||||
if (downloadingTorrents.size > 3) {
|
||||
appendLine("... and ${downloadingTorrents.size - 3} more")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(bigText))
|
||||
|
||||
// Add action buttons
|
||||
addNotificationActions(builder)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add action buttons to notification
|
||||
*/
|
||||
private fun addNotificationActions(builder: NotificationCompat.Builder) {
|
||||
// Pause all button
|
||||
val pauseAllIntent = Intent(this, TorrentService::class.java).apply {
|
||||
action = ACTION_PAUSE_ALL
|
||||
}
|
||||
val pauseAllPendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
pauseAllIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
builder.addAction(
|
||||
android.R.drawable.ic_media_pause,
|
||||
"Pause All",
|
||||
pauseAllPendingIntent
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format speed for display
|
||||
*/
|
||||
private fun formatSpeed(bytesPerSecond: Long): String {
|
||||
return when {
|
||||
bytesPerSecond >= 1024 * 1024 -> String.format("%.1f MB/s", bytesPerSecond / (1024.0 * 1024.0))
|
||||
bytesPerSecond >= 1024 -> String.format("%.1f KB/s", bytesPerSecond / 1024.0)
|
||||
else -> "$bytesPerSecond B/s"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_PAUSE_ALL = "com.neomovies.torrentengine.PAUSE_ALL"
|
||||
const val ACTION_RESUME_ALL = "com.neomovies.torrentengine.RESUME_ALL"
|
||||
const val ACTION_STOP_SERVICE = "com.neomovies.torrentengine.STOP_SERVICE"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user