Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 86 additions & 1 deletion app/src/main/cpp/torrent_jni.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<lt::session>(settings);
LOGI("Session started save_path=%s state_dir=%s", g_save_path.c_str(), g_state_dir.c_str());

Expand Down Expand Up @@ -283,6 +314,29 @@ Java_com_jpcottin_simpletorrent_data_TorrentManager_getTorrentsJson(
std::vector<lt::alert*> alerts;
g_session->pop_alerts(&alerts);

// Process alerts to build error map and save resume data
std::unordered_map<std::string, std::string> torrent_errors;
for (auto* a : alerts) {
if (auto* rd = lt::alert_cast<lt::save_resume_data_alert>(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<std::streamsize>(buf.size()));
} else if (auto* te = lt::alert_cast<lt::torrent_error_alert>(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<lt::tracker_error_alert>(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;
Expand Down Expand Up @@ -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") << ","
Expand Down Expand Up @@ -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<std::mutex> 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<std::mutex> 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"
12 changes: 10 additions & 2 deletions app/src/main/java/com/jpcottin/simpletorrent/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<List<TorrentInfo>>
Expand All @@ -13,16 +19,25 @@ 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 {

override val torrents: Flow<List<TorrentInfo>> = 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)
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,6 +42,7 @@ data class TorrentInfo(
val allTimeUploadBytes: Long = 0L,
val allTimeDownloadBytes: Long = 0L,
val fileList: List<FileInfo> = emptyList(),
val lastError: String = "",
)

sealed interface CreateTorrentResult {
Expand All @@ -52,6 +54,9 @@ object TorrentManager {

private val json = Json { ignoreUnknownKeys = true }

@Volatile
var isInBackground: Boolean = false

init {
System.loadLibrary("simpletorrent")
}
Expand All @@ -75,6 +80,7 @@ object TorrentManager {

fun getTorrents(): List<TorrentInfo> =
runCatching { json.decodeFromString<List<TorrentInfo>>(getTorrentsJson()) }
.onFailure { Log.e("TorrentManager", "getTorrents parse error", it) }
.getOrDefault(emptyList())

fun createTorrentFrom(sourcePath: String, outputDir: String): CreateTorrentResult {
Expand All @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/com/jpcottin/simpletorrent/ui/main/MainScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(':')
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,10 +34,20 @@ class MainScreenViewModel(
.catch { emit(MainScreenUiState.Error(it)) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MainScreenUiState.Loading)

private val _errorEvents = MutableSharedFlow<String>(extraBufferCapacity = 8)
val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()

private val _createState = MutableStateFlow<CreateState>(CreateState.Idle)
val createState: StateFlow<CreateState> = _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) }
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<List<TorrentInfo>> = flow {
Expand All @@ -163,12 +173,13 @@ 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) {
lastRemovedHash = infoHash; lastRemovedDeleteFiles = deleteFiles
}
override fun setSequentialDownload(infoHash: String, enabled: Boolean) {}
override suspend fun createTorrentFrom(sourcePath: String, outputDir: String) = createResult
override fun setBackgroundMode(isBackground: Boolean) {}
}