Harden Android torrent lifecycle

This commit is contained in:
KhooLy 2026-08-01 15:08:50 +03:00
parent 09f717f4a5
commit a6f5d2f075
5 changed files with 68 additions and 39 deletions

View file

@ -8,7 +8,6 @@ import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
@ -23,47 +22,41 @@ class TorrentEngineComparisonInstrumentationBenchmark {
@After
fun tearDown() {
torrentServerEngine?.stop()
FluxaStreamingNative.stopTorrentServer()
}
@Test
fun compareTorrentServerAndRustEngineFirstRangeRead() {
fun measureRustEnginePlaybackScenarios() {
val args = InstrumentationRegistry.getArguments()
val torrentLink = args.getString("torrentLink").orEmpty()
assumeTrue("Pass -e torrentLink <magnet-or-torrent-url> to run this benchmark.", torrentLink.isNotBlank())
val rustInfo = JSONObject(
FluxaStreamingNative.startTorrentServer(
cacheDir = context.cacheDir.resolve("rust_torrent_benchmark").absolutePath,
preferredPort = 0
)
)
val rustBaseUrl = rustInfo.getString("url")
val rustUrl = torrentStreamUrl(rustBaseUrl, torrentLink)
torrentServerEngine = TorrentServerEngine(context).also { it.start() }
waitForHttp(Constants.LocalServer.TORRENT_SERVER_BASE_URL)
val torrentServerUrl = torrentStreamUrl(Constants.LocalServer.TORRENT_SERVER_BASE_URL, torrentLink)
val streamUrl = torrentStreamUrl(Constants.LocalServer.TORRENT_SERVER_BASE_URL, torrentLink)
val rustDelayMs = measureFirstRangeMs(rustUrl)
val torrentServerDelayMs = measureFirstRangeMs(torrentServerUrl)
val firstByteMs = measureFirstRangeMs(streamUrl, 0)
val length = streamLength(streamUrl)
val middleSeekMs = measureFirstRangeMs(streamUrl, length / 2)
val tailSeekMs = measureFirstRangeMs(streamUrl, (length - 262_144L).coerceAtLeast(0L))
println("torrent-rust-engine-first-range-ms=$rustDelayMs")
println("torrent-torrserver-first-range-ms=$torrentServerDelayMs")
assertTrue(rustDelayMs > 0)
assertTrue(torrentServerDelayMs > 0)
println("torrent-rust-engine-first-byte-ms=$firstByteMs")
println("torrent-rust-engine-middle-seek-first-byte-ms=$middleSeekMs")
println("torrent-rust-engine-tail-seek-first-byte-ms=$tailSeekMs")
assertTrue(firstByteMs > 0)
assertTrue(middleSeekMs > 0)
assertTrue(tailSeekMs > 0)
}
private fun torrentStreamUrl(baseUrl: String, torrentLink: String): String {
return "$baseUrl/stream/fname?link=${URLEncoder.encode(torrentLink, "UTF-8")}&title=benchmark"
}
private fun measureFirstRangeMs(url: String): Long {
private fun measureFirstRangeMs(url: String, startByte: Long): Long {
val start = System.nanoTime()
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
connection.setRequestProperty("Range", "bytes=0-262143")
connection.setRequestProperty("Range", "bytes=$startByte-${startByte + 262_143L}")
val bytes = connection.inputStream.use { stream ->
val buffer = ByteArray(32 * 1024)
var total = 0
@ -78,6 +71,18 @@ class TorrentEngineComparisonInstrumentationBenchmark {
return (System.nanoTime() - start) / 1_000_000L
}
private fun streamLength(url: String): Long {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
connection.setRequestProperty("Range", "bytes=0-0")
connection.inputStream.use { it.read() }
return connection.getHeaderField("Content-Range")
?.substringAfter('/')
?.toLongOrNull()
?: error("Torrent server did not return Content-Range")
}
private fun waitForHttp(baseUrl: String) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20)
while (System.nanoTime() < deadline) {

View file

@ -29,7 +29,9 @@ object FluxaStreamingNative {
startTorrentServerNative(cacheDir, preferredPort, accessToken)
}
fun stopTorrentServer(): Boolean = call { stopTorrentServerNative() }
fun stopTorrentServer(expectedGeneration: Long? = null): Boolean = call {
stopTorrentServerNative(expectedGeneration ?: -1L)
}
fun dvRpuSelfTest(): Boolean = call { dvRpuSelfTestNative() }
@ -59,7 +61,7 @@ object FluxaStreamingNative {
private external fun startDvRewriteLocalStreamServerNative(targetUrl: String, headersJson: String, dvConfigJson: String, preferredPort: Int): String
private external fun stopLocalStreamServerNative(serverId: String): Boolean
private external fun startTorrentServerNative(cacheDir: String, preferredPort: Int, accessToken: String): String
private external fun stopTorrentServerNative(): Boolean
private external fun stopTorrentServerNative(expectedGeneration: Long): Boolean
private external fun dvRpuSelfTestNative(): Boolean
private external fun dvAutoDetectWasIptPqc2Native(): Boolean
private external fun dvRewriteSegmentBytesNative(data: ByteArray, rpuMode: Int, zeroLevel5: Boolean, removeHdr10Plus: Boolean): ByteArray

View file

@ -13,7 +13,10 @@ data class TorrentRequest(
val hash: String? = null,
val title: String? = null,
@SerializedName("save_to_db") val saveToDb: Boolean = false,
@SerializedName("file_id") val fileId: Int? = null
@SerializedName("file_id") val fileId: Int? = null,
// Defaults to video for old callers. Subtitle selection can opt in
// without replacing the torrent's current primary video focus.
val role: String = "video"
)
data class TorrentSettings(

View file

@ -4,8 +4,11 @@ import android.content.Context
import android.util.Log
import com.fluxa.app.core.rust.FluxaStreamingNative
import java.io.IOException
import java.net.HttpURLConnection
import java.net.ServerSocket
import java.net.URL
import java.util.UUID
import org.json.JSONObject
import kotlinx.coroutines.*
class TorrentServerEngine(private val context: Context) {
@ -19,22 +22,22 @@ class TorrentServerEngine(private val context: Context) {
private var watcherJob: Job? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile private var running = false
@Volatile private var generation: Long? = null
fun start() {
if (isRunning()) return
if (isRunning() && healthResponds()) return
ensurePortFree()
val legacyDataDir = java.io.File(context.filesDir, "rust_torrent_cache")
if (legacyDataDir.exists()) legacyDataDir.deleteRecursively()
val dataDir = java.io.File(context.cacheDir, "rust_torrent_cache").apply {
deleteRecursively()
mkdirs()
}
// Keep torrent pieces and librqbit's session persistence across normal
// app/engine restarts. Cache removal is an explicit user action, not
// a lifecycle side effect.
val dataDir = java.io.File(context.filesDir, "rust_torrent_cache").apply { mkdirs() }
try {
castAccessToken = UUID.randomUUID().toString()
val result = FluxaStreamingNative.startTorrentServer(dataDir.absolutePath, port, castAccessToken)
running = result.isNotBlank()
generation = runCatching { JSONObject(result).getLong("generation") }.getOrNull()
running = generation != null
if (running) {
Log.i("TorrentServer", "Rust torrent engine started on port $port")
startWatcher()
@ -55,8 +58,9 @@ class TorrentServerEngine(private val context: Context) {
watcherJob = scope.launch {
while (isActive) {
delay(3000)
if (!isRunning()) {
Log.w("TorrentServer", "Rust torrent engine stopped. Restarting...")
if (!healthResponds()) {
Log.w("TorrentServer", "Rust torrent engine health check failed. Restarting...")
FluxaStreamingNative.stopTorrentServer(generation)
running = false
start()
}
@ -68,8 +72,9 @@ class TorrentServerEngine(private val context: Context) {
watcherJob?.cancel()
Log.i("TorrentServer", "Stopping Rust torrent engine...")
try {
FluxaStreamingNative.stopTorrentServer()
FluxaStreamingNative.stopTorrentServer(generation)
running = false
generation = null
castAccessToken = ""
} catch (e: Exception) {
Log.e("TorrentServer", "Rust torrent engine stop failed", e)
@ -85,6 +90,16 @@ class TorrentServerEngine(private val context: Context) {
return running
}
private fun healthResponds(): Boolean = runCatching {
(URL("http://127.0.0.1:$port/health").openConnection() as HttpURLConnection).run {
connectTimeout = 1_000
readTimeout = 1_000
requestMethod = "GET"
connect()
responseCode in 200..299
}
}.getOrDefault(false)
private fun ensurePortFree() {
try {
ServerSocket(port).use { /* Port is free */ }

View file

@ -276,11 +276,15 @@ class TorrentStreamManager private constructor() {
}.getOrDefault(false)
}
// 1% of file size, clamped between 3 MB and 24 MB.
// Falls back to the speed-preset default when file size is unknown.
// Target ~20 seconds of media rather than a fixed fraction of a file.
// A size-only target makes high-bitrate remuxes start with just a couple
// of seconds buffered while over-buffering low-bitrate content.
private fun estimatePreloadMb(fileSizeBytes: Long, durationMs: Long): Long {
if (fileSizeBytes > 0L) {
return (fileSizeBytes / 100L / (1024L * 1024L)).coerceIn(3L, 24L)
if (fileSizeBytes > 0L && durationMs > 0L) {
val bytesPerSecond = fileSizeBytes.toDouble() / (durationMs / 1_000.0)
return (bytesPerSecond * 20.0 / (1024.0 * 1024.0))
.toLong()
.coerceIn(4L, 256L)
}
return pendingSettings.preloadSize
}