├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── develop_ci.yml │ └── pr_ci.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE.txt ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── lint-baseline.xml ├── proguard-rules.pro ├── schemas │ └── dev.yashgarg.qbit.data.AppDatabase │ │ ├── 1.json │ │ ├── 2.json │ │ └── 3.json └── src │ ├── main │ ├── AndroidManifest.xml │ ├── baseline-prof.txt │ ├── ic_launcher-playstore.png │ ├── kotlin │ │ └── dev │ │ │ └── yashgarg │ │ │ └── qbit │ │ │ ├── MainActivity.kt │ │ │ ├── QbitApplication.kt │ │ │ ├── data │ │ │ ├── AppDatabase.kt │ │ │ ├── QbitRepository.kt │ │ │ ├── daos │ │ │ │ └── ConfigDao.kt │ │ │ ├── manager │ │ │ │ ├── CilentManager.kt │ │ │ │ ├── ClientManagerImpl.kt │ │ │ │ └── SslSettings.kt │ │ │ ├── models │ │ │ │ ├── ConfigStatus.kt │ │ │ │ ├── ConnectionType.kt │ │ │ │ ├── FilePriority.kt │ │ │ │ ├── ServerConfig.kt │ │ │ │ ├── ServerPreferences.kt │ │ │ │ └── TrackerStatus.kt │ │ │ └── preferences │ │ │ │ └── ServerPreferencesSerializer.kt │ │ │ ├── di │ │ │ └── AppModule.kt │ │ │ ├── ui │ │ │ ├── common │ │ │ │ └── ListTileTextView.kt │ │ │ ├── config │ │ │ │ ├── ConfigFragment.kt │ │ │ │ ├── ConfigState.kt │ │ │ │ └── ConfigViewModel.kt │ │ │ ├── dialogs │ │ │ │ ├── AddTorrentDialog.kt │ │ │ │ ├── RemoveTorrentDialog.kt │ │ │ │ └── RenameTorrentDialog.kt │ │ │ ├── home │ │ │ │ └── HomeFragment.kt │ │ │ ├── server │ │ │ │ ├── ServerFragment.kt │ │ │ │ ├── ServerState.kt │ │ │ │ ├── ServerViewModel.kt │ │ │ │ └── adapter │ │ │ │ │ └── TorrentListAdapter.kt │ │ │ ├── torrent │ │ │ │ ├── TorrentDetailsFragment.kt │ │ │ │ ├── TorrentDetailsState.kt │ │ │ │ ├── TorrentDetailsViewModel.kt │ │ │ │ ├── adapter │ │ │ │ │ ├── TorrentDetailsAdapter.kt │ │ │ │ │ └── TorrentTrackersAdapter.kt │ │ │ │ └── tabs │ │ │ │ │ ├── TorrentFilesFragment.kt │ │ │ │ │ ├── TorrentInfoFragment.kt │ │ │ │ │ ├── TorrentPeersFragment.kt │ │ │ │ │ └── TorrentTrackersFragment.kt │ │ │ └── version │ │ │ │ ├── VersionFragment.kt │ │ │ │ ├── VersionState.kt │ │ │ │ └── VersionViewModel.kt │ │ │ ├── utils │ │ │ ├── ClipboardUtil.kt │ │ │ ├── CountryFlags.kt │ │ │ ├── Exceptions.kt │ │ │ ├── NumberFormat.kt │ │ │ ├── PermissionUtil.kt │ │ │ ├── TransformUtil.kt │ │ │ └── ViewBindingDelegate.kt │ │ │ ├── validation │ │ │ ├── HostValidator.kt │ │ │ ├── LinkValidator.kt │ │ │ ├── PortValidator.kt │ │ │ ├── StringValidator.kt │ │ │ └── TextValidator.kt │ │ │ └── worker │ │ │ └── StatusWorker.kt │ └── res │ │ ├── drawable │ │ ├── baseline_sync.xml │ │ ├── cloud_done.xml │ │ ├── cloud_done_scaled.xml │ │ ├── more_vert.xml │ │ ├── outline_category_24.xml │ │ ├── outline_delete_24.xml │ │ ├── sync_error.xml │ │ ├── sync_error_scaled.xml │ │ ├── twotone_add_24.xml │ │ ├── twotone_content_copy_24.xml │ │ ├── twotone_content_paste_24.xml │ │ ├── twotone_delete_24.xml │ │ ├── twotone_drive_file_rename_outline_24.xml │ │ ├── twotone_find_in_page_24.xml │ │ ├── twotone_pause_24.xml │ │ ├── twotone_play_arrow_24.xml │ │ ├── twotone_restore_page_24.xml │ │ ├── twotone_sort_24.xml │ │ └── twotone_speed_24.xml │ │ ├── font-v26 │ │ └── space_grotesk.xml │ │ ├── font │ │ └── space_grotesk.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── config_fragment.xml │ │ ├── delete_files_dialog.xml │ │ ├── edit_text.xml │ │ ├── home_fragment.xml │ │ ├── list_item.xml │ │ ├── list_tile.xml │ │ ├── rename_torrent_dialog.xml │ │ ├── server_fragment.xml │ │ ├── torrent_details_fragment.xml │ │ ├── torrent_info_fragment.xml │ │ ├── torrent_item.xml │ │ ├── torrent_peers_fragment.xml │ │ ├── torrent_trackers_fragment.xml │ │ ├── tracker_item.xml │ │ └── version_fragment.xml │ │ ├── menu │ │ ├── server_bottombar.xml │ │ └── torrent_options.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ └── ic_launcher_foreground.png │ │ ├── navigation │ │ └── nav_graph.xml │ │ ├── raw │ │ └── loader_animation.json │ │ ├── values-v29 │ │ └── themes.xml │ │ ├── values │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── styles.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ ├── data_extraction_rules.xml │ │ └── network_security_config.xml │ ├── nonFree │ └── AndroidManifest.xml │ └── test │ └── kotlin │ └── dev │ └── yashgarg │ └── qbit │ ├── Constants.kt │ ├── FakeClientManager.kt │ ├── MainDispatcherRule.kt │ ├── data │ └── QbitRepositoryTest.kt │ ├── utils │ └── NumberFormatTest.kt │ └── validation │ ├── HostValidatorTest.kt │ └── PortValidatorTest.kt ├── art ├── screen-1.png ├── screen-2.png ├── screen-3.png ├── screen-4.png ├── screen-5.png └── screen-6.png ├── benchmark ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── dev │ └── yashgarg │ └── benchmark │ ├── BaselineProfileGenerator.kt │ ├── StartupBenchmark.kt │ └── UiHelper.kt ├── bonsai-core ├── .gitignore ├── build.gradle.kts ├── lint-baseline.xml └── src │ ├── AndroidManifest.xml │ └── main │ └── kotlin │ └── cafe │ └── adriel │ └── bonsai │ └── core │ ├── Bonsai.kt │ ├── node │ ├── ComposeNode.kt │ ├── Node.kt │ ├── UiNode.kt │ └── extension │ │ ├── ExpandableNode.kt │ │ └── SelectableNode.kt │ ├── tree │ ├── Tree.kt │ └── extension │ │ ├── ExpandableTree.kt │ │ └── SelectableTree.kt │ └── util │ └── Expects.kt ├── build-logic ├── .gitignore ├── build.gradle.kts ├── settings.gradle.kts └── src │ └── main │ └── kotlin │ └── dev │ └── yashgarg │ └── qbit │ └── gradle │ ├── GitHooksPlugin.kt │ ├── KotlinAndroidPlugin.kt │ ├── KotlinCommonPlugin.kt │ └── SpotlessPlugin.kt ├── build.gradle.kts ├── client-wrapper ├── LICENSE ├── README.md ├── client │ ├── build.gradle.kts │ └── src │ │ ├── commonMain │ │ └── kotlin │ │ │ ├── QBittorrentClient.kt │ │ │ ├── QBittorrentException.kt │ │ │ └── internal │ │ │ ├── AtomicReference.kt │ │ │ ├── AuthHandler.kt │ │ │ ├── DataSync.kt │ │ │ ├── ErrorTransformer.kt │ │ │ ├── FileReader.kt │ │ │ ├── HttpUtils.kt │ │ │ ├── JsonUtils.kt │ │ │ └── RawCookiesStorage.kt │ │ └── jvmMain │ │ └── kotlin │ │ ├── AtomicReference.kt │ │ └── FileReader.kt ├── models │ ├── build.gradle.kts │ └── src │ │ └── commonMain │ │ └── kotlin │ │ ├── AddTorrentBody.kt │ │ ├── BuildInfo.kt │ │ ├── Category.kt │ │ ├── FlakyIntSerializer.kt │ │ ├── GlobalTransferInfo.kt │ │ ├── KeyMergingTransformer.kt │ │ ├── LogEntry.kt │ │ ├── MainData.kt │ │ ├── PeerLog.kt │ │ ├── PieceState.kt │ │ ├── ServerState.kt │ │ ├── TagListSerializer.kt │ │ ├── Torrent.kt │ │ ├── TorrentFile.kt │ │ ├── TorrentFilter.kt │ │ ├── TorrentPeer.kt │ │ ├── TorrentPeers.kt │ │ ├── TorrentProperties.kt │ │ ├── TorrentTracker.kt │ │ └── Webseed.kt └── settings.gradle.kts ├── common ├── .gitignore ├── build.gradle.kts ├── lint-baseline.xml └── src │ └── main │ ├── kotlin │ └── dev │ │ └── yashgarg │ │ └── qbit │ │ ├── data │ │ └── models │ │ │ └── ContentTreeItem.kt │ │ └── notifications │ │ └── AppNotificationManager.kt │ └── res │ └── values │ └── strings.xml ├── fastlane └── metadata │ └── android │ └── en-US │ ├── changelogs │ ├── 12.txt │ ├── 5.txt │ ├── 7.txt │ ├── 8.txt │ └── 9.txt │ ├── full_description.txt │ ├── images │ ├── featureGraphic.png │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ ├── 5.png │ │ └── 6.png │ ├── short_description.txt │ └── title.txt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── hooks └── pre-commit.sh ├── renovate.json ├── sentry.properties ├── settings.gradle.kts └── ui-compose ├── .gitignore ├── build.gradle.kts ├── lint-baseline.xml └── src └── main ├── AndroidManifest.xml ├── kotlin └── dev │ └── yashgarg │ └── qbit │ └── ui │ └── compose │ ├── CenterLinearLoading.kt │ ├── CenterView.kt │ ├── ListTile.kt │ ├── TorrentContentTreeView.kt │ └── theme │ ├── Color.kt │ └── Type.kt └── res └── font ├── spacegrotesk_bold.ttf ├── spacegrotesk_medium.ttf ├── spacegrotesk_regular.ttf └── spacegrotesk_semibold.ttf /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Yash-Garg] 4 | patreon: yashgarg 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: "C-bug, S-awaiting-development" 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Smartphone (please complete the following information):** 27 | - Device: [e.g. Pixel 7] 28 | - OS: [e.g. Android 13] 29 | - App Version [e.g. v0.1.6] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE REQUEST]" 5 | labels: "C-feature,S-awaiting-development" 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/* 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | *.jks 12 | **/build/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qBittorrent-Manager (SLOW DEVELOPMENT) 🚧 2 | 3 | [![Android CI](https://github.com/Yash-Garg/qBittorrent-Manager/actions/workflows/develop_ci.yml/badge.svg?branch=develop)](https://github.com/Yash-Garg/qBittorrent-Manager/actions/workflows/develop_ci.yml) 4 | 5 | This is the repository for **_qBittorrent Manager_**, an Android app for managing [qBittorrent](http://www.qbittorrent.org/) remotely, written in Kotlin. 6 | 7 | **I mostly work on this project in my free time, so the development may be slow.** 8 | 9 |

10 | Maintenance of this project is made possible by all the contributors and sponsors. If you'd like to sponsor this project and have your avatar or company logo appear below click here. 💖 11 |

12 | 13 |

14 | DrewCarlson   15 |

16 | 17 | 18 | ## Download 19 | 20 | 21 | Get it on F-Droid 24 | 25 | 26 | 27 | Get it on Google Play 30 | 31 | 32 | ## Previews 33 | 34 |

35 | 36 | 37 | 38 | 39 | 40 | 41 |

42 | 43 | ## License 44 | 45 | See [LICENSE](LICENSE.txt) 46 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release 3 | *.jks 4 | *.properties 5 | free/* 6 | nonFree/* -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | -dontwarn org.slf4j.impl.StaticLoggerBinder 9 | -dontwarn org.slf4j.impl.StaticMDCBinder 10 | -dontwarn org.bouncycastle.jsse.BCSSLParameters 11 | -dontwarn org.bouncycastle.jsse.BCSSLSocket 12 | -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider 13 | -dontwarn org.conscrypt.Conscrypt$Version 14 | -dontwarn org.conscrypt.Conscrypt 15 | -dontwarn org.conscrypt.ConscryptHostnameVerifier 16 | -dontwarn org.openjsse.javax.net.ssl.SSLParameters 17 | -dontwarn org.openjsse.javax.net.ssl.SSLSocket 18 | -dontwarn org.openjsse.net.ssl.OpenJSSE 19 | 20 | # Keep `Companion` object fields of serializable classes. 21 | # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. 22 | -if @kotlinx.serialization.Serializable class ** 23 | -keepclassmembers class <1> { 24 | static <1>$Companion Companion; 25 | } 26 | 27 | # Keep `serializer()` on companion objects (both default and named) of serializable classes. 28 | -if @kotlinx.serialization.Serializable class ** { 29 | static **$* *; 30 | } 31 | -keepclassmembers class <2>$<3> { 32 | kotlinx.serialization.KSerializer serializer(...); 33 | } 34 | 35 | # Keep `INSTANCE.serializer()` of serializable objects. 36 | -if @kotlinx.serialization.Serializable class ** { 37 | public static ** INSTANCE; 38 | } 39 | -keepclassmembers class <1> { 40 | public static <1> INSTANCE; 41 | kotlinx.serialization.KSerializer serializer(...); 42 | } 43 | 44 | # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. 45 | -keepattributes RuntimeVisibleAnnotations,AnnotationDefault 46 | 47 | # Don't print notes about potential mistakes or omissions in the configuration for kotlinx-serialization classes 48 | # See also https://github.com/Kotlin/kotlinx.serialization/issues/1900 49 | -dontnote kotlinx.serialization.** 50 | 51 | # Serialization core uses `java.lang.ClassValue` for caching inside these specified classes. 52 | # If there is no `java.lang.ClassValue` (for example, in Android), then R8/ProGuard will print a warning. 53 | # However, since in this case they will not be used, we can disable these warnings 54 | -dontwarn kotlinx.serialization.internal.ClassValueReferences -------------------------------------------------------------------------------- /app/schemas/dev.yashgarg.qbit.data.AppDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "1d78153b2d86c72ab89ffd2a8628fef6", 6 | "entities": [ 7 | { 8 | "tableName": "configs", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`config_id` INTEGER NOT NULL, `serverName` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `connectionType` TEXT NOT NULL, PRIMARY KEY(`config_id`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "configId", 13 | "columnName": "config_id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "serverName", 19 | "columnName": "serverName", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "baseUrl", 25 | "columnName": "baseUrl", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "port", 31 | "columnName": "port", 32 | "affinity": "INTEGER", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "username", 37 | "columnName": "username", 38 | "affinity": "TEXT", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "password", 43 | "columnName": "password", 44 | "affinity": "TEXT", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "connectionType", 49 | "columnName": "connectionType", 50 | "affinity": "TEXT", 51 | "notNull": true 52 | } 53 | ], 54 | "primaryKey": { 55 | "autoGenerate": false, 56 | "columnNames": [ 57 | "config_id" 58 | ] 59 | }, 60 | "indices": [], 61 | "foreignKeys": [] 62 | } 63 | ], 64 | "views": [], 65 | "setupQueries": [ 66 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 67 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1d78153b2d86c72ab89ffd2a8628fef6')" 68 | ] 69 | } 70 | } -------------------------------------------------------------------------------- /app/schemas/dev.yashgarg.qbit.data.AppDatabase/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 2, 5 | "identityHash": "21b3cf9c071443efedec82931fa06f54", 6 | "entities": [ 7 | { 8 | "tableName": "configs", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`config_id` INTEGER NOT NULL, `serverName` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `port` INTEGER, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `connectionType` TEXT NOT NULL, `trustSelfSigned` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`config_id`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "configId", 13 | "columnName": "config_id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "serverName", 19 | "columnName": "serverName", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "baseUrl", 25 | "columnName": "baseUrl", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "port", 31 | "columnName": "port", 32 | "affinity": "INTEGER", 33 | "notNull": false 34 | }, 35 | { 36 | "fieldPath": "username", 37 | "columnName": "username", 38 | "affinity": "TEXT", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "password", 43 | "columnName": "password", 44 | "affinity": "TEXT", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "connectionType", 49 | "columnName": "connectionType", 50 | "affinity": "TEXT", 51 | "notNull": true 52 | }, 53 | { 54 | "fieldPath": "trustSelfSigned", 55 | "columnName": "trustSelfSigned", 56 | "affinity": "INTEGER", 57 | "notNull": true, 58 | "defaultValue": "0" 59 | } 60 | ], 61 | "primaryKey": { 62 | "autoGenerate": false, 63 | "columnNames": [ 64 | "config_id" 65 | ] 66 | }, 67 | "indices": [], 68 | "foreignKeys": [] 69 | } 70 | ], 71 | "views": [], 72 | "setupQueries": [ 73 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 74 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '21b3cf9c071443efedec82931fa06f54')" 75 | ] 76 | } 77 | } -------------------------------------------------------------------------------- /app/schemas/dev.yashgarg.qbit.data.AppDatabase/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 3, 5 | "identityHash": "fd3c97ded76419eef57c25570fef8bfb", 6 | "entities": [ 7 | { 8 | "tableName": "configs", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`config_id` INTEGER NOT NULL, `serverName` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `port` INTEGER, `path` TEXT, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `connectionType` TEXT NOT NULL, `trustSelfSigned` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`config_id`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "configId", 13 | "columnName": "config_id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "serverName", 19 | "columnName": "serverName", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "baseUrl", 25 | "columnName": "baseUrl", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "port", 31 | "columnName": "port", 32 | "affinity": "INTEGER", 33 | "notNull": false 34 | }, 35 | { 36 | "fieldPath": "path", 37 | "columnName": "path", 38 | "affinity": "TEXT", 39 | "notNull": false 40 | }, 41 | { 42 | "fieldPath": "username", 43 | "columnName": "username", 44 | "affinity": "TEXT", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "password", 49 | "columnName": "password", 50 | "affinity": "TEXT", 51 | "notNull": true 52 | }, 53 | { 54 | "fieldPath": "connectionType", 55 | "columnName": "connectionType", 56 | "affinity": "TEXT", 57 | "notNull": true 58 | }, 59 | { 60 | "fieldPath": "trustSelfSigned", 61 | "columnName": "trustSelfSigned", 62 | "affinity": "INTEGER", 63 | "notNull": true, 64 | "defaultValue": "0" 65 | } 66 | ], 67 | "primaryKey": { 68 | "autoGenerate": false, 69 | "columnNames": [ 70 | "config_id" 71 | ] 72 | }, 73 | "indices": [], 74 | "foreignKeys": [] 75 | } 76 | ], 77 | "views": [], 78 | "setupQueries": [ 79 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 80 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fd3c97ded76419eef57c25570fef8bfb')" 81 | ] 82 | } 83 | } -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/QbitApplication.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit 2 | 3 | import android.app.Application 4 | import androidx.hilt.work.HiltWorkerFactory 5 | import androidx.work.Configuration 6 | import dagger.hilt.android.HiltAndroidApp 7 | import dev.yashgarg.qbit.notifications.AppNotificationManager 8 | import javax.inject.Inject 9 | 10 | @HiltAndroidApp 11 | class QbitApplication : Application(), Configuration.Provider { 12 | 13 | @Inject lateinit var workerFactory: HiltWorkerFactory 14 | 15 | override fun getWorkManagerConfiguration() = 16 | Configuration.Builder().setWorkerFactory(workerFactory).build() 17 | 18 | override fun onCreate() { 19 | super.onCreate() 20 | 21 | AppNotificationManager.createNotificationChannel(applicationContext) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/data/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.data 2 | 3 | import androidx.room.AutoMigration 4 | import androidx.room.Database 5 | import androidx.room.RoomDatabase 6 | import androidx.room.migration.Migration 7 | import androidx.sqlite.db.SupportSQLiteDatabase 8 | import dev.yashgarg.qbit.data.daos.ConfigDao 9 | import dev.yashgarg.qbit.data.models.ServerConfig 10 | 11 | @Database( 12 | entities = [ServerConfig::class], 13 | version = 3, 14 | autoMigrations = [AutoMigration(from = 2, to = 3)], 15 | exportSchema = true 16 | ) 17 | abstract class AppDatabase : RoomDatabase() { 18 | abstract fun configDao(): ConfigDao 19 | 20 | companion object { 21 | const val DB_NAME = "qbit-db" 22 | } 23 | } 24 | 25 | val MIGRATION_1_2 = 26 | object : Migration(1, 2) { 27 | override fun migrate(database: SupportSQLiteDatabase) { 28 | with(database) { 29 | execSQL( 30 | "CREATE TABLE IF NOT EXISTS configsTmp (config_id INTEGER NOT NULL, serverName TEXT NOT NULL, " + 31 | "baseUrl TEXT NOT NULL, port INTEGER, username TEXT NOT NULL, password TEXT NOT NULL, connectionType TEXT NOT NULL, " + 32 | "trustSelfSigned INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(config_id))" 33 | ) 34 | 35 | execSQL( 36 | "INSERT INTO configsTmp (config_id, serverName, baseUrl, port, username, password, " + 37 | "connectionType) SELECT config_id, serverName, baseUrl, port, username, password, connectionType FROM configs" 38 | ) 39 | 40 | execSQL("UPDATE configsTmp SET port = NULL WHERE port = 443") 41 | 42 | execSQL("DROP TABLE configs") 43 | 44 | execSQL("ALTER TABLE configsTmp RENAME TO configs") 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/data/daos/ConfigDao.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.data.daos 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import dev.yashgarg.qbit.data.models.ServerConfig 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | @Dao 11 | interface ConfigDao { 12 | @Query("SELECT * FROM configs") fun getConfigs(): Flow> 13 | 14 | @Query("SELECT * FROM configs WHERE config_id = :index") 15 | fun getConfigAtIndex(index: Int = 0): ServerConfig? 16 | 17 | @Insert(onConflict = OnConflictStrategy.REPLACE) fun addConfig(config: ServerConfig) 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/data/manager/CilentManager.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.data.manager 2 | 3 | import android.util.Log 4 | import dev.yashgarg.qbit.data.models.ConfigStatus 5 | import io.ktor.client.HttpClient 6 | import io.ktor.client.engine.okhttp.OkHttp 7 | import io.ktor.client.plugins.HttpTimeout 8 | import io.ktor.client.plugins.logging.LogLevel 9 | import io.ktor.client.plugins.logging.Logger 10 | import io.ktor.client.plugins.logging.Logging 11 | import kotlin.time.Duration.Companion.seconds 12 | import kotlinx.coroutines.flow.SharedFlow 13 | import qbittorrent.QBittorrentClient 14 | 15 | interface ClientManager { 16 | val configStatus: SharedFlow 17 | 18 | suspend fun checkAndGetClient(): QBittorrentClient? 19 | 20 | companion object { 21 | const val tag = "ClientManager" 22 | val syncInterval = 1.seconds 23 | 24 | fun httpClient(trustAllCerts: Boolean): HttpClient { 25 | return HttpClient(OkHttp) { 26 | install(HttpTimeout) { connectTimeoutMillis = 3000 } 27 | install(Logging) { 28 | logger = 29 | object : Logger { 30 | override fun log(message: String) { 31 | Log.i(tag, message) 32 | } 33 | } 34 | level = LogLevel.NONE 35 | } 36 | engine { 37 | if (trustAllCerts) { 38 | config { 39 | sslSocketFactory( 40 | SslSettings.getSslContext()!!.socketFactory, 41 | SslSettings.getTrustManager() 42 | ) 43 | hostnameVerifier { _, _ -> true } 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/data/manager/ClientManagerImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.data.manager 2 | 3 | import android.util.Log 4 | import com.github.michaelbull.result.Err 5 | import com.github.michaelbull.result.Ok 6 | import com.github.michaelbull.result.runCatching 7 | import dev.yashgarg.qbit.data.daos.ConfigDao 8 | import dev.yashgarg.qbit.data.models.ConfigStatus 9 | import dev.yashgarg.qbit.di.ApplicationScope 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | import kotlinx.coroutines.* 13 | import kotlinx.coroutines.flow.MutableSharedFlow 14 | import kotlinx.coroutines.flow.asSharedFlow 15 | import qbittorrent.QBittorrentClient 16 | 17 | @Singleton 18 | class ClientManagerImpl 19 | @Inject 20 | constructor( 21 | private val configDao: ConfigDao, 22 | @ApplicationScope coroutineScope: CoroutineScope, 23 | ) : ClientManager { 24 | private val _configStatus = MutableSharedFlow(replay = 1) 25 | override val configStatus = _configStatus.asSharedFlow() 26 | 27 | private var client: QBittorrentClient? = null 28 | 29 | init { 30 | coroutineScope.launch { checkIfConfigsExist() } 31 | } 32 | 33 | private suspend fun checkIfConfigsExist() { 34 | withContext(Dispatchers.IO) { 35 | configDao.getConfigs().collect { configs -> 36 | if (configs.isNotEmpty()) { 37 | _configStatus.emit(ConfigStatus.EXISTS) 38 | checkAndGetClient() 39 | } else { 40 | _configStatus.emit(ConfigStatus.DOES_NOT_EXIST) 41 | } 42 | } 43 | } 44 | } 45 | 46 | override suspend fun checkAndGetClient(): QBittorrentClient? { 47 | return when (val result = runCatching { getClient() }) { 48 | is Ok -> { 49 | this.client = result.value 50 | client 51 | } 52 | is Err -> { 53 | Log.e(this::class.simpleName, result.error.toString()) 54 | null 55 | } 56 | } 57 | } 58 | 59 | private suspend fun getClient(): QBittorrentClient = 60 | withContext(Dispatchers.IO) { 61 | if (client == null) { 62 | val config = configDao.getConfigAtIndex()!! 63 | val port = if (config.port != null) ":${config.port}" else "" 64 | val path = config.path ?: "" 65 | 66 | client = 67 | QBittorrentClient( 68 | "${config.connectionType.toString().lowercase()}://${config.baseUrl}$port$path", 69 | config.username, 70 | config.password, 71 | syncInterval = ClientManager.syncInterval, 72 | httpClient = ClientManager.httpClient(config.trustSelfSigned), 73 | dispatcher = Dispatchers.Default, 74 | ) 75 | } 76 | return@withContext requireNotNull(client) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/data/manager/SslSettings.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.data.manager 2 | 3 | import android.annotation.SuppressLint 4 | import java.security.SecureRandom 5 | import java.security.cert.X509Certificate 6 | import javax.net.ssl.SSLContext 7 | import javax.net.ssl.X509TrustManager 8 | 9 | object SslSettings { 10 | fun getSslContext(): SSLContext? { 11 | val sslContext = SSLContext.getInstance("TLS") 12 | sslContext.init(null, arrayOf(TrustAllX509TrustManager()), SecureRandom()) 13 | return sslContext 14 | } 15 | 16 | fun getTrustManager() = TrustAllX509TrustManager() 17 | } 18 | 19 | @SuppressLint("TrustAllX509TrustManager", "CustomX509TrustManager") 20 | class TrustAllX509TrustManager : X509TrustManager { 21 | override fun getAcceptedIssuers() = arrayOf() 22 | 23 | override fun checkClientTrusted(certs: Array?, authType: String?) {} 24 | 25 | override fun checkServerTrusted(certs: Array?, authType: String?) {} 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/data/models/ConfigStatus.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.data.models 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | enum class ConfigStatus { 7 | EXISTS, 8 | DOES_NOT_EXIST 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/data/models/ConnectionType.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.data.models 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | enum class ConnectionType { 7 | HTTP, 8 | HTTPS 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/data/models/FilePriority.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.data.models 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | enum class FilePriority { 7 | NOT_DOWNLOAD, 8 | NORMAL, 9 | HIGH, 10 | MAXIMAL; 11 | 12 | companion object { 13 | fun valueOf(priority: Int): FilePriority { 14 | return when (priority) { 15 | 0 -> NOT_DOWNLOAD 16 | 1 -> NORMAL 17 | 6 -> HIGH 18 | 7 -> MAXIMAL 19 | else -> throw IllegalArgumentException("Invalid priority value received") 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/data/models/ServerConfig.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.data.models 2 | 3 | import androidx.annotation.Keep 4 | import androidx.room.ColumnInfo 5 | import androidx.room.Entity 6 | import androidx.room.PrimaryKey 7 | 8 | @Keep 9 | @Entity(tableName = "configs") 10 | data class ServerConfig( 11 | @PrimaryKey @ColumnInfo("config_id") val configId: Int, 12 | val serverName: String, 13 | val baseUrl: String, 14 | val port: Int? = null, 15 | val path: String? = null, 16 | val username: String, 17 | val password: String, 18 | val connectionType: ConnectionType, 19 | @ColumnInfo(defaultValue = "0") val trustSelfSigned: Boolean 20 | ) 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/data/models/ServerPreferences.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.data.models 2 | 3 | import androidx.annotation.Keep 4 | import kotlinx.serialization.Serializable 5 | 6 | @Keep @Serializable data class ServerPreferences(val showNotification: Boolean = true) 7 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/data/models/TrackerStatus.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.data.models 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | enum class TrackerStatus { 7 | DISABLED, 8 | UPDATING, 9 | NOT_CONTACTED, 10 | CONTACTED_WORKING, 11 | CONTACTED_NOT_WORKING; 12 | 13 | companion object { 14 | fun statusOf(status: Int): TrackerStatus { 15 | return when (status) { 16 | 0 -> DISABLED 17 | 1 -> NOT_CONTACTED 18 | 2 -> CONTACTED_WORKING 19 | 3 -> UPDATING 20 | 4 -> CONTACTED_NOT_WORKING 21 | else -> throw IllegalArgumentException("Invalid status code") 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/data/preferences/ServerPreferencesSerializer.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.data.preferences 2 | 3 | import androidx.datastore.core.CorruptionException 4 | import androidx.datastore.core.Serializer 5 | import dev.yashgarg.qbit.BuildConfig 6 | import dev.yashgarg.qbit.data.models.ServerPreferences 7 | import java.io.InputStream 8 | import java.io.OutputStream 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.withContext 11 | import kotlinx.serialization.SerializationException 12 | import kotlinx.serialization.json.Json 13 | 14 | object ServerPreferencesSerializer : Serializer { 15 | const val SERVER_PREFS_NAME = "${BuildConfig.APPLICATION_ID}_preferences" 16 | 17 | override val defaultValue = ServerPreferences() 18 | 19 | override suspend fun readFrom(input: InputStream): ServerPreferences { 20 | try { 21 | return Json.decodeFromString( 22 | ServerPreferences.serializer(), 23 | input.readBytes().decodeToString() 24 | ) 25 | } catch (serialization: SerializationException) { 26 | throw CorruptionException("Unable to read ServerPrefs", serialization) 27 | } 28 | } 29 | 30 | override suspend fun writeTo(t: ServerPreferences, output: OutputStream) { 31 | withContext(Dispatchers.IO) { 32 | output.write(Json.encodeToString(ServerPreferences.serializer(), t).encodeToByteArray()) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.di 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.core.DataStoreFactory 6 | import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler 7 | import androidx.datastore.preferences.preferencesDataStoreFile 8 | import androidx.room.Room 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.android.qualifiers.ApplicationContext 13 | import dagger.hilt.components.SingletonComponent 14 | import dev.yashgarg.qbit.data.AppDatabase 15 | import dev.yashgarg.qbit.data.MIGRATION_1_2 16 | import dev.yashgarg.qbit.data.QbitRepository 17 | import dev.yashgarg.qbit.data.manager.ClientManager 18 | import dev.yashgarg.qbit.data.manager.ClientManagerImpl 19 | import dev.yashgarg.qbit.data.models.ServerPreferences 20 | import dev.yashgarg.qbit.data.preferences.ServerPreferencesSerializer 21 | import javax.inject.Qualifier 22 | import javax.inject.Singleton 23 | import kotlinx.coroutines.CoroutineScope 24 | import kotlinx.coroutines.Dispatchers 25 | import kotlinx.coroutines.MainScope 26 | import kotlinx.coroutines.SupervisorJob 27 | 28 | @Module 29 | @InstallIn(SingletonComponent::class) 30 | class AppModule { 31 | 32 | @Singleton 33 | @Provides 34 | fun provideRoomDb(@ApplicationContext context: Context) = 35 | Room.databaseBuilder(context, AppDatabase::class.java, AppDatabase.DB_NAME) 36 | .addMigrations(MIGRATION_1_2) 37 | .build() 38 | 39 | @Singleton @Provides fun provideConfigDao(db: AppDatabase) = db.configDao() 40 | 41 | @ApplicationScope @Provides fun provideCoroutineScope() = MainScope() 42 | 43 | @Provides 44 | fun provideClientManager(clientManager: ClientManagerImpl): ClientManager = clientManager 45 | 46 | @Provides 47 | fun provideQbitRepository(clientManager: ClientManager) = 48 | QbitRepository(Dispatchers.IO, clientManager) 49 | 50 | @Singleton 51 | @Provides 52 | fun provideServerPreferencesDataStore( 53 | @ApplicationContext appContext: Context 54 | ): DataStore { 55 | return DataStoreFactory.create( 56 | serializer = ServerPreferencesSerializer, 57 | corruptionHandler = 58 | ReplaceFileCorruptionHandler(produceNewData = { ServerPreferences() }), 59 | scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), 60 | produceFile = { 61 | appContext.preferencesDataStoreFile(ServerPreferencesSerializer.SERVER_PREFS_NAME) 62 | } 63 | ) 64 | } 65 | } 66 | 67 | @Qualifier @Retention(AnnotationRetention.BINARY) annotation class ApplicationScope 68 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/common/ListTileTextView.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.common 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.View 6 | import android.widget.LinearLayout 7 | import android.widget.TextView 8 | import dev.yashgarg.qbit.R 9 | import dev.yashgarg.qbit.utils.ClipboardUtil 10 | 11 | class ListTileTextView(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) { 12 | private var titleTv: TextView 13 | private var subtitleTv: TextView 14 | 15 | var subtitle: String? = null 16 | set(value) { 17 | subtitleTv.text = value 18 | field = value 19 | } 20 | 21 | init { 22 | val typedArr = context.obtainStyledAttributes(attrs, R.styleable.ListTileTextView, 0, 0) 23 | View.inflate(context, R.layout.list_tile, this) 24 | 25 | titleTv = findViewById(R.id.title) 26 | subtitleTv = findViewById(R.id.subtitle) 27 | 28 | try { 29 | titleTv.text = typedArr.getString(R.styleable.ListTileTextView_title) 30 | subtitleTv.text = subtitle 31 | 32 | this.setOnLongClickListener { 33 | ClipboardUtil.copyToClipboard( 34 | context, 35 | titleTv.text.toString(), 36 | "${titleTv.text} | $subtitle" 37 | ) 38 | true 39 | } 40 | } finally { 41 | typedArr.recycle() 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/config/ConfigState.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.config 2 | 3 | data class ConfigState( 4 | val isServerNameValid: Boolean = false, 5 | val isServerUrlValid: Boolean = false, 6 | val isPortValid: Boolean = false, 7 | val isUsernameValid: Boolean = false, 8 | val isPasswordValid: Boolean = false, 9 | val isConnectionTypeValid: Boolean = false, 10 | val showServerNameError: Boolean = false, 11 | val showUrlError: Boolean = false, 12 | val showPortError: Boolean = false, 13 | val showUsernameError: Boolean = false, 14 | val showPasswordError: Boolean = false, 15 | val showConnectionTypeError: Boolean = false, 16 | val isTrustSelfSignedChecked: Boolean = false 17 | ) 18 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/dialogs/RemoveTorrentDialog.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.dialogs 2 | 3 | import android.app.AlertDialog 4 | import android.app.Dialog 5 | import android.os.Bundle 6 | import android.widget.CheckBox 7 | import android.widget.LinearLayout 8 | import androidx.core.os.bundleOf 9 | import androidx.fragment.app.DialogFragment 10 | import androidx.fragment.app.setFragmentResult 11 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 12 | import dev.yashgarg.qbit.R 13 | 14 | class RemoveTorrentDialog : DialogFragment() { 15 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 16 | val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext()) 17 | 18 | // TODO: Switch to string resources below 19 | alertDialogBuilder.apply { 20 | setTitle("Are you sure you want to delete the torrent(s)?") 21 | setView(R.layout.delete_files_dialog) 22 | setPositiveButton("Yes", null) 23 | setNegativeButton("No") { dialog, _ -> dialog.dismiss() } 24 | } 25 | 26 | val dialog = alertDialogBuilder.create() 27 | 28 | dialog.setOnShowListener { 29 | val deleteFilesLL = dialog.findViewById(R.id.deleteFiles_ll) 30 | val deleteFilesCheckBox = dialog.findViewById(R.id.deleteFiles_box) 31 | 32 | deleteFilesLL?.setOnClickListener { 33 | deleteFilesCheckBox?.isChecked = !deleteFilesCheckBox!!.isChecked 34 | } 35 | 36 | dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { 37 | setFragmentResult( 38 | REMOVE_TORRENT_KEY, 39 | bundleOf(TORRENT_KEY to deleteFilesCheckBox?.isChecked) 40 | ) 41 | dialog.dismiss() 42 | } 43 | } 44 | 45 | return dialog 46 | } 47 | 48 | companion object { 49 | fun newInstance(): RemoveTorrentDialog = RemoveTorrentDialog() 50 | 51 | const val TAG = "RemoveTorrentDialogFragment" 52 | const val REMOVE_TORRENT_KEY = "remove_torrent" 53 | const val TORRENT_KEY = "torrent" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/dialogs/RenameTorrentDialog.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.dialogs 2 | 3 | import android.app.AlertDialog 4 | import android.app.Dialog 5 | import android.os.Bundle 6 | import android.view.WindowManager 7 | import androidx.core.os.bundleOf 8 | import androidx.fragment.app.DialogFragment 9 | import androidx.fragment.app.setFragmentResult 10 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 11 | import com.google.android.material.textfield.TextInputEditText 12 | import com.google.android.material.textfield.TextInputLayout 13 | import dev.yashgarg.qbit.R 14 | 15 | class RenameTorrentDialog : DialogFragment() { 16 | private var nameTiet: TextInputEditText? = null 17 | 18 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 19 | super.onCreateDialog(savedInstanceState) 20 | val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext()) 21 | 22 | // TODO: Switch to string resources below 23 | alertDialogBuilder.apply { 24 | setTitle("Rename torrent") 25 | setView(R.layout.rename_torrent_dialog) 26 | setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() } 27 | setPositiveButton("Rename", null) 28 | } 29 | 30 | val title = 31 | savedInstanceState?.getString(TORRENT_NAME_KEY) 32 | ?: arguments?.getString(TORRENT_NAME_KEY) 33 | 34 | val dialog = alertDialogBuilder.create() 35 | dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) 36 | 37 | dialog.setOnShowListener { 38 | val nameTil = dialog.findViewById(R.id.torrentName_til) 39 | nameTiet = dialog.findViewById(R.id.torrentName_tiet) 40 | nameTiet?.setText(title) 41 | nameTiet?.setSelection(title?.length ?: 0) 42 | 43 | dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { 44 | if (!nameTiet?.text.isNullOrEmpty()) { 45 | setFragmentResult( 46 | RENAME_TORRENT_KEY, 47 | bundleOf(RENAME_KEY to nameTiet?.text.toString()) 48 | ) 49 | dialog.dismiss() 50 | } else { 51 | nameTil?.error = "Please enter a valid name" 52 | } 53 | } 54 | } 55 | 56 | return dialog 57 | } 58 | 59 | override fun onSaveInstanceState(outState: Bundle) { 60 | super.onSaveInstanceState(outState) 61 | outState.putString(TORRENT_NAME_KEY, nameTiet?.text.toString()) 62 | } 63 | 64 | companion object { 65 | fun newInstance(): RenameTorrentDialog = RenameTorrentDialog() 66 | 67 | const val TAG = "RenameTorrentDialogFragment" 68 | const val TORRENT_NAME_KEY = "torrent_name" 69 | const val RENAME_TORRENT_KEY = "rename_torrent" 70 | const val RENAME_KEY = "rename_fragment" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/home/HomeFragment.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.home 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.fragment.app.Fragment 7 | import androidx.navigation.fragment.findNavController 8 | import com.google.android.material.transition.MaterialSharedAxis 9 | import dagger.hilt.android.AndroidEntryPoint 10 | import dev.yashgarg.qbit.R 11 | import dev.yashgarg.qbit.databinding.HomeFragmentBinding 12 | import dev.yashgarg.qbit.utils.viewBinding 13 | 14 | @AndroidEntryPoint 15 | class HomeFragment : Fragment(R.layout.home_fragment) { 16 | private val binding by viewBinding(HomeFragmentBinding::bind) 17 | 18 | override fun onCreate(savedInstanceState: Bundle?) { 19 | super.onCreate(savedInstanceState) 20 | 21 | exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true) 22 | reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false) 23 | } 24 | 25 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 26 | super.onViewCreated(view, savedInstanceState) 27 | 28 | (activity as AppCompatActivity).setSupportActionBar(binding.toolbar) 29 | 30 | val navController = findNavController() 31 | if (navController.currentDestination?.id == R.id.homeFragment) { 32 | binding.addServerFab.setOnClickListener { 33 | navController.navigate(R.id.action_homeFragment_to_configFragment) 34 | } 35 | } 36 | } 37 | 38 | override fun onStop() { 39 | super.onStop() 40 | (activity as AppCompatActivity).setSupportActionBar(null) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/server/ServerState.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.server 2 | 3 | import qbittorrent.models.MainData 4 | 5 | data class ServerState( 6 | val dataLoading: Boolean = true, 7 | val data: MainData? = null, 8 | val speedLimitMode: Int = 0, 9 | val serverName: String? = null, 10 | val hasError: Boolean = false, 11 | val error: Throwable? = null 12 | ) 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/torrent/TorrentDetailsState.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.torrent 2 | 3 | import dev.yashgarg.qbit.data.models.ContentTreeItem 4 | import qbittorrent.models.* 5 | 6 | data class TorrentDetailsState( 7 | val loading: Boolean = true, 8 | val peersLoading: Boolean = true, 9 | val peers: TorrentPeers? = null, 10 | val torrent: Torrent? = null, 11 | val contentTree: List = emptyList(), 12 | val contentLoading: Boolean = true, 13 | val trackers: List = emptyList(), 14 | val torrentProperties: TorrentProperties? = null, 15 | val error: Exception? = null 16 | ) 17 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/torrent/adapter/TorrentDetailsAdapter.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.torrent.adapter 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.viewpager2.adapter.FragmentStateAdapter 5 | import dev.yashgarg.qbit.ui.torrent.tabs.TorrentFilesFragment 6 | import dev.yashgarg.qbit.ui.torrent.tabs.TorrentInfoFragment 7 | import dev.yashgarg.qbit.ui.torrent.tabs.TorrentPeersFragment 8 | import dev.yashgarg.qbit.ui.torrent.tabs.TorrentTrackersFragment 9 | 10 | class TorrentDetailsAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { 11 | override fun getItemCount(): Int = 4 12 | 13 | override fun createFragment(position: Int): Fragment = 14 | when (position) { 15 | 0 -> TorrentInfoFragment() 16 | 1 -> TorrentFilesFragment() 17 | 2 -> TorrentTrackersFragment() 18 | else -> TorrentPeersFragment() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/torrent/adapter/TorrentTrackersAdapter.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.torrent.adapter 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import android.widget.TextView 7 | import androidx.recyclerview.widget.DiffUtil 8 | import androidx.recyclerview.widget.ListAdapter 9 | import androidx.recyclerview.widget.RecyclerView 10 | import dev.yashgarg.qbit.R 11 | import dev.yashgarg.qbit.data.models.TrackerStatus 12 | import javax.inject.Inject 13 | import qbittorrent.models.TorrentTracker 14 | 15 | class TorrentTrackersAdapter @Inject constructor() : 16 | ListAdapter(TrackerComparator()) { 17 | 18 | var onTrackerClick: ((TorrentTracker) -> Unit)? = null 19 | 20 | class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { 21 | val trackerUrl: TextView = view.findViewById(R.id.trackerName) 22 | } 23 | 24 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 25 | val view = LayoutInflater.from(parent.context).inflate(R.layout.tracker_item, parent, false) 26 | 27 | return ViewHolder(view) 28 | } 29 | 30 | override fun getItemCount(): Int = currentList.size 31 | 32 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 33 | val tracker = currentList.elementAt(position) 34 | val context = holder.itemView.context 35 | 36 | with(holder) { 37 | trackerUrl.text = tracker.url 38 | 39 | itemView.setOnClickListener { onTrackerClick?.invoke(tracker) } 40 | 41 | when (TrackerStatus.statusOf(tracker.status)) { 42 | TrackerStatus.DISABLED -> { 43 | trackerUrl.setTextColor(context.getColor(R.color.red)) 44 | } 45 | TrackerStatus.UPDATING -> { 46 | trackerUrl.setTextColor(context.getColor(R.color.yellow)) 47 | } 48 | TrackerStatus.NOT_CONTACTED -> { 49 | trackerUrl.setTextColor(context.getColor(R.color.red)) 50 | } 51 | TrackerStatus.CONTACTED_WORKING -> { 52 | trackerUrl.setTextColor(context.getColor(R.color.green)) 53 | } 54 | TrackerStatus.CONTACTED_NOT_WORKING -> { 55 | trackerUrl.setTextColor(context.getColor(R.color.red)) 56 | } 57 | } 58 | } 59 | } 60 | 61 | private class TrackerComparator : DiffUtil.ItemCallback() { 62 | override fun areItemsTheSame(oldItem: TorrentTracker, newItem: TorrentTracker): Boolean { 63 | return oldItem == newItem 64 | } 65 | 66 | override fun areContentsTheSame(oldItem: TorrentTracker, newItem: TorrentTracker): Boolean { 67 | return oldItem.url == newItem.url 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/torrent/tabs/TorrentFilesFragment.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.torrent.tabs 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.* 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.input.nestedscroll.nestedScroll 11 | import androidx.compose.ui.platform.ComposeView 12 | import androidx.compose.ui.platform.rememberNestedScrollInteropConnection 13 | import androidx.fragment.app.Fragment 14 | import androidx.fragment.app.viewModels 15 | import com.google.accompanist.themeadapter.material3.Mdc3Theme 16 | import dev.yashgarg.qbit.R 17 | import dev.yashgarg.qbit.ui.compose.Center 18 | import dev.yashgarg.qbit.ui.compose.CenterLinearLoading 19 | import dev.yashgarg.qbit.ui.compose.TorrentContentTreeView 20 | import dev.yashgarg.qbit.ui.torrent.TorrentDetailsState 21 | import dev.yashgarg.qbit.ui.torrent.TorrentDetailsViewModel 22 | 23 | class TorrentFilesFragment : Fragment() { 24 | private val viewModel by 25 | viewModels(ownerProducer = { requireParentFragment() }) 26 | 27 | override fun onCreateView( 28 | inflater: LayoutInflater, 29 | container: ViewGroup?, 30 | savedInstanceState: Bundle? 31 | ): View { 32 | val composeView = ComposeView(requireContext()) 33 | 34 | composeView.apply { 35 | setContent { 36 | val state by viewModel.uiState.collectAsState() 37 | val scrollState = rememberNestedScrollInteropConnection() 38 | 39 | Mdc3Theme(setTextColors = true, setDefaultFontFamily = true) { 40 | FilesListView(state, Modifier.nestedScroll(scrollState)) 41 | } 42 | } 43 | } 44 | 45 | return composeView 46 | } 47 | } 48 | 49 | @Composable 50 | fun FilesListView(state: TorrentDetailsState, modifier: Modifier = Modifier) { 51 | if (state.contentLoading) { 52 | CenterLinearLoading(modifier, R.color.md_theme_dark_seed) 53 | } else if (state.contentTree.isEmpty()) { 54 | Center(modifier) { Text("No content found") } 55 | } else { 56 | TorrentContentTreeView(modifier, state.contentTree) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/torrent/tabs/TorrentTrackersFragment.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.torrent.tabs 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.fragment.app.Fragment 6 | import androidx.fragment.app.viewModels 7 | import androidx.lifecycle.flowWithLifecycle 8 | import androidx.lifecycle.lifecycleScope 9 | import dagger.hilt.android.AndroidEntryPoint 10 | import dev.yashgarg.qbit.R 11 | import dev.yashgarg.qbit.databinding.TorrentTrackersFragmentBinding 12 | import dev.yashgarg.qbit.ui.torrent.TorrentDetailsState 13 | import dev.yashgarg.qbit.ui.torrent.TorrentDetailsViewModel 14 | import dev.yashgarg.qbit.ui.torrent.adapter.TorrentTrackersAdapter 15 | import dev.yashgarg.qbit.utils.viewBinding 16 | import javax.inject.Inject 17 | import kotlinx.coroutines.flow.launchIn 18 | import kotlinx.coroutines.flow.onEach 19 | 20 | @AndroidEntryPoint 21 | class TorrentTrackersFragment : Fragment(R.layout.torrent_trackers_fragment) { 22 | private val binding by viewBinding(TorrentTrackersFragmentBinding::bind) 23 | private val viewModel by 24 | viewModels(ownerProducer = { requireParentFragment() }) 25 | 26 | @Inject lateinit var torrentTrackersAdapter: TorrentTrackersAdapter 27 | 28 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 29 | super.onViewCreated(view, savedInstanceState) 30 | 31 | binding.trackersRv.adapter = torrentTrackersAdapter 32 | observeFlows() 33 | } 34 | 35 | private fun observeFlows() { 36 | viewModel.uiState 37 | .flowWithLifecycle(viewLifecycleOwner.lifecycle) 38 | .onEach(::render) 39 | .launchIn(viewLifecycleOwner.lifecycleScope) 40 | } 41 | 42 | private fun render(state: TorrentDetailsState) { 43 | if (!state.loading) { 44 | torrentTrackersAdapter.submitList(state.trackers) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/version/VersionState.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.version 2 | 3 | data class VersionState( 4 | val loading: Boolean = true, 5 | val appVersion: String? = null, 6 | val apiVersion: String? = null, 7 | ) 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/ui/version/VersionViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.ui.version 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import dev.yashgarg.qbit.data.manager.ClientManager 7 | import javax.inject.Inject 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.asStateFlow 10 | import kotlinx.coroutines.flow.update 11 | import kotlinx.coroutines.launch 12 | import qbittorrent.QBittorrentClient 13 | 14 | @HiltViewModel 15 | class VersionViewModel @Inject constructor(private val clientManager: ClientManager) : ViewModel() { 16 | private val _uiState = MutableStateFlow(VersionState()) 17 | val uiState = _uiState.asStateFlow() 18 | 19 | private lateinit var client: QBittorrentClient 20 | 21 | init { 22 | viewModelScope.launch { 23 | val clientResult = clientManager.checkAndGetClient() 24 | clientResult?.let { 25 | client = it 26 | getVersions() 27 | } 28 | } 29 | } 30 | 31 | private suspend fun getVersions() { 32 | _uiState.update { state -> 33 | state.copy( 34 | apiVersion = client.getApiVersion(), 35 | appVersion = client.getVersion(), 36 | loading = false, 37 | ) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/utils/ClipboardUtil.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.utils 2 | 3 | import android.content.ClipData 4 | import android.content.ClipDescription 5 | import android.content.ClipboardManager 6 | import android.content.Context 7 | import android.widget.Toast 8 | import androidx.core.content.ContextCompat.getSystemService 9 | 10 | object ClipboardUtil { 11 | fun getClipboardText(context: Context): String { 12 | var pasteData = "" 13 | val clipboard = getSystemService(context, ClipboardManager::class.java) 14 | if ( 15 | clipboard?.primaryClipDescription?.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) == 16 | true 17 | ) { 18 | pasteData = clipboard.primaryClip?.getItemAt(0)?.text.toString() 19 | } 20 | return pasteData 21 | } 22 | 23 | fun copyToClipboard( 24 | context: Context, 25 | label: String, 26 | text: String, 27 | message: String = "Copied to clipboard" 28 | ) { 29 | val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 30 | val clip = ClipData.newPlainText(label, text) 31 | clipboard.setPrimaryClip(clip) 32 | Toast.makeText(context, message, Toast.LENGTH_SHORT).show() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/utils/CountryFlags.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.utils 2 | 3 | object CountryFlags { 4 | private val A = getEmojiByUnicode(0x1F1E6) 5 | private val B = getEmojiByUnicode(0x1F1E7) 6 | private val C = getEmojiByUnicode(0x1F1E8) 7 | private val D = getEmojiByUnicode(0x1F1E9) 8 | private val E = getEmojiByUnicode(0x1F1EA) 9 | private val F = getEmojiByUnicode(0x1F1EB) 10 | private val G = getEmojiByUnicode(0x1F1EC) 11 | private val H = getEmojiByUnicode(0x1F1ED) 12 | private val I = getEmojiByUnicode(0x1F1EE) 13 | private val J = getEmojiByUnicode(0x1F1EF) 14 | private val K = getEmojiByUnicode(0x1F1F0) 15 | private val L = getEmojiByUnicode(0x1F1F1) 16 | private val M = getEmojiByUnicode(0x1F1F2) 17 | private val N = getEmojiByUnicode(0x1F1F3) 18 | private val O = getEmojiByUnicode(0x1F1F4) 19 | private val P = getEmojiByUnicode(0x1F1F5) 20 | private val Q = getEmojiByUnicode(0x1F1F6) 21 | private val R = getEmojiByUnicode(0x1F1F7) 22 | private val S = getEmojiByUnicode(0x1F1F8) 23 | private val T = getEmojiByUnicode(0x1F1F9) 24 | private val U = getEmojiByUnicode(0x1F1FA) 25 | private val V = getEmojiByUnicode(0x1F1FB) 26 | private val W = getEmojiByUnicode(0x1F1FC) 27 | private val X = getEmojiByUnicode(0x1F1FD) 28 | private val Y = getEmojiByUnicode(0x1F1FE) 29 | private val Z = getEmojiByUnicode(0x1F1FF) 30 | 31 | private fun getCodeByCharacter(character: Char): String { 32 | return when (character.uppercaseChar()) { 33 | 'A' -> A 34 | 'B' -> B 35 | 'C' -> C 36 | 'D' -> D 37 | 'E' -> E 38 | 'F' -> F 39 | 'G' -> G 40 | 'H' -> H 41 | 'I' -> I 42 | 'J' -> J 43 | 'K' -> K 44 | 'L' -> L 45 | 'M' -> M 46 | 'N' -> N 47 | 'O' -> O 48 | 'P' -> P 49 | 'Q' -> Q 50 | 'R' -> R 51 | 'S' -> S 52 | 'T' -> T 53 | 'U' -> U 54 | 'V' -> V 55 | 'W' -> W 56 | 'X' -> X 57 | 'Y' -> Y 58 | 'Z' -> Z 59 | else -> "" 60 | } 61 | } 62 | 63 | private fun getEmojiByUnicode(unicode: Int) = String(Character.toChars(unicode)) 64 | 65 | fun getCountryFlagByCountryCode(countryCode: String): String { 66 | return if (countryCode.length == 2) { 67 | getCodeByCharacter(countryCode.first()) + getCodeByCharacter(countryCode.last()) 68 | } else countryCode 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/utils/Exceptions.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.utils 2 | 3 | import io.ktor.client.network.sockets.* 4 | import qbittorrent.QBittorrentException 5 | 6 | class ClientConnectionError : Throwable("Failed to connect client") 7 | 8 | class TorrentRemovedError : Exception("Torrent has been removed") 9 | 10 | object ExceptionHandler { 11 | fun mapException(ex: Throwable): Throwable { 12 | return when (ex) { 13 | is UninitializedPropertyAccessException -> ClientConnectionError() 14 | is QBittorrentException -> { 15 | return when (ex.cause) { 16 | is ConnectTimeoutException -> ClientConnectionError() 17 | else -> ex 18 | } 19 | } 20 | else -> ex 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/utils/NumberFormat.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.utils 2 | 3 | import java.lang.StringBuilder 4 | import java.text.CharacterIterator 5 | import java.text.StringCharacterIterator 6 | import java.time.Instant 7 | import java.time.LocalDateTime 8 | import java.time.ZoneId 9 | import java.time.format.DateTimeFormatter 10 | import java.util.concurrent.TimeUnit 11 | import kotlin.math.abs 12 | 13 | private object NumberFormat { 14 | fun bytesToHumanReadable(bytes: Long): String { 15 | val absB = if (bytes == Long.MIN_VALUE) Long.MAX_VALUE else abs(bytes) 16 | if (absB < 1024) { 17 | return "$bytes B" 18 | } 19 | var value = absB 20 | val ci: CharacterIterator = StringCharacterIterator("KMGTPE") 21 | var i = 40 22 | while (i >= 0 && absB > 0xfffccccccccccccL shr i) { 23 | value = value shr 10 24 | ci.next() 25 | i -= 10 26 | } 27 | value *= java.lang.Long.signum(bytes).toLong() 28 | return String.format("%.2f %ciB", value / 1024.0, ci.current()).trim() 29 | } 30 | 31 | fun millisToDate(millis: Long, zoneId: ZoneId?): String { 32 | val millisEpoch = millis * 1000 33 | val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy, HH:mm:ss") 34 | val instant = Instant.ofEpochMilli(millisEpoch) 35 | val date = LocalDateTime.ofInstant(instant, zoneId ?: ZoneId.systemDefault()) 36 | return formatter.format(date).trim() 37 | } 38 | 39 | fun secondsToTime(seconds: Long): String { 40 | var duration = seconds 41 | val days: Long = TimeUnit.SECONDS.toDays(duration) 42 | duration -= TimeUnit.DAYS.toSeconds(days) 43 | val hours: Long = TimeUnit.SECONDS.toHours(duration) 44 | duration -= TimeUnit.HOURS.toSeconds(hours) 45 | val minutes: Long = TimeUnit.SECONDS.toMinutes(duration) 46 | duration -= TimeUnit.MINUTES.toSeconds(minutes) 47 | val secs: Long = TimeUnit.SECONDS.toSeconds(duration) 48 | val timeStr = StringBuilder() 49 | if (days != 0L) { 50 | timeStr.append("${days}d") 51 | } 52 | if (hours != 0L) { 53 | timeStr.append(" ${hours}h") 54 | } 55 | if (minutes != 0L) { 56 | timeStr.append(" ${minutes}m") 57 | } 58 | if (secs != 0L) { 59 | timeStr.append(" ${secs}s") 60 | } 61 | 62 | return timeStr.toString().trim() 63 | } 64 | } 65 | 66 | fun Long.toHumanReadable(): String = NumberFormat.bytesToHumanReadable(this) 67 | 68 | fun Long.toTime(): String = NumberFormat.secondsToTime(this) 69 | 70 | fun Long.toDate(zoneId: ZoneId? = null): String = NumberFormat.millisToDate(this, zoneId) 71 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/utils/PermissionUtil.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.utils 2 | 3 | import android.Manifest 4 | import android.content.Context 5 | import android.content.pm.PackageManager 6 | import android.os.Build 7 | import android.os.Environment 8 | import androidx.core.content.ContextCompat 9 | 10 | object PermissionUtil { 11 | fun canReadStorage(context: Context): Boolean { 12 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 13 | Environment.isExternalStorageEmulated() 14 | } else { 15 | ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) == 16 | PackageManager.PERMISSION_GRANTED 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/utils/TransformUtil.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.utils 2 | 3 | import dev.yashgarg.qbit.data.models.ContentTreeItem 4 | import qbittorrent.models.TorrentFile 5 | 6 | object TransformUtil { 7 | private const val FILE_KEY = "/FILE/" 8 | private const val UNWANTED_FILE = ".unwanted" 9 | 10 | fun transformFilesToTree(files: List, start: Int): List { 11 | val tree = mutableListOf() 12 | var folderIndex = 0 13 | files.sortedBy { it.name.lowercase() } 14 | 15 | val entries = files.groupBy { getFileFolder(it, start) } 16 | for ((folder, values) in entries) { 17 | if (folder == UNWANTED_FILE) { 18 | for (item in values) { 19 | tree.add( 20 | ContentTreeItem( 21 | id = item.index, 22 | name = item.name.substring(start + folder.length + 1), 23 | item, 24 | size = item.size, 25 | progress = item.progress.toLong(), 26 | ) 27 | ) 28 | } 29 | continue 30 | } 31 | 32 | if (folder != FILE_KEY) { 33 | val subTree = transformFilesToTree(values, start + folder.length + 1) 34 | tree.add( 35 | ContentTreeItem( 36 | id = files.size + folderIndex++, 37 | name = folder, 38 | children = subTree, 39 | size = subTree.sumOf { it.size }, 40 | progress = subTree.sumOf { it.progress } / subTree.size 41 | ) 42 | ) 43 | continue 44 | } 45 | 46 | for (item in values) { 47 | tree.add( 48 | ContentTreeItem( 49 | id = item.index, 50 | name = item.name.substring(start), 51 | item, 52 | size = item.size, 53 | progress = item.progress.toLong() 54 | ) 55 | ) 56 | } 57 | } 58 | 59 | return tree 60 | } 61 | 62 | private fun getFileFolder(item: TorrentFile, start: Int): String { 63 | val name = item.name 64 | val index = name.indexOf("/", start) 65 | 66 | if (index == -1) { 67 | return FILE_KEY 68 | } 69 | 70 | return name.substring(start, index) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/utils/ViewBindingDelegate.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.utils 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import androidx.activity.ComponentActivity 6 | import androidx.fragment.app.Fragment 7 | import androidx.lifecycle.DefaultLifecycleObserver 8 | import androidx.lifecycle.Lifecycle 9 | import androidx.lifecycle.LifecycleOwner 10 | import androidx.lifecycle.Observer 11 | import androidx.viewbinding.ViewBinding 12 | import kotlin.properties.ReadOnlyProperty 13 | import kotlin.reflect.KProperty 14 | 15 | class FragmentViewBindingDelegate( 16 | val fragment: Fragment, 17 | val viewBindingFactory: (View) -> T 18 | ) : ReadOnlyProperty { 19 | private var binding: T? = null 20 | 21 | init { 22 | fragment.lifecycle.addObserver( 23 | object : DefaultLifecycleObserver { 24 | val viewLifecycleOwnerLiveDataObserver = 25 | Observer { 26 | val viewLifecycleOwner = it ?: return@Observer 27 | 28 | viewLifecycleOwner.lifecycle.addObserver( 29 | object : DefaultLifecycleObserver { 30 | override fun onDestroy(owner: LifecycleOwner) { 31 | binding = null 32 | } 33 | } 34 | ) 35 | } 36 | 37 | override fun onCreate(owner: LifecycleOwner) { 38 | fragment.viewLifecycleOwnerLiveData.observeForever( 39 | viewLifecycleOwnerLiveDataObserver 40 | ) 41 | } 42 | 43 | override fun onDestroy(owner: LifecycleOwner) { 44 | fragment.viewLifecycleOwnerLiveData.removeObserver( 45 | viewLifecycleOwnerLiveDataObserver 46 | ) 47 | } 48 | } 49 | ) 50 | } 51 | 52 | override fun getValue(thisRef: Fragment, property: KProperty<*>): T { 53 | val binding = binding 54 | if (binding != null) { 55 | return binding 56 | } 57 | 58 | val lifecycle = fragment.viewLifecycleOwner.lifecycle 59 | if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { 60 | throw IllegalStateException( 61 | "Should not attempt to get bindings when Fragment views are destroyed." 62 | ) 63 | } 64 | 65 | return viewBindingFactory(thisRef.requireView()).also { this.binding = it } 66 | } 67 | } 68 | 69 | fun Fragment.viewBinding(viewBindingFactory: (View) -> T) = 70 | FragmentViewBindingDelegate(this, viewBindingFactory) 71 | 72 | inline fun ComponentActivity.viewBinding( 73 | crossinline bindingInflater: (LayoutInflater) -> T 74 | ) = lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) } 75 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/validation/HostValidator.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.validation 2 | 3 | import java.util.regex.Pattern 4 | 5 | class HostValidator : TextValidator { 6 | override fun isValid(text: String): Boolean { 7 | val ipMatcher = ipRegex.matcher(text) 8 | val hostMatcher = hostnameRegex.matcher(text) 9 | return hostMatcher.matches() || ipMatcher.matches() 10 | } 11 | 12 | companion object { 13 | val ipRegex: Pattern = 14 | Pattern.compile( 15 | "^((\\d|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])\\.){3}(\\d|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])$" 16 | ) 17 | val hostnameRegex: Pattern = 18 | Pattern.compile( 19 | "^(([a-zA-Z\\d]|[a-zA-Z\\d][a-zA-Z\\d\\-]*[a-zA-Z\\d])\\.)*([A-Za-z\\d]|[A-Za-z\\d][A-Za-z\\d\\-]*[A-Za-z\\d])$" 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/validation/LinkValidator.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.validation 2 | 3 | class LinkValidator : TextValidator { 4 | override fun isValid(text: String): Boolean { 5 | return text.startsWith("http://") || 6 | text.startsWith("https://") || 7 | text.startsWith("magnet:?xt=urn:") || 8 | text.startsWith("bc://bt/") 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/validation/PortValidator.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.validation 2 | 3 | import java.util.regex.Pattern 4 | 5 | class PortValidator : TextValidator { 6 | override fun isValid(text: String): Boolean { 7 | val portMatcher = portRegex.matcher(text) 8 | 9 | return if (text.isEmpty()) { 10 | true 11 | } else if (text.isNotEmpty()) { 12 | portMatcher.matches() 13 | } else false 14 | } 15 | 16 | companion object { 17 | val portRegex: Pattern = 18 | Pattern.compile( 19 | "^((6553[0-5])|(655[0-2]\\d)|(65[0-4]\\d{2})|(6[0-4]\\d{3})|([1-5]\\d{4})|([0-5]{0,5})|(\\d{1,4}))\$" 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/validation/StringValidator.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.validation 2 | 3 | class StringValidator : TextValidator { 4 | override fun isValid(text: String): Boolean { 5 | return text.isNotEmpty() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/validation/TextValidator.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.validation 2 | 3 | interface TextValidator { 4 | fun isValid(text: String): Boolean 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/yashgarg/qbit/worker/StatusWorker.kt: -------------------------------------------------------------------------------- 1 | package dev.yashgarg.qbit.worker 2 | 3 | import android.app.PendingIntent 4 | import android.content.Context 5 | import android.content.Intent 6 | import androidx.core.app.NotificationCompat.Action 7 | import androidx.hilt.work.HiltWorker 8 | import androidx.work.Constraints 9 | import androidx.work.CoroutineWorker 10 | import androidx.work.ForegroundInfo 11 | import androidx.work.NetworkType 12 | import androidx.work.WorkManager 13 | import androidx.work.WorkerParameters 14 | import dagger.assisted.Assisted 15 | import dagger.assisted.AssistedInject 16 | import dev.yashgarg.qbit.MainActivity 17 | import dev.yashgarg.qbit.R 18 | import dev.yashgarg.qbit.data.manager.ClientManager 19 | import dev.yashgarg.qbit.notifications.AppNotificationManager 20 | import dev.yashgarg.qbit.utils.toHumanReadable 21 | 22 | @HiltWorker 23 | class StatusWorker 24 | @AssistedInject 25 | constructor( 26 | @Assisted appContext: Context, 27 | @Assisted workerParams: WorkerParameters, 28 | private val clientManager: ClientManager, 29 | ) : CoroutineWorker(appContext, workerParams) { 30 | 31 | override suspend fun doWork(): Result { 32 | getStatus() 33 | return Result.success() 34 | } 35 | 36 | private suspend fun getStatus() { 37 | val client = clientManager.checkAndGetClient() 38 | val closeIntent = WorkManager.getInstance(applicationContext).createCancelPendingIntent(id) 39 | 40 | val intent = 41 | Intent(applicationContext, MainActivity::class.java).apply { 42 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 43 | } 44 | 45 | val pendingIntent = 46 | PendingIntent.getActivity(applicationContext, 0, intent, PendingIntent.FLAG_IMMUTABLE) 47 | 48 | client?.observeMainData()?.collect { data -> 49 | val state = data.serverState 50 | setForeground( 51 | ForegroundInfo( 52 | 1, 53 | AppNotificationManager.createNotification( 54 | applicationContext, 55 | "Server State • Connected", 56 | "DL: ${state.dlInfoSpeed.toHumanReadable()}/s | UL: ${state.upInfoSpeed.toHumanReadable()}/s", 57 | R.drawable.baseline_sync, 58 | true, 59 | listOf(Action(null, "Close", closeIntent)), 60 | pendingIntent 61 | ) 62 | ) 63 | ) 64 | } 65 | } 66 | 67 | companion object { 68 | val constraints = 69 | Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/baseline_sync.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/cloud_done.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/cloud_done_scaled.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/more_vert.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outline_category_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outline_delete_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/sync_error.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/sync_error_scaled.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/twotone_add_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/twotone_content_copy_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/twotone_content_paste_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/twotone_delete_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/twotone_drive_file_rename_outline_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 15 | 18 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/twotone_find_in_page_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/twotone_pause_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/twotone_play_arrow_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/twotone_restore_page_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/twotone_sort_24.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/twotone_speed_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/font-v26/space_grotesk.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 11 | 12 | 15 | 16 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/font/space_grotesk.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 11 | 12 | 15 | 16 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/layout/delete_files_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 16 | 17 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/layout/edit_text.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 18 | 19 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/res/layout/home_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 11 | 12 | 16 | 17 | 18 | 19 | 29 | 30 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/res/layout/list_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/layout/list_tile.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 16 | 17 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/layout/rename_torrent_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 13 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/layout/torrent_details_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 25 | 26 | 34 | 35 | 36 | 46 | 47 | 48 | 53 | 54 | -------------------------------------------------------------------------------- /app/src/main/res/layout/torrent_peers_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/layout/torrent_trackers_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/layout/tracker_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/layout/version_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 16 | 17 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/res/menu/server_bottombar.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 11 | 16 | 21 | 27 | 33 | 39 | 40 | -------------------------------------------------------------------------------- /app/src/main/res/menu/torrent_options.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/navigation/nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 15 | 20 | 21 | 22 | 26 | 31 | 32 | 37 | 41 | 44 | 48 | 49 | 53 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /app/src/main/res/values-v29/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | #FFD979 5 | #88FFA6 6 | #FF6464 7 | #A1A1A1 8 | 9 | #DBBCFF 10 | #CB9EFF 11 | #353A4E 12 | #1F222E 13 | 14 | #DAB9FF 15 | #431672 16 | #7240AA 17 | #EFDBFF 18 | #DAB9FF 19 | #431672 20 | #7240AA 21 | #EFDBFF 22 | #FFB4AB 23 | #93000A 24 | #690005 25 | #FFDAD6 26 | #262C3A 27 | #FFFFFF 28 | #262C3A 29 | #FFFFFF 30 | #4A454E 31 | #CCC4CF 32 | #958E98 33 | #262C3A 34 | #FFFFFF 35 | #734AA4 36 | #000000 37 | #DAB9FF 38 | #DAB9FF 39 | 40 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 24dp 3 | 8dp 4 | 12sp 5 | 20sp 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 13 | 14 | 18 | 19 | 23 | 24 | 30 | 31 | 36 | 37 | 41 | 42 | 45 | 46 | 49 | 50 | 60 | 61 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33 | 34 |