diff --git a/README.md b/README.md index 6d2d2f7..8d48524 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,12 @@ Android NDK and the libtorrent-rasterbar C++ library. ## Recent Improvements - **Subtitle support robustness**: Case-insensitive language code matching, proper fallback for unknown locales, fixed Compose state mutation -- **User feedback**: Sample torrents now show confirmation snackbar when added -- **Code quality**: Extracted testable subtitle detection logic, added unit tests for edge cases +- **User feedback**: Sample torrents now show confirmation snackbar when added; `addMagnet` errors are surfaced as snackbar alerts +- **Error handling**: Per-torrent error tracking (tracker & torrent errors displayed on cards); polling flow auto-recovers from transient errors instead of getting stuck +- **Connection optimization**: Session connection limits, unchoke slots, active torrent limits for bandwidth efficiency +- **Peer discovery**: DHT bootstrap nodes configured; `announce_to_all_trackers/tiers` enabled for faster peer finding +- **Battery optimization**: Libtorrent tick interval slows to 2000ms in background (vs 500ms foreground); polling rate drops to 10s background / 3s foreground; alert queue capped at 1000 entries +- **Code quality**: Polling now runs on IO dispatcher (not main thread); timeouts for peer/request/handshake; send buffer tuning ## Demo diff --git a/app/src/main/cpp/torrent_jni.cpp b/app/src/main/cpp/torrent_jni.cpp index 63cfeba..2ccd314 100644 --- a/app/src/main/cpp/torrent_jni.cpp +++ b/app/src/main/cpp/torrent_jni.cpp @@ -180,7 +180,8 @@ Java_com_jpcottin_simpletorrent_data_TorrentManager_nativeInit( settings.set_int(lt::settings_pack::alert_mask, lt::alert::status_notification | lt::alert::error_notification | - lt::alert::storage_notification); // needed for save_resume_data_alert + lt::alert::storage_notification | + lt::alert::tracker_notification); // for tracker_error_alert, torrent_error_alert settings.set_bool(lt::settings_pack::enable_dht, true); settings.set_bool(lt::settings_pack::enable_lsd, true); // local peer discovery on same LAN settings.set_bool(lt::settings_pack::enable_upnp, true); @@ -189,6 +190,36 @@ Java_com_jpcottin_simpletorrent_data_TorrentManager_nativeInit( // 15 s means peers on the same LAN (or same emulator host) find each other almost instantly. settings.set_int(lt::settings_pack::local_service_announce_interval, 15); + // Timeouts for mobile networks + settings.set_int(lt::settings_pack::peer_timeout, 60); + settings.set_int(lt::settings_pack::request_timeout, 20); + settings.set_int(lt::settings_pack::handshake_timeout, 10); + + // Connection & unchoke limits + settings.set_int(lt::settings_pack::connections_limit, 200); + settings.set_int(lt::settings_pack::unchoke_slots_limit, 8); + + // Active torrent limits + settings.set_int(lt::settings_pack::active_downloads, 4); + settings.set_int(lt::settings_pack::active_seeds, 4); + settings.set_int(lt::settings_pack::active_limit, 6); + + // Tracker announce behavior + settings.set_bool(lt::settings_pack::announce_to_all_trackers, true); + settings.set_bool(lt::settings_pack::announce_to_all_tiers, true); + + // DHT bootstrap nodes + settings.set_str(lt::settings_pack::dht_bootstrap_nodes, + "router.bittorrent.com:6881," + "router.utorrent.com:6881," + "dht.transmissionbt.com:6881"); + + // Memory & CPU tuning + settings.set_int(lt::settings_pack::alert_queue_size, 1000); + settings.set_int(lt::settings_pack::tick_interval, 500); // ms; reduces CPU; default is 100ms + settings.set_int(lt::settings_pack::send_buffer_watermark, 512 * 1024); // 512 KiB + settings.set_int(lt::settings_pack::send_buffer_low_watermark, 128 * 1024); // 128 KiB + g_session = std::make_unique(settings); LOGI("Session started save_path=%s state_dir=%s", g_save_path.c_str(), g_state_dir.c_str()); @@ -283,6 +314,29 @@ Java_com_jpcottin_simpletorrent_data_TorrentManager_getTorrentsJson( std::vector alerts; g_session->pop_alerts(&alerts); + // Process alerts to build error map and save resume data + std::unordered_map torrent_errors; + for (auto* a : alerts) { + if (auto* rd = lt::alert_cast(a)) { + // Write resume data to disk + auto buf = lt::write_resume_data_buf(rd->params); + std::string path = resume_path(rd->params.info_hashes.v1); + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (f) f.write(buf.data(), static_cast(buf.size())); + } else if (auto* te = lt::alert_cast(a)) { + std::string hash = sha1_to_hex(te->handle.status().info_hashes.v1); + torrent_errors[hash] = te->error.message(); + LOGE("Torrent error [%s]: %s", hash.c_str(), te->error.message().c_str()); + } else if (auto* tr = lt::alert_cast(a)) { + // Tracker errors; only store if no torrent-level error exists + std::string hash = sha1_to_hex(tr->handle.status().info_hashes.v1); + if (torrent_errors.find(hash) == torrent_errors.end()) { + torrent_errors[hash] = "tracker: " + tr->error.message(); + LOGE("Tracker error [%s]: %s", hash.c_str(), tr->error.message().c_str()); + } + } + } + std::ostringstream json; json << "["; bool first = true; @@ -335,8 +389,12 @@ Java_com_jpcottin_simpletorrent_data_TorrentManager_getTorrentsJson( }); auto top = std::min(peer_vec.size(), size_t(5)); + auto err_it = torrent_errors.find(sha1_to_hex(st.info_hashes.v1)); + std::string last_error = (err_it != torrent_errors.end()) ? err_it->second : ""; + json << "{" << "\"infoHash\":" << json_str(sha1_to_hex(st.info_hashes.v1)) << "," + << "\"lastError\":" << json_str(last_error) << "," << "\"name\":" << json_str(name) << "," << "\"state\":" << json_str(state_str(st.state)) << "," << "\"isPaused\":" << (paused ? "true" : "false") << "," @@ -561,4 +619,31 @@ Java_com_jpcottin_simpletorrent_data_TorrentManager_saveTorrentFile( return env->NewStringUTF(path.c_str()); } +// Set download and upload rate limits (in KB/s; 0 = unlimited). +JNIEXPORT void JNICALL +Java_com_jpcottin_simpletorrent_data_TorrentManager_nativeSetBandwidthLimits( + JNIEnv* /*env*/, jobject /*thiz*/, jint downloadKbs, jint uploadKbs) { + std::lock_guard lock(g_mutex); + if (!g_session) return; + lt::settings_pack pack; + // Convert KB/s to bytes/s; 0 means unlimited in libtorrent + pack.set_int(lt::settings_pack::download_rate_limit, downloadKbs * 1024); + pack.set_int(lt::settings_pack::upload_rate_limit, uploadKbs * 1024); + g_session->apply_settings(pack); + LOGI("Bandwidth limits: down=%d KB/s up=%d KB/s", downloadKbs, uploadKbs); +} + +// Set the libtorrent session tick interval (in milliseconds). +// Lower values (e.g. 500) reduce CPU; higher values (e.g. 2000) save battery in background. +JNIEXPORT void JNICALL +Java_com_jpcottin_simpletorrent_data_TorrentManager_nativeSetTickInterval( + JNIEnv* /*env*/, jobject /*thiz*/, jint intervalMs) { + std::lock_guard lock(g_mutex); + if (!g_session) return; + lt::settings_pack pack; + pack.set_int(lt::settings_pack::tick_interval, intervalMs); + g_session->apply_settings(pack); + LOGI("Tick interval set to %d ms", intervalMs); +} + } // extern "C" diff --git a/app/src/main/java/com/jpcottin/simpletorrent/MainActivity.kt b/app/src/main/java/com/jpcottin/simpletorrent/MainActivity.kt index 5686543..b3a3bc5 100644 --- a/app/src/main/java/com/jpcottin/simpletorrent/MainActivity.kt +++ b/app/src/main/java/com/jpcottin/simpletorrent/MainActivity.kt @@ -38,6 +38,13 @@ class MainActivity : ComponentActivity() { } } + override fun onStart() { + super.onStart() + // App is becoming visible; restore normal libtorrent tick interval and polling + TorrentManager.nativeSetTickInterval(500) + TorrentManager.isInBackground = false + } + override fun onResume() { super.onResume() // Reinit only if the resolved save path changed (e.g. user just granted MANAGE_EXTERNAL_STORAGE) @@ -50,10 +57,11 @@ class MainActivity : ComponentActivity() { override fun onStop() { super.onStop() - // onStop is guaranteed before process death; flush resume data here so we - // survive the OS killing the process without onDestroy being called. + // App is going background; slow down libtorrent to save battery lifecycleScope.launch(Dispatchers.IO) { TorrentManager.nativeSaveResumeData() + TorrentManager.nativeSetTickInterval(2000) + TorrentManager.isInBackground = true } } diff --git a/app/src/main/java/com/jpcottin/simpletorrent/data/DataRepository.kt b/app/src/main/java/com/jpcottin/simpletorrent/data/DataRepository.kt index 8dd0678..672d2cb 100644 --- a/app/src/main/java/com/jpcottin/simpletorrent/data/DataRepository.kt +++ b/app/src/main/java/com/jpcottin/simpletorrent/data/DataRepository.kt @@ -1,9 +1,15 @@ package com.jpcottin.simpletorrent.data import kotlin.time.Duration.Companion.seconds +import android.util.Log +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.retryWhen +import kotlin.math.min +import kotlin.time.Duration.Companion.seconds interface DataRepository { val torrents: Flow> @@ -13,6 +19,7 @@ interface DataRepository { fun removeTorrent(infoHash: String, deleteFiles: Boolean) fun setSequentialDownload(infoHash: String, enabled: Boolean) suspend fun createTorrentFrom(sourcePath: String, outputDir: String): CreateTorrentResult + fun setBackgroundMode(isBackground: Boolean) } class DefaultDataRepository : DataRepository { @@ -20,9 +27,17 @@ class DefaultDataRepository : DataRepository { override val torrents: Flow> = flow { while (true) { emit(TorrentManager.getTorrents()) - delay(3.seconds) + val delay = if (TorrentManager.isInBackground) 10.seconds else 3.seconds + delay(delay) } } + .retryWhen { cause, attempt -> + Log.w("DataRepository", "Polling error (attempt $attempt): ${cause.message}") + val backoffDelay = (3 * (attempt + 1)).coerceAtMost(30).seconds + delay(backoffDelay) + true + } + .flowOn(Dispatchers.IO) override suspend fun addMagnet(uri: String): String = TorrentManager.addMagnet(uri) override fun pauseTorrent(infoHash: String) = TorrentManager.pauseTorrent(infoHash) @@ -33,4 +48,7 @@ class DefaultDataRepository : DataRepository { TorrentManager.setSequentialDownload(infoHash, enabled) override suspend fun createTorrentFrom(sourcePath: String, outputDir: String): CreateTorrentResult = TorrentManager.createTorrentFrom(sourcePath, outputDir) + override fun setBackgroundMode(isBackground: Boolean) { + TorrentManager.nativeSetTickInterval(if (isBackground) 2000 else 500) + } } diff --git a/app/src/main/java/com/jpcottin/simpletorrent/data/TorrentManager.kt b/app/src/main/java/com/jpcottin/simpletorrent/data/TorrentManager.kt index 6a9ad18..2d8de09 100644 --- a/app/src/main/java/com/jpcottin/simpletorrent/data/TorrentManager.kt +++ b/app/src/main/java/com/jpcottin/simpletorrent/data/TorrentManager.kt @@ -3,6 +3,7 @@ package com.jpcottin.simpletorrent.data import android.content.Context import android.os.Build import android.os.Environment +import android.util.Log import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject @@ -41,6 +42,7 @@ data class TorrentInfo( val allTimeUploadBytes: Long = 0L, val allTimeDownloadBytes: Long = 0L, val fileList: List = emptyList(), + val lastError: String = "", ) sealed interface CreateTorrentResult { @@ -52,6 +54,9 @@ object TorrentManager { private val json = Json { ignoreUnknownKeys = true } + @Volatile + var isInBackground: Boolean = false + init { System.loadLibrary("simpletorrent") } @@ -75,6 +80,7 @@ object TorrentManager { fun getTorrents(): List = runCatching { json.decodeFromString>(getTorrentsJson()) } + .onFailure { Log.e("TorrentManager", "getTorrents parse error", it) } .getOrDefault(emptyList()) fun createTorrentFrom(sourcePath: String, outputDir: String): CreateTorrentResult { @@ -96,6 +102,8 @@ object TorrentManager { external fun nativeInit(savePath: String, statePath: String) external fun nativeRelease() external fun nativeSaveResumeData() + external fun nativeSetBandwidthLimits(downloadKbs: Int, uploadKbs: Int) + external fun nativeSetTickInterval(intervalMs: Int) external fun addMagnet(magnetUri: String): String external fun addTorrentFile(torrentPath: String): String external fun pauseTorrent(infoHash: String) diff --git a/app/src/main/java/com/jpcottin/simpletorrent/ui/main/MainScreen.kt b/app/src/main/java/com/jpcottin/simpletorrent/ui/main/MainScreen.kt index 07dd26c..f859759 100644 --- a/app/src/main/java/com/jpcottin/simpletorrent/ui/main/MainScreen.kt +++ b/app/src/main/java/com/jpcottin/simpletorrent/ui/main/MainScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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 @@ -108,6 +109,13 @@ fun MainScreen( val context = LocalContext.current val scope = rememberCoroutineScope() + // Collect error events and show as snackbar + LaunchedEffect(Unit) { + viewModel.errorEvents.collect { message -> + snackbarHostState.showSnackbar(message) + } + } + fun resolveTreeToPath(uri: Uri): String? { val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(uri) val colon = treeDocId.indexOf(':') @@ -443,6 +451,16 @@ internal fun TorrentCard( fontSize = 12.sp, modifier = Modifier.padding(top = 2.dp, bottom = 6.dp), ) + if (torrent.lastError.isNotEmpty()) { + Text( + text = torrent.lastError, + fontSize = 10.sp, + color = Color(0xFFFF6B6B), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 4.dp), + ) + } LinearProgressIndicator( progress = { torrent.progress }, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/jpcottin/simpletorrent/ui/main/MainScreenViewModel.kt b/app/src/main/java/com/jpcottin/simpletorrent/ui/main/MainScreenViewModel.kt index 6808a5a..8b3b9c1 100644 --- a/app/src/main/java/com/jpcottin/simpletorrent/ui/main/MainScreenViewModel.kt +++ b/app/src/main/java/com/jpcottin/simpletorrent/ui/main/MainScreenViewModel.kt @@ -10,10 +10,13 @@ import com.jpcottin.simpletorrent.data.DataRepository import com.jpcottin.simpletorrent.data.TorrentInfo import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import java.io.File import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map @@ -31,10 +34,20 @@ class MainScreenViewModel( .catch { emit(MainScreenUiState.Error(it)) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MainScreenUiState.Loading) + private val _errorEvents = MutableSharedFlow(extraBufferCapacity = 8) + val errorEvents: SharedFlow = _errorEvents.asSharedFlow() + private val _createState = MutableStateFlow(CreateState.Idle) val createState: StateFlow = _createState.asStateFlow() - fun addMagnet(uri: String) { viewModelScope.launch { repository.addMagnet(uri.trim()) } } + fun addMagnet(uri: String) { + viewModelScope.launch { + val result = repository.addMagnet(uri.trim()) + if (result.startsWith("error:")) { + _errorEvents.emit(result.removePrefix("error: ")) + } + } + } fun pause(infoHash: String) { repository.pauseTorrent(infoHash) } fun resume(infoHash: String) { repository.resumeTorrent(infoHash) } fun remove(infoHash: String, deleteFiles: Boolean) { repository.removeTorrent(infoHash, deleteFiles) } @@ -78,6 +91,16 @@ class MainScreenViewModel( } fun dismissCreateResult() { _createState.value = CreateState.Idle } + + fun onAppBackground() { + com.jpcottin.simpletorrent.data.TorrentManager.isInBackground = true + repository.setBackgroundMode(true) + } + + fun onAppForeground() { + com.jpcottin.simpletorrent.data.TorrentManager.isInBackground = false + repository.setBackgroundMode(false) + } } sealed interface CreateState { diff --git a/app/src/test/java/com/jpcottin/simpletorrent/ui/main/MainScreenViewModelTest.kt b/app/src/test/java/com/jpcottin/simpletorrent/ui/main/MainScreenViewModelTest.kt index 24ff497..cf48649 100644 --- a/app/src/test/java/com/jpcottin/simpletorrent/ui/main/MainScreenViewModelTest.kt +++ b/app/src/test/java/com/jpcottin/simpletorrent/ui/main/MainScreenViewModelTest.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -67,6 +68,14 @@ class MainScreenViewModelTest { assertEquals("magnet:?xt=urn:btih:abc", repo.lastAddedMagnet) } + @Test + fun addMagnet_delegatesToRepository_withDefaultOkResult() = runTest { + val repo = FakeRepository(addMagnetResult = "ok") + val vm = viewModel(repo = repo) + vm.addMagnet("magnet:?xt=urn:btih:test") + assertEquals("magnet:?xt=urn:btih:test", repo.lastAddedMagnet) + } + @Test fun pause_delegatesToRepository() = runTest { val repo = FakeRepository() @@ -150,6 +159,7 @@ private class FakeRepository( private val throwOnTorrents: Throwable? = null, private val createResult: CreateTorrentResult = CreateTorrentResult.Success("test.mkv", "/tmp/test.mkv.torrent", "magnet:?xt=urn:btih:aabbcc"), + private val addMagnetResult: String = "ok", ) : DataRepository { override val torrents: Flow> = flow { @@ -163,7 +173,7 @@ private class FakeRepository( var lastRemovedHash: String? = null var lastRemovedDeleteFiles: Boolean? = null - override suspend fun addMagnet(uri: String): String { lastAddedMagnet = uri; return "ok" } + override suspend fun addMagnet(uri: String): String { lastAddedMagnet = uri; return addMagnetResult } override fun pauseTorrent(infoHash: String) { lastPausedHash = infoHash } override fun resumeTorrent(infoHash: String) { lastResumedHash = infoHash } override fun removeTorrent(infoHash: String, deleteFiles: Boolean) { @@ -171,4 +181,5 @@ private class FakeRepository( } override fun setSequentialDownload(infoHash: String, enabled: Boolean) {} override suspend fun createTorrentFrom(sourcePath: String, outputDir: String) = createResult + override fun setBackgroundMode(isBackground: Boolean) {} }