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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ Android NDK and the libtorrent-rasterbar C++ library.
- Pause, resume, and delete torrents (with optional file removal)
- Persistent sessions — resume data is saved on `onStop` and restored on next launch
- Share `.torrent` files to other clients via the system share sheet
- Edge-to-edge UI with Material You theming
- Adaptive multi-column grid — 1 column on phones, 2 on foldables, 3 on tablets
- Full edge-to-edge UI with proper IME insets, Material You theming

## Demo

Expand All @@ -37,7 +38,9 @@ https://github.com/user-attachments/assets/6182efdb-9862-47ee-9fa7-011a64ee87eb
| Architecture | MVVM — `ViewModel` + `StateFlow` + `DataRepository` |
| Serialization | kotlinx.serialization 1.8.1 (JSON bridge between JNI and Kotlin) |
| Async | Kotlin Coroutines 1.10.2 |
| Adaptive layout | `LazyVerticalGrid` + `GridCells.Adaptive(300.dp)` — phone / foldable / tablet |
| Testing | JUnit 4, kotlinx-coroutines-test, Compose UI Test |
| Release shrinking | R8 (`isMinifyEnabled`, `isShrinkResources`) with targeted ProGuard rules for JNI and kotlinx.serialization |
| CI | GitHub Actions (unit tests on Ubuntu; NDK build & UI tests on macOS M1) |

## Project Structure
Expand Down
3 changes: 2 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ android {

buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
Expand Down
10 changes: 10 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Keep TorrentManager native (JNI) methods so R8 doesn't rename them
-keep class com.jpcexample.simpletorrent.data.TorrentManager {
native <methods>;
}

# Keep @Serializable classes and their companion serializers (kotlinx.serialization)
-keep @kotlinx.serialization.Serializable class * { *; }
-keepclassmembers @kotlinx.serialization.Serializable class * {
*** Companion;
}
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
Expand Down
5 changes: 1 addition & 4 deletions app/src/main/java/com/jpcexample/simpletorrent/Navigation.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package com.jpcexample.simpletorrent

import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
Expand All @@ -19,7 +16,7 @@ fun MainNavigation() {
entryProvider =
entryProvider {
entry<Main> {
MainScreen(onItemClick = { navKey -> backStack.add(navKey) }, modifier = Modifier.safeDrawingPadding())
MainScreen(onItemClick = { navKey -> backStack.add(navKey) })
}
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.material3.Scaffold
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
Expand Down Expand Up @@ -182,44 +187,57 @@ fun MainScreen(
)
}

Column(modifier = modifier.padding(horizontal = 16.dp).padding(top = 16.dp)) {
MagnetInputBar(
value = magnetInput,
onValueChange = { magnetInput = it },
onAdd = {
if (magnetInput.isNotBlank()) {
viewModel.addMagnet(magnetInput)
magnetInput = ""
}
},
onCreateFile = { fileLauncher.launch(arrayOf("*/*")) },
onCreateFolder = { folderLauncher.launch(null) },
)
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.safeDrawing,
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.padding(horizontal = 16.dp)
.padding(top = 16.dp),
) {
MagnetInputBar(
value = magnetInput,
onValueChange = { magnetInput = it },
onAdd = {
if (magnetInput.isNotBlank()) {
viewModel.addMagnet(magnetInput)
magnetInput = ""
}
},
onCreateFile = { fileLauncher.launch(arrayOf("*/*")) },
onCreateFolder = { folderLauncher.launch(null) },
)

when (state) {
MainScreenUiState.Loading ->
CircularProgressIndicator(Modifier.align(Alignment.CenterHorizontally))
is MainScreenUiState.Error ->
Text("Error: ${(state as MainScreenUiState.Error).throwable.message}")
is MainScreenUiState.Success -> {
val torrents = (state as MainScreenUiState.Success).torrents
if (torrents.isEmpty()) {
Text(
text = "No active torrents.\nPaste a magnet link above to start.",
modifier = Modifier.padding(top = 24.dp),
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(torrents, key = { it.infoHash }) { torrent ->
TorrentCard(
torrent = torrent,
onPause = { viewModel.pause(torrent.infoHash) },
onResume = { viewModel.resume(torrent.infoHash) },
onRemove = { viewModel.remove(torrent.infoHash, deleteFiles = true) },
)
when (state) {
MainScreenUiState.Loading ->
CircularProgressIndicator(Modifier.align(Alignment.CenterHorizontally))
is MainScreenUiState.Error ->
Text("Error: ${(state as MainScreenUiState.Error).throwable.message}")
is MainScreenUiState.Success -> {
val torrents = (state as MainScreenUiState.Success).torrents
if (torrents.isEmpty()) {
Text(
text = "No active torrents.\nPaste a magnet link above to start.",
modifier = Modifier.padding(top = 24.dp),
)
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(300.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(torrents, key = { it.infoHash }) { torrent ->
TorrentCard(
torrent = torrent,
onPause = { viewModel.pause(torrent.infoHash) },
onResume = { viewModel.resume(torrent.infoHash) },
onRemove = { viewModel.remove(torrent.infoHash, deleteFiles = true) },
)
}
}
}
}
Expand Down