diff --git a/README.md b/README.md index 0c81504..3c01b11 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ba31878..124b023 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,7 +54,8 @@ android { buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f862a8f --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,10 @@ +# Keep TorrentManager native (JNI) methods so R8 doesn't rename them +-keep class com.jpcexample.simpletorrent.data.TorrentManager { + native ; +} + +# Keep @Serializable classes and their companion serializers (kotlinx.serialization) +-keep @kotlinx.serialization.Serializable class * { *; } +-keepclassmembers @kotlinx.serialization.Serializable class * { + *** Companion; +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3bd0671..9088122 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,7 +23,8 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" - android:windowSoftInputMode="adjustResize"> + android:windowSoftInputMode="adjustResize" + tools:ignore="Instantiatable"> diff --git a/app/src/main/java/com/jpcexample/simpletorrent/Navigation.kt b/app/src/main/java/com/jpcexample/simpletorrent/Navigation.kt index d30e9cb..0ddcdbf 100644 --- a/app/src/main/java/com/jpcexample/simpletorrent/Navigation.kt +++ b/app/src/main/java/com/jpcexample/simpletorrent/Navigation.kt @@ -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 @@ -19,7 +16,7 @@ fun MainNavigation() { entryProvider = entryProvider { entry
{ - MainScreen(onItemClick = { navKey -> backStack.add(navKey) }, modifier = Modifier.safeDrawingPadding()) + MainScreen(onItemClick = { navKey -> backStack.add(navKey) }) } }, ) diff --git a/app/src/main/java/com/jpcexample/simpletorrent/ui/main/MainScreen.kt b/app/src/main/java/com/jpcexample/simpletorrent/ui/main/MainScreen.kt index 065d0dd..775a36d 100644 --- a/app/src/main/java/com/jpcexample/simpletorrent/ui/main/MainScreen.kt +++ b/app/src/main/java/com/jpcexample/simpletorrent/ui/main/MainScreen.kt @@ -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 @@ -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) }, + ) + } } } }