├── .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 | [](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 |
15 |
16 |
17 |
18 | ## Download
19 |
20 |
21 |
24 |
25 |
26 |
27 |
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 |
40 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/torrent_options.xml:
--------------------------------------------------------------------------------
1 |
2 |
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 |
35 |
36 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
20 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/network_security_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/nonFree/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
17 |
18 |
19 |
22 |
23 |
24 |
27 |
28 |
29 |
32 |
33 |
34 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/dev/yashgarg/qbit/Constants.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.qbit
2 |
3 | object Constants {
4 | const val magnetUrl =
5 | "magnet:?xt=urn:btih:7cb890a8886ae03491ba5f706a5b6655963b8f01&dn=archlinux-2022.10.01-x86_64.iso"
6 | const val magnetHash = "7cb890a8886ae03491ba5f706a5b6655963b8f01"
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/dev/yashgarg/qbit/FakeClientManager.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.qbit
2 |
3 | import dev.yashgarg.qbit.data.manager.ClientManager
4 | import dev.yashgarg.qbit.data.models.ConfigStatus
5 | import dev.yashgarg.qbit.data.models.ConnectionType
6 | import dev.yashgarg.qbit.data.models.ServerConfig
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.flow.MutableSharedFlow
9 | import kotlinx.coroutines.flow.SharedFlow
10 | import kotlinx.coroutines.flow.asSharedFlow
11 | import qbittorrent.QBittorrentClient
12 |
13 | class FakeClientManager : ClientManager {
14 | private val baseUrl: String by lazy { System.getenv("base_url") }
15 | private val password: String by lazy { System.getenv("password") }
16 |
17 | private val config =
18 | ServerConfig(
19 | configId = 0,
20 | serverName = "TestServer",
21 | baseUrl = baseUrl,
22 | port = 443,
23 | path = null,
24 | username = "admin",
25 | password = password,
26 | connectionType = ConnectionType.HTTPS,
27 | trustSelfSigned = false
28 | )
29 |
30 | private val _configStatus = MutableSharedFlow()
31 | override val configStatus: SharedFlow
32 | get() = _configStatus.asSharedFlow()
33 |
34 | override suspend fun checkAndGetClient() =
35 | QBittorrentClient(
36 | "${config.connectionType}://${config.baseUrl}",
37 | config.username,
38 | config.password,
39 | syncInterval = ClientManager.syncInterval,
40 | httpClient = ClientManager.httpClient(config.trustSelfSigned),
41 | dispatcher = Dispatchers.Default,
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/dev/yashgarg/qbit/MainDispatcherRule.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.qbit
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.ExperimentalCoroutinesApi
5 | import kotlinx.coroutines.test.TestDispatcher
6 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
7 | import kotlinx.coroutines.test.resetMain
8 | import kotlinx.coroutines.test.setMain
9 | import org.junit.rules.TestWatcher
10 | import org.junit.runner.Description
11 |
12 | @ExperimentalCoroutinesApi
13 | class MainDispatcherRule(
14 | private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
15 | ) : TestWatcher() {
16 | override fun starting(description: Description) {
17 | Dispatchers.setMain(testDispatcher)
18 | }
19 |
20 | override fun finished(description: Description) {
21 | Dispatchers.resetMain()
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/dev/yashgarg/qbit/data/QbitRepositoryTest.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.qbit.data
2 |
3 | import com.github.michaelbull.result.Ok
4 | import dev.yashgarg.qbit.Constants
5 | import dev.yashgarg.qbit.FakeClientManager
6 | import dev.yashgarg.qbit.MainDispatcherRule
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.ExperimentalCoroutinesApi
9 | import kotlinx.coroutines.flow.first
10 | import kotlinx.coroutines.test.runTest
11 | import org.junit.Assert.assertFalse
12 | import org.junit.Assert.assertTrue
13 | import org.junit.Before
14 | import org.junit.Rule
15 | import org.junit.Test
16 |
17 | @OptIn(ExperimentalCoroutinesApi::class)
18 | class QbitRepositoryTest {
19 | private lateinit var repository: QbitRepository
20 | private val clientManager = FakeClientManager()
21 |
22 | @get:Rule val mainDispatcherRule = MainDispatcherRule()
23 |
24 | @Before
25 | fun setUp() {
26 | repository = QbitRepository(Dispatchers.Main, clientManager)
27 | }
28 |
29 | @Test
30 | fun checkClientConnected() = runTest {
31 | assertTrue(repository.getVersion() is Ok)
32 | assertTrue(repository.getApiVersion() is Ok)
33 | }
34 |
35 | @Test
36 | fun checkAddTorrentSuccess() = runTest {
37 | assertTrue(repository.addTorrentUrl(Constants.magnetUrl) is Ok)
38 |
39 | val data = repository.observeMainData().first()
40 | assertTrue(data.torrents.containsKey(Constants.magnetHash))
41 | }
42 |
43 | @Test
44 | fun checkRemoveTorrentSuccess() = runTest {
45 | assertTrue(repository.removeTorrents(listOf(Constants.magnetHash)) is Ok)
46 |
47 | val data = repository.observeMainData().first()
48 | assertFalse(data.torrents.containsKey(Constants.magnetHash))
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/dev/yashgarg/qbit/utils/NumberFormatTest.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.qbit.utils
2 |
3 | import java.time.ZoneId
4 | import org.junit.Assert.assertFalse
5 | import org.junit.Assert.assertTrue
6 | import org.junit.Test
7 |
8 | class NumberFormatTest {
9 | private val bytes = 1602083870L
10 | private val timeInSeconds = 136L
11 | private val dateInMsEpoch = 1659354647L
12 | private val zoneId = ZoneId.of("GMT+05:30")
13 |
14 | @Test
15 | fun testCorrectSizeIsValid() {
16 | assertTrue(bytes.toHumanReadable() == "1.49 GiB")
17 | }
18 |
19 | @Test
20 | fun testIncorrectSizeIsInvalid() {
21 | assertFalse(bytes.toHumanReadable() == "1.1 GiB")
22 | }
23 |
24 | @Test
25 | fun testMillisCorrectDateIsValid() {
26 | assertTrue(dateInMsEpoch.toDate(zoneId) == "01/08/2022, 17:20:47")
27 | }
28 |
29 | @Test
30 | fun testMillisIncorrectDateIsInvalid() {
31 | assertFalse(dateInMsEpoch.toDate(zoneId) == "12/08/2021, 12:30")
32 | }
33 |
34 | @Test
35 | fun testCorrectTimeIsValid() {
36 | assertTrue(timeInSeconds.toTime() == "2m 16s")
37 | }
38 |
39 | @Test
40 | fun testIncorrectTimeIsInvalid() {
41 | assertFalse(timeInSeconds.toTime() == "5m 20s")
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/dev/yashgarg/qbit/validation/HostValidatorTest.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.qbit.validation
2 |
3 | import org.junit.Assert.assertFalse
4 | import org.junit.Assert.assertTrue
5 | import org.junit.Test
6 |
7 | class HostValidatorTest {
8 | private val hostValidator = HostValidator()
9 |
10 | @Test
11 | fun testCorrectIpisValid() {
12 | assertTrue(hostValidator.isValid("192.168.1.1"))
13 | }
14 |
15 | @Test
16 | fun testCorrectHostIsValid() {
17 | assertTrue(hostValidator.isValid("qbit.yashgarg.dev"))
18 | }
19 |
20 | @Test
21 | fun testIncorrectIpIsInvalid() {
22 | assertFalse(hostValidator.isValid("192.168.*"))
23 | }
24 |
25 | @Test
26 | fun testIncorrectHostIsInvalid() {
27 | assertFalse(hostValidator.isValid("qbit.#*example"))
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/dev/yashgarg/qbit/validation/PortValidatorTest.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.qbit.validation
2 |
3 | import org.junit.Assert.assertFalse
4 | import org.junit.Assert.assertTrue
5 | import org.junit.Test
6 |
7 | class PortValidatorTest {
8 | private val portValidator = PortValidator()
9 |
10 | @Test
11 | fun testPortInRangeIsValid() {
12 | assertTrue(portValidator.isValid("8080"))
13 | }
14 |
15 | @Test
16 | fun testPortOutOfRangeIsInvalid() {
17 | assertFalse(portValidator.isValid("79590"))
18 | }
19 |
20 | @Test
21 | fun testPortCharacterIsInvalid() {
22 | assertFalse(portValidator.isValid("808L"))
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/art/screen-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/art/screen-1.png
--------------------------------------------------------------------------------
/art/screen-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/art/screen-2.png
--------------------------------------------------------------------------------
/art/screen-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/art/screen-3.png
--------------------------------------------------------------------------------
/art/screen-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/art/screen-4.png
--------------------------------------------------------------------------------
/art/screen-5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/art/screen-5.png
--------------------------------------------------------------------------------
/art/screen-6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Yash-Garg/qBittorrent-Manager/0bd6be6539154f800c46dcbd1e790842f1e1e03e/art/screen-6.png
--------------------------------------------------------------------------------
/benchmark/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/benchmark/build.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:Suppress("UnstableApiUsage", "DSL_SCOPE_VIOLATION")
2 |
3 | plugins {
4 | id("dev.yashgarg.qbit.kotlin-android")
5 | alias(libs.plugins.android.test)
6 | }
7 |
8 | android {
9 | namespace = "dev.yashgarg.benchmark"
10 | compileSdk = 34
11 |
12 | defaultConfig {
13 | minSdk = 33
14 | targetSdk = 34
15 |
16 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
17 | missingDimensionStrategy("app", "nonFree", "free")
18 | }
19 |
20 | compileOptions {
21 | sourceCompatibility = JavaVersion.VERSION_17
22 | targetCompatibility = JavaVersion.VERSION_17
23 | }
24 |
25 | kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() }
26 |
27 | buildTypes {
28 | // This benchmark buildType is used for benchmarking, and should function like your
29 | // release build (for example, with minification on). It"s signed with a debug key
30 | // for easy local/CI testing.
31 | create("benchmark") {
32 | isDebuggable = true
33 | signingConfig = getByName("debug").signingConfig
34 | matchingFallbacks += listOf("release")
35 | }
36 | }
37 |
38 | lint { baseline = file("lint-baseline.xml") }
39 |
40 | targetProjectPath = ":app"
41 | experimentalProperties["android.experimental.self-instrumenting"] = true
42 | }
43 |
44 | dependencies {
45 | implementation(libs.androidx.test.junit)
46 | implementation(libs.androidx.test.espresso)
47 | implementation(libs.androidx.test.uiautomator)
48 | implementation(libs.androidx.benchmark.junit)
49 | }
50 |
51 | androidComponents { beforeVariants(selector().all()) { it.enable = it.buildType == "benchmark" } }
52 |
--------------------------------------------------------------------------------
/benchmark/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/benchmark/src/main/java/dev/yashgarg/benchmark/BaselineProfileGenerator.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.benchmark
2 |
3 | import androidx.benchmark.macro.junit4.BaselineProfileRule
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 | import org.junit.Rule
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | @RunWith(AndroidJUnit4::class)
10 | class BaselineProfileGenerator {
11 |
12 | @get:Rule val baselineRule = BaselineProfileRule()
13 |
14 | @Test
15 | fun generateBaselineProfile() =
16 | baselineRule.collect(StartupBenchmark.packageName) {
17 | pressHome()
18 | startActivityAndWait()
19 |
20 | navigateToConfigFragment()
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/benchmark/src/main/java/dev/yashgarg/benchmark/StartupBenchmark.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.benchmark
2 |
3 | import androidx.benchmark.macro.CompilationMode
4 | import androidx.benchmark.macro.FrameTimingMetric
5 | import androidx.benchmark.macro.StartupMode
6 | import androidx.benchmark.macro.StartupTimingMetric
7 | import androidx.benchmark.macro.junit4.MacrobenchmarkRule
8 | import androidx.test.ext.junit.runners.AndroidJUnit4
9 | import org.junit.Rule
10 | import org.junit.Test
11 | import org.junit.runner.RunWith
12 |
13 | @RunWith(AndroidJUnit4::class)
14 | class StartupBenchmark {
15 | @get:Rule val benchmarkRule = MacrobenchmarkRule()
16 |
17 | @Test fun startupNoCompilation() = startup(CompilationMode.None())
18 |
19 | @Test fun startupBaselineProfile() = startup(CompilationMode.Partial())
20 |
21 | private fun startup(mode: CompilationMode) =
22 | benchmarkRule.measureRepeated(
23 | packageName = packageName,
24 | metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
25 | iterations = 5,
26 | startupMode = StartupMode.COLD,
27 | compilationMode = mode,
28 | setupBlock = { pressHome() }
29 | ) {
30 | startActivityAndWait()
31 |
32 | navigateToConfigFragment()
33 | }
34 |
35 | companion object {
36 | const val packageName = "dev.yashgarg.qbit"
37 | }
38 | }
39 |
40 | // StartupBenchmark_startupNoCompilation
41 | // timeToInitialDisplayMs min 459.4, median 487.8, max 581.8
42 | // frameDurationCpuMs P50 62.7, P90 67.8, P95 87.5, P99 124.2
43 | // frameOverrunMs P50 41.6, P90 56.7, P95 169.6, P99 265.5
44 | // Traces: Iteration 0 1 2 3 4
45 | // StartupBenchmark_startupBaselineProfile
46 | // timeToInitialDisplayMs min 459.0, median 471.4, max 516.2
47 | // frameDurationCpuMs P50 48.5, P90 65.8, P95 67.8, P99 116.4
48 | // frameOverrunMs P50 26.1, P90 51.0, P95 104.3, P99 215.0
49 | // Traces: Iteration 0 1 2 3 4
50 |
--------------------------------------------------------------------------------
/benchmark/src/main/java/dev/yashgarg/benchmark/UiHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.benchmark
2 |
3 | import androidx.benchmark.macro.MacrobenchmarkScope
4 | import androidx.test.uiautomator.By
5 | import androidx.test.uiautomator.Until
6 |
7 | fun MacrobenchmarkScope.navigateToConfigFragment() {
8 | val button = device.findObject(By.text("Add server"))
9 | button.click()
10 |
11 | device.waitForIdle()
12 | device.wait(Until.hasObject(By.text("Save and test config")), 1000)
13 | }
14 |
--------------------------------------------------------------------------------
/bonsai-core/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/bonsai-core/build.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:Suppress("UnstableApiUsage", "DSL_SCOPE_VIOLATION")
2 |
3 | plugins {
4 | id("dev.yashgarg.qbit.kotlin-android")
5 | alias(libs.plugins.android.library)
6 | }
7 |
8 | android {
9 | namespace = "cafe.adriel.bonsai.core"
10 | compileSdk = 34
11 |
12 | compileOptions {
13 | sourceCompatibility = JavaVersion.VERSION_17
14 | targetCompatibility = JavaVersion.VERSION_17
15 | }
16 |
17 | kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() }
18 |
19 | buildFeatures {
20 | compose = true
21 | composeOptions {
22 | useLiveLiterals = false
23 | kotlinCompilerExtensionVersion =
24 | libs.compose.compiler.get().versionConstraint.requiredVersion
25 | }
26 | }
27 |
28 | lint { baseline = file("lint-baseline.xml") }
29 | }
30 |
31 | dependencies {
32 | api(libs.bundles.compose)
33 | implementation(libs.compose.material.icons)
34 | }
35 |
--------------------------------------------------------------------------------
/bonsai-core/lint-baseline.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/bonsai-core/src/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/bonsai-core/src/main/kotlin/cafe/adriel/bonsai/core/node/Node.kt:
--------------------------------------------------------------------------------
1 | package cafe.adriel.bonsai.core.node
2 |
3 | import androidx.compose.runtime.Composable
4 | import cafe.adriel.bonsai.core.BonsaiScope
5 | import cafe.adriel.bonsai.core.node.extension.ExpandableNode
6 | import cafe.adriel.bonsai.core.node.extension.ExpandableNodeHandler
7 | import cafe.adriel.bonsai.core.node.extension.SelectableNode
8 | import cafe.adriel.bonsai.core.node.extension.SelectableNodeHandler
9 | import cafe.adriel.bonsai.core.util.randomUUID
10 |
11 | typealias NodeComponent = @Composable BonsaiScope.(Node) -> Unit
12 |
13 | sealed interface Node {
14 |
15 | val key: String
16 |
17 | val content: T
18 |
19 | val name: String
20 |
21 | val depth: Int
22 |
23 | val isSelected: Boolean
24 |
25 | val iconComponent: NodeComponent
26 |
27 | val nameComponent: NodeComponent
28 | }
29 |
30 | class LeafNode
31 | internal constructor(
32 | override val content: T,
33 | override val depth: Int,
34 | override val key: String = randomUUID,
35 | override val name: String = content.toString(),
36 | override val iconComponent: NodeComponent = { DefaultNodeIcon(it) },
37 | override val nameComponent: NodeComponent = { DefaultNodeName(it) }
38 | ) : Node, SelectableNode by SelectableNodeHandler()
39 |
40 | class BranchNode
41 | internal constructor(
42 | override val content: T,
43 | override val depth: Int,
44 | override val key: String = randomUUID,
45 | override val name: String = content.toString(),
46 | override val iconComponent: NodeComponent = { DefaultNodeIcon(it) },
47 | override val nameComponent: NodeComponent = { DefaultNodeName(it) }
48 | ) : Node, SelectableNode by SelectableNodeHandler(), ExpandableNode by ExpandableNodeHandler()
49 |
--------------------------------------------------------------------------------
/bonsai-core/src/main/kotlin/cafe/adriel/bonsai/core/node/extension/ExpandableNode.kt:
--------------------------------------------------------------------------------
1 | package cafe.adriel.bonsai.core.node.extension
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 |
7 | internal interface ExpandableNode {
8 |
9 | val isExpanded: Boolean
10 |
11 | var isExpandedState: Boolean
12 |
13 | var onToggleExpanded: (Boolean, Int) -> Unit
14 |
15 | fun setExpanded(isExpanded: Boolean, maxDepth: Int)
16 | }
17 |
18 | internal class ExpandableNodeHandler : ExpandableNode {
19 |
20 | override val isExpanded: Boolean
21 | get() = isExpandedState
22 |
23 | override var isExpandedState: Boolean by mutableStateOf(false)
24 |
25 | override var onToggleExpanded: (Boolean, Int) -> Unit by mutableStateOf({ _, _ -> })
26 |
27 | override fun setExpanded(isExpanded: Boolean, maxDepth: Int) {
28 | onToggleExpanded(isExpanded, maxDepth)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/bonsai-core/src/main/kotlin/cafe/adriel/bonsai/core/node/extension/SelectableNode.kt:
--------------------------------------------------------------------------------
1 | package cafe.adriel.bonsai.core.node.extension
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 |
7 | internal interface SelectableNode {
8 |
9 | val isSelected: Boolean
10 |
11 | var isSelectedState: Boolean
12 |
13 | var onToggleSelected: (Boolean) -> Unit
14 |
15 | fun setSelected(isSelected: Boolean)
16 | }
17 |
18 | internal class SelectableNodeHandler : SelectableNode {
19 |
20 | override val isSelected: Boolean
21 | get() = isSelectedState
22 |
23 | override var isSelectedState: Boolean by mutableStateOf(false)
24 |
25 | override var onToggleSelected: (Boolean) -> Unit by mutableStateOf({})
26 |
27 | override fun setSelected(isSelected: Boolean) {
28 | onToggleSelected(isSelected)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/bonsai-core/src/main/kotlin/cafe/adriel/bonsai/core/tree/Tree.kt:
--------------------------------------------------------------------------------
1 | package cafe.adriel.bonsai.core.tree
2 |
3 | import android.annotation.SuppressLint
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.Composition
6 | import androidx.compose.runtime.Immutable
7 | import androidx.compose.runtime.Stable
8 | import androidx.compose.runtime.remember
9 | import androidx.compose.runtime.rememberCompositionContext
10 | import cafe.adriel.bonsai.core.node.Node
11 | import cafe.adriel.bonsai.core.node.TreeApplier
12 | import cafe.adriel.bonsai.core.tree.extension.ExpandableTree
13 | import cafe.adriel.bonsai.core.tree.extension.ExpandableTreeHandler
14 | import cafe.adriel.bonsai.core.tree.extension.SelectableTree
15 | import cafe.adriel.bonsai.core.tree.extension.SelectableTreeHandler
16 |
17 | @DslMarker private annotation class TreeMarker
18 |
19 | @Immutable
20 | @TreeMarker
21 | data class TreeScope
22 | internal constructor(
23 | val depth: Int,
24 | internal val isExpanded: Boolean = true,
25 | internal val expandMaxDepth: Int = 0
26 | )
27 |
28 | @Stable
29 | class Tree internal constructor(val nodes: List>) :
30 | ExpandableTree by ExpandableTreeHandler(nodes),
31 | SelectableTree by SelectableTreeHandler(nodes)
32 |
33 | @SuppressLint("ComposableNaming")
34 | @Composable
35 | fun Tree(content: @Composable TreeScope.() -> Unit): Tree {
36 | val applier = remember { TreeApplier() }
37 | val compositionContext = rememberCompositionContext()
38 | val composition =
39 | remember(applier, compositionContext) { Composition(applier, compositionContext) }
40 | composition.setContent { TreeScope(depth = 0).content() }
41 | return remember(applier) { Tree(applier.children) }
42 | }
43 |
--------------------------------------------------------------------------------
/bonsai-core/src/main/kotlin/cafe/adriel/bonsai/core/tree/extension/ExpandableTree.kt:
--------------------------------------------------------------------------------
1 | package cafe.adriel.bonsai.core.tree.extension
2 |
3 | import cafe.adriel.bonsai.core.node.BranchNode
4 | import cafe.adriel.bonsai.core.node.Node
5 |
6 | interface ExpandableTree {
7 |
8 | fun toggleExpansion(node: Node)
9 |
10 | fun collapseRoot()
11 |
12 | fun expandRoot()
13 |
14 | fun collapseAll()
15 |
16 | fun expandAll()
17 |
18 | fun collapseFrom(depth: Int)
19 |
20 | fun expandUntil(depth: Int)
21 |
22 | fun collapseNode(node: Node)
23 |
24 | fun expandNode(node: Node)
25 | }
26 |
27 | internal class ExpandableTreeHandler(private val nodes: List>) : ExpandableTree {
28 |
29 | override fun toggleExpansion(node: Node) {
30 | if (node !is BranchNode) return
31 |
32 | if (node.isExpanded) collapseNode(node) else expandNode(node)
33 | }
34 |
35 | override fun collapseRoot() {
36 | collapse(nodes, depth = 0)
37 | }
38 |
39 | override fun expandRoot() {
40 | expand(nodes, depth = 0)
41 | }
42 |
43 | override fun collapseAll() {
44 | collapse(nodes, depth = 0)
45 | }
46 |
47 | override fun expandAll() {
48 | expand(nodes, depth = Int.MAX_VALUE)
49 | }
50 |
51 | override fun collapseFrom(depth: Int) {
52 | collapse(nodes, depth)
53 | }
54 |
55 | override fun expandUntil(depth: Int) {
56 | expand(nodes, depth)
57 | }
58 |
59 | override fun collapseNode(node: Node) {
60 | collapse(listOf(node), node.depth)
61 | }
62 |
63 | override fun expandNode(node: Node) {
64 | expand(listOf(node), node.depth)
65 | }
66 |
67 | private fun collapse(nodes: List>, depth: Int) {
68 | nodes
69 | .asSequence()
70 | .filterIsInstance>()
71 | .filter { it.depth >= depth }
72 | .sortedByDescending { it.depth }
73 | .forEach { it.setExpanded(false, depth) }
74 | }
75 |
76 | private fun expand(nodes: List>, depth: Int) {
77 | nodes
78 | .asSequence()
79 | .filterIsInstance>()
80 | .filter { it.depth <= depth }
81 | .sortedBy { it.depth }
82 | .forEach { it.setExpanded(true, depth) }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/bonsai-core/src/main/kotlin/cafe/adriel/bonsai/core/tree/extension/SelectableTree.kt:
--------------------------------------------------------------------------------
1 | package cafe.adriel.bonsai.core.tree.extension
2 |
3 | import cafe.adriel.bonsai.core.node.BranchNode
4 | import cafe.adriel.bonsai.core.node.LeafNode
5 | import cafe.adriel.bonsai.core.node.Node
6 |
7 | interface SelectableTree {
8 |
9 | val selectedNodes: List>
10 |
11 | fun toggleSelection(node: Node)
12 |
13 | fun selectNode(node: Node)
14 |
15 | fun unselectNode(node: Node)
16 |
17 | fun clearSelection()
18 | }
19 |
20 | internal class SelectableTreeHandler(private val nodes: List>) : SelectableTree {
21 |
22 | override val selectedNodes: List>
23 | get() = nodes.filter { it.isSelected }
24 |
25 | override fun toggleSelection(node: Node) {
26 | if (node.isSelected) unselectNode(node) else selectNode(node)
27 | }
28 |
29 | override fun selectNode(node: Node) {
30 | node.setSelected(true)
31 | }
32 |
33 | override fun unselectNode(node: Node) {
34 | node.setSelected(false)
35 | }
36 |
37 | override fun clearSelection() {
38 | selectedNodes.forEach { it.setSelected(false) }
39 | }
40 |
41 | private fun Node.setSelected(isSelected: Boolean) {
42 | when (this) {
43 | is LeafNode -> setSelected(isSelected)
44 | is BranchNode -> setSelected(isSelected)
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/bonsai-core/src/main/kotlin/cafe/adriel/bonsai/core/util/Expects.kt:
--------------------------------------------------------------------------------
1 | package cafe.adriel.bonsai.core.util
2 |
3 | import java.util.*
4 |
5 | internal val randomUUID: String
6 | get() = UUID.randomUUID().toString()
7 |
--------------------------------------------------------------------------------
/build-logic/.gitignore:
--------------------------------------------------------------------------------
1 | .gradle/*
2 | build/*
3 | **/build/*
--------------------------------------------------------------------------------
/build-logic/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
2 |
3 | plugins { `kotlin-dsl` }
4 |
5 | dependencies {
6 | implementation(libs.build.spotless)
7 | implementation(libs.build.kotlin)
8 | }
9 |
10 | afterEvaluate {
11 | tasks.withType().configureEach {
12 | sourceCompatibility = JavaVersion.VERSION_17.toString()
13 | targetCompatibility = JavaVersion.VERSION_17.toString()
14 | }
15 |
16 | tasks.withType().configureEach {
17 | kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() }
18 | }
19 | }
20 |
21 | gradlePlugin {
22 | plugins {
23 | register("spotless") {
24 | id = "dev.yashgarg.qbit.spotless"
25 | implementationClass = "dev.yashgarg.qbit.gradle.SpotlessPlugin"
26 | version = "1.0.0"
27 | }
28 | register("githooks") {
29 | id = "dev.yashgarg.qbit.githooks"
30 | implementationClass = "dev.yashgarg.qbit.gradle.GitHooksPlugin"
31 | version = "1.0.0"
32 | }
33 | register("kotlin-common") {
34 | id = "dev.yashgarg.qbit.kotlin-common"
35 | implementationClass = "dev.yashgarg.qbit.gradle.KotlinCommonPlugin"
36 | version = "1.0.0"
37 | }
38 | register("kotlin-android") {
39 | id = "dev.yashgarg.qbit.kotlin-android"
40 | implementationClass = "dev.yashgarg.qbit.gradle.KotlinAndroidPlugin"
41 | version = "1.0.0"
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/build-logic/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:Suppress("UnstableApiUsage")
2 |
3 | rootProject.name = "build-logic"
4 |
5 | pluginManagement {
6 | repositories {
7 | mavenCentral()
8 | google()
9 | gradlePluginPortal()
10 | }
11 | }
12 |
13 | dependencyResolutionManagement {
14 | repositories {
15 | google()
16 | mavenCentral()
17 | versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/build-logic/src/main/kotlin/dev/yashgarg/qbit/gradle/GitHooksPlugin.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.qbit.gradle
2 |
3 | import org.apache.tools.ant.taskdefs.condition.Os
4 | import org.gradle.api.Plugin
5 | import org.gradle.api.Project
6 | import org.gradle.api.tasks.Copy
7 | import org.gradle.api.tasks.Exec
8 | import org.gradle.kotlin.dsl.register
9 |
10 | @Suppress("Unused")
11 | class GitHooksPlugin : Plugin {
12 |
13 | override fun apply(project: Project) {
14 | project.tasks.register("copyGitHooks") {
15 | description = "Copies the git hooks from /hooks to the .git/hooks folder."
16 | from("${project.rootDir}/hooks/") {
17 | include("**/*.sh")
18 | rename("(.*).sh", "$1")
19 | }
20 | into("${project.rootDir}/.git/hooks")
21 | }
22 |
23 | project.tasks.register("installGitHooks") {
24 | description = "Installs the pre-commit hooks with permissions"
25 | commandLine("chmod", "-R", "+x", ".git/hooks/")
26 | onlyIf { !Os.isFamily(Os.FAMILY_WINDOWS) }
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/build-logic/src/main/kotlin/dev/yashgarg/qbit/gradle/KotlinAndroidPlugin.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.qbit.gradle
2 |
3 | import org.gradle.api.Plugin
4 | import org.gradle.api.Project
5 | import org.gradle.kotlin.dsl.apply
6 | import org.jetbrains.kotlin.gradle.plugin.KotlinAndroidPluginWrapper
7 |
8 | @Suppress("Unused")
9 | class KotlinAndroidPlugin : Plugin {
10 |
11 | override fun apply(project: Project) {
12 | project.pluginManager.run {
13 | apply(KotlinAndroidPluginWrapper::class)
14 | apply(KotlinCommonPlugin::class)
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/build-logic/src/main/kotlin/dev/yashgarg/qbit/gradle/KotlinCommonPlugin.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.qbit.gradle
2 |
3 | import org.gradle.api.JavaVersion
4 | import org.gradle.api.Plugin
5 | import org.gradle.api.Project
6 | import org.gradle.api.tasks.compile.JavaCompile
7 | import org.gradle.kotlin.dsl.withType
8 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
9 |
10 | @Suppress("Unused")
11 | class KotlinCommonPlugin : Plugin {
12 |
13 | override fun apply(project: Project) {
14 | project.tasks.run {
15 | withType().configureEach {
16 | sourceCompatibility = JavaVersion.VERSION_17.toString()
17 | targetCompatibility = JavaVersion.VERSION_17.toString()
18 | }
19 | withType().configureEach {
20 | kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() }
21 | }
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/build-logic/src/main/kotlin/dev/yashgarg/qbit/gradle/SpotlessPlugin.kt:
--------------------------------------------------------------------------------
1 | package dev.yashgarg.qbit.gradle
2 |
3 | import com.diffplug.gradle.spotless.SpotlessExtension
4 | import com.diffplug.gradle.spotless.SpotlessPlugin
5 | import com.diffplug.spotless.LineEnding
6 | import org.gradle.api.Plugin
7 | import org.gradle.api.Project
8 | import org.gradle.kotlin.dsl.apply
9 | import org.gradle.kotlin.dsl.getByType
10 |
11 | @Suppress("Unused")
12 | class SpotlessPlugin : Plugin {
13 |
14 | override fun apply(project: Project) {
15 | project.pluginManager.apply(SpotlessPlugin::class)
16 | project.extensions.getByType().run {
17 | /** Workaround for https://github.com/diffplug/spotless/issues/1644 */
18 | lineEndings = LineEnding.UNIX
19 |
20 | kotlin {
21 | ktfmt().kotlinlangStyle()
22 | target("**/*.kt")
23 | targetExclude("**/build/")
24 | trimTrailingWhitespace()
25 | endWithNewline()
26 | }
27 |
28 | kotlinGradle {
29 | ktfmt().kotlinlangStyle()
30 | target("**/*.gradle.kts")
31 | targetExclude("**/build/")
32 | }
33 |
34 | format("xml") {
35 | target("**/*.xml")
36 | targetExclude("**/build/", ".idea/")
37 | trimTrailingWhitespace()
38 | indentWithSpaces()
39 | endWithNewline()
40 | }
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | @file:Suppress("DSL_SCOPE_VIOLATION")
3 |
4 | import com.android.build.gradle.internal.tasks.factory.dependsOn
5 |
6 | plugins {
7 | alias(libs.plugins.android.application) apply false
8 | alias(libs.plugins.android.library) apply false
9 | alias(libs.plugins.android.test) apply false
10 | alias(libs.plugins.kotlin.serialization) apply false
11 | alias(libs.plugins.kotlin.android) apply false
12 | alias(libs.plugins.hilt) apply false
13 | alias(libs.plugins.kotlin.kapt) apply false
14 | alias(libs.plugins.navigation.safeargs) apply false
15 | alias(libs.plugins.multiplatform) apply false
16 | alias(libs.plugins.binaryCompat) apply false
17 | alias(libs.plugins.sentry) apply false
18 |
19 | alias(libs.plugins.custom.githooks)
20 | alias(libs.plugins.custom.spotless)
21 | alias(libs.plugins.kotlin.common)
22 | }
23 |
24 | val clean by tasks.existing(Delete::class) { delete(rootProject.layout.buildDirectory) }
25 |
26 | afterEvaluate {
27 | tasks.prepareKotlinBuildScriptModel.dependsOn(tasks.copyGitHooks, tasks.installGitHooks)
28 | }
29 |
--------------------------------------------------------------------------------
/client-wrapper/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020 Andrew Carlson
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/client-wrapper/client/build.gradle.kts:
--------------------------------------------------------------------------------
1 | @Suppress("DSL_SCOPE_VIOLATION")
2 | plugins {
3 | alias(libs.plugins.multiplatform)
4 | alias(libs.plugins.kotlin.serialization)
5 | alias(libs.plugins.binaryCompat)
6 | }
7 |
8 | kotlin {
9 | jvm()
10 |
11 | sourceSets {
12 | val commonMain by getting {
13 | dependencies {
14 | api(projects.clientWrapper.models)
15 | implementation(libs.coroutines.core)
16 | implementation(libs.kotlinx.serialization)
17 | implementation(libs.ktor.client.core)
18 | implementation(libs.ktor.client.logging)
19 | implementation(libs.ktor.client.contentNegotiation)
20 | implementation(libs.ktor.serialization)
21 | }
22 | }
23 |
24 | val jvmMain by getting { dependencies { implementation(kotlin("stdlib-jdk8")) } }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/client-wrapper/client/src/commonMain/kotlin/QBittorrentException.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent
2 |
3 | import io.ktor.client.statement.*
4 |
5 | /**
6 | * A generic exception thrown by every API method in [QBittorrentClient], simplifying error
7 | * handling.
8 | *
9 | * If a [response] is set, the error occurred because the server responded with an error. If no
10 | * [response] is provided, the [cause] will contain an exception that was produced before executing
11 | * the request.
12 | */
13 | class QBittorrentException : Exception {
14 |
15 | private var body: String? = null
16 | var response: HttpResponse? = null
17 | private set
18 |
19 | constructor(
20 | response: HttpResponse,
21 | body: String,
22 | ) : super() {
23 | this.response = response
24 | this.body = body
25 | }
26 |
27 | constructor(cause: Throwable) : super(cause)
28 |
29 | override val message: String
30 | get() =
31 | if (response == null) {
32 | super.message ?: ""
33 | } else {
34 | body?.ifBlank { "${response?.status?.value}: " }.orEmpty()
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/client-wrapper/client/src/commonMain/kotlin/internal/AtomicReference.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.internal
2 |
3 | internal expect class AtomicReference(value: T) {
4 |
5 | var value: T
6 | }
7 |
--------------------------------------------------------------------------------
/client-wrapper/client/src/commonMain/kotlin/internal/ErrorTransformer.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.internal
2 |
3 | import io.ktor.client.*
4 | import io.ktor.client.call.*
5 | import io.ktor.client.plugins.*
6 | import io.ktor.client.request.*
7 | import io.ktor.client.statement.*
8 | import io.ktor.http.*
9 | import io.ktor.util.*
10 | import io.ktor.util.date.*
11 | import io.ktor.utils.io.*
12 |
13 | /**
14 | * A Ktor client plugin which forwards errors that occur before requests execute with a dummy
15 | * [HttpResponse].
16 | *
17 | * The call attributes will contain a [KEY_INTERNAL_ERROR] containing the exception which can be
18 | * wrapped and rethrown for consistent handling.
19 | */
20 | @OptIn(InternalAPI::class)
21 | internal object ErrorTransformer : HttpClientPlugin {
22 |
23 | val KEY_INTERNAL_ERROR = AttributeKey("INTERNAL_ERROR")
24 |
25 | override val key: AttributeKey = AttributeKey("ErrorTransformer")
26 |
27 | override fun prepare(block: ErrorTransformer.() -> Unit): ErrorTransformer = this
28 |
29 | override fun install(plugin: ErrorTransformer, scope: HttpClient) {
30 | scope.requestPipeline.intercept(HttpRequestPipeline.State) {
31 | try {
32 | proceed()
33 | } catch (e: Throwable) {
34 | val responseData =
35 | HttpResponseData(
36 | statusCode = HttpStatusCode(-1, ""),
37 | requestTime = GMTDate(),
38 | body = ByteReadChannel(byteArrayOf()),
39 | callContext = context.executionContext,
40 | headers = Headers.Empty,
41 | version = HttpProtocolVersion.HTTP_1_0,
42 | )
43 | context.attributes.put(KEY_INTERNAL_ERROR, e)
44 | subject = HttpClientCall(scope, context.build(), responseData)
45 | proceed()
46 | }
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/client-wrapper/client/src/commonMain/kotlin/internal/FileReader.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.internal
2 |
3 | /**
4 | * A simple utility to create a [ByteArray] of the contents of a given file path, if anything goes
5 | * wrong null is returned.
6 | */
7 | internal expect object FileReader {
8 |
9 | fun contentOrNull(filePath: String): ByteArray?
10 | }
11 |
--------------------------------------------------------------------------------
/client-wrapper/client/src/commonMain/kotlin/internal/HttpUtils.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.internal
2 |
3 | import io.ktor.client.call.*
4 | import io.ktor.client.statement.*
5 | import io.ktor.http.*
6 | import io.ktor.util.reflect.*
7 | import qbittorrent.QBittorrentException
8 |
9 | internal suspend fun HttpResponse.orThrow() {
10 | if (!status.isSuccess()) {
11 | throw call.attributes
12 | .takeOrNull(ErrorTransformer.KEY_INTERNAL_ERROR)
13 | ?.run(::QBittorrentException)
14 | ?: QBittorrentException(this, bodyAsText())
15 | }
16 | }
17 |
18 | internal suspend inline fun HttpResponse.bodyOrThrow(): T {
19 | return if (status.isSuccess()) {
20 | when (T::class) {
21 | String::class -> bodyAsText() as T
22 | else -> body()
23 | }
24 | } else {
25 | throw call.attributes
26 | .takeOrNull(ErrorTransformer.KEY_INTERNAL_ERROR)
27 | ?.run(::QBittorrentException)
28 | ?: QBittorrentException(this, bodyAsText())
29 | }
30 | }
31 |
32 | internal suspend fun HttpResponse.bodyOrThrow(typeInfo: TypeInfo): T {
33 | return if (status.isSuccess()) {
34 | body(typeInfo)
35 | } else {
36 | throw call.attributes
37 | .takeOrNull(ErrorTransformer.KEY_INTERNAL_ERROR)
38 | ?.run(::QBittorrentException)
39 | ?: QBittorrentException(this, bodyAsText())
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/client-wrapper/client/src/commonMain/kotlin/internal/JsonUtils.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.internal
2 |
3 | import kotlinx.serialization.json.*
4 |
5 | internal val emptyArray = buildJsonArray {}
6 |
7 | internal fun MutableMap.resetRemoved(key: String) {
8 | put("${key}_removed", emptyArray)
9 | }
10 |
11 | internal fun MutableMap.dropRemoved(key: String) {
12 | val removeKeys = get("${key}_removed").toStringList()
13 | if (removeKeys.isNotEmpty()) {
14 | val items = checkNotNull(get(key)).jsonObject.toMutableMap()
15 | removeKeys.forEach(items::remove)
16 | put(key, JsonObject(items))
17 | }
18 | }
19 |
20 | internal fun MutableMap.dropRemovedStrings(key: String) {
21 | val removeKeys = get("${key}_removed").toStringList()
22 | if (removeKeys.isNotEmpty()) {
23 | val tags =
24 | checkNotNull(get(key))
25 | .jsonArray
26 | .map { it.jsonPrimitive.content }
27 | .filterNot(removeKeys::contains)
28 | .toMutableList()
29 | put(key, JsonArray(tags.map(::JsonPrimitive)))
30 | }
31 | }
32 |
33 | internal fun MutableMap.merge(
34 | newJson: JsonObject,
35 | nestedObjectKeys: List,
36 | ): MutableMap {
37 | forEach { (key, currentValue) ->
38 | val update =
39 | when (val newValue = newJson[key] ?: return@forEach) {
40 | is JsonPrimitive,
41 | is JsonArray -> newValue
42 | is JsonObject -> {
43 | val actualObject =
44 | (if (currentValue is JsonNull) newValue else currentValue).mutateJson()
45 | if (nestedObjectKeys.contains(key)) {
46 | (newValue.keys - actualObject.keys).forEach { newHash ->
47 | actualObject[newHash] = checkNotNull(newValue[newHash]).jsonObject
48 | }
49 | }
50 | JsonObject(actualObject.merge(newValue.jsonObject, emptyList()))
51 | }
52 | }
53 |
54 | put(key, update)
55 | }
56 | return this
57 | }
58 |
59 | internal fun JsonElement?.toStringList(): List {
60 | return this?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty()
61 | }
62 |
63 | internal fun JsonElement?.mutateJson(): MutableMap {
64 | return this?.jsonObject?.toMutableMap() ?: mutableMapOf()
65 | }
66 |
--------------------------------------------------------------------------------
/client-wrapper/client/src/commonMain/kotlin/internal/RawCookiesStorage.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.internal
2 |
3 | import io.ktor.client.plugins.cookies.*
4 | import io.ktor.http.*
5 |
6 | /**
7 | * Work around for Ktor improperly encoding `SID` cookie values, causing authentication to loop
8 | * until a alphanumeric value is created.
9 | */
10 | internal class RawCookiesStorage(private val cookiesStorage: CookiesStorage) :
11 | CookiesStorage by cookiesStorage {
12 |
13 | override suspend fun get(requestUrl: Url): List {
14 | return cookiesStorage.get(requestUrl).map { cookie ->
15 | cookie.copy(encoding = CookieEncoding.RAW)
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/client-wrapper/client/src/jvmMain/kotlin/AtomicReference.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.internal
2 |
3 | internal actual class AtomicReference actual constructor(value: T) {
4 |
5 | private val ref = java.util.concurrent.atomic.AtomicReference(value)
6 |
7 | actual var value: T
8 | get() = ref.get()
9 | set(value) {
10 | ref.set(value)
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/client-wrapper/client/src/jvmMain/kotlin/FileReader.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.internal
2 |
3 | import java.io.File
4 |
5 | internal actual object FileReader {
6 | actual fun contentOrNull(filePath: String): ByteArray? {
7 | val actualFilePath =
8 | if (filePath.startsWith("~/")) {
9 | filePath.replaceFirst("~", System.getProperty("user.home").ifBlank { "~" })
10 | } else {
11 | filePath
12 | }
13 | val file = File(actualFilePath)
14 | return if (file.exists()) {
15 | try {
16 | file.readBytes()
17 | } catch (_: Throwable) {
18 | null
19 | }
20 | } else {
21 | null
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/client-wrapper/models/build.gradle.kts:
--------------------------------------------------------------------------------
1 | @Suppress("DSL_SCOPE_VIOLATION")
2 | plugins {
3 | alias(libs.plugins.multiplatform)
4 | alias(libs.plugins.kotlin.serialization)
5 | alias(libs.plugins.binaryCompat)
6 | }
7 |
8 | kotlin {
9 | jvm()
10 |
11 | sourceSets {
12 | val commonMain by getting {
13 | dependencies {
14 | implementation(libs.serialization.core)
15 | implementation(libs.kotlinx.serialization)
16 | }
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/client-wrapper/models/src/commonMain/kotlin/AddTorrentBody.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.models
2 |
3 | import kotlin.time.*
4 |
5 | data class AddTorrentBody(
6 | /** Torrent file HTTP or Magnet urls. */
7 | val urls: MutableList = mutableListOf(),
8 | /** File paths to torrent files. */
9 | val torrents: MutableList = mutableListOf(),
10 | /** Torrent file names and their file contents. */
11 | val rawTorrents: MutableMap = mutableMapOf(),
12 | /** The torrent download folder. */
13 | var savepath: String? = null,
14 | /** Cookie sent to download the .torrent file. */
15 | var cookie: String? = null,
16 | /** Category for the torrent. */
17 | var category: String? = null,
18 | /** Tags for the torrent. */
19 | val tags: MutableList = mutableListOf(),
20 | /** Skip hash checking. Possible values are true, false (default) */
21 | var skipChecking: Boolean? = null,
22 | /** Add torrents in the paused state. Possible values are true, false (default) */
23 | var paused: Boolean? = null,
24 | /** Create the root folder. Possible values are true, false, unset (default) */
25 | var rootFolder: Boolean? = null,
26 | /** Rename torrent. */
27 | var rename: String? = null,
28 | /** Set torrent upload speed limit. Unit in bytes/second */
29 | var upLimit: Long? = null,
30 | /** Set torrent download speed limit. Unit in bytes/second */
31 | var dlLimit: Long? = null,
32 | /** Set torrent share ratio limit. */
33 | var ratioLimit: Float? = null,
34 | /** Set torrent seeding time limit. */
35 | var seedingTimeLimit: Duration? = null,
36 | /** Whether Automatic Torrent Management should be used. */
37 | var autoTMM: Boolean? = null,
38 | /** Enable sequential download. Possible values are true, false (default) */
39 | var sequentialDownload: Boolean? = null,
40 | /** Prioritize download first last piece. Possible values are true, false (default) */
41 | var firstLastPiecePriority: Boolean? = null,
42 | )
43 |
--------------------------------------------------------------------------------
/client-wrapper/models/src/commonMain/kotlin/BuildInfo.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.models
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class BuildInfo(
7 | val qt: String,
8 | val libtorrent: String,
9 | val boost: String,
10 | val openssl: String,
11 | val bitness: Int,
12 | )
13 |
--------------------------------------------------------------------------------
/client-wrapper/models/src/commonMain/kotlin/Category.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.models
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class Category(
7 | val name: String,
8 | val savePath: String,
9 | )
10 |
--------------------------------------------------------------------------------
/client-wrapper/models/src/commonMain/kotlin/FlakyIntSerializer.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.models
2 |
3 | import kotlinx.serialization.KSerializer
4 | import kotlinx.serialization.SerializationException
5 | import kotlinx.serialization.descriptors.PrimitiveKind
6 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
7 | import kotlinx.serialization.descriptors.SerialDescriptor
8 | import kotlinx.serialization.encoding.Decoder
9 | import kotlinx.serialization.encoding.Encoder
10 |
11 | internal class FlakyIntSerializer : KSerializer {
12 | override val descriptor: SerialDescriptor =
13 | PrimitiveSerialDescriptor("FlakyInt", PrimitiveKind.INT)
14 |
15 | override fun deserialize(decoder: Decoder): Int {
16 | return try {
17 | decoder.decodeInt()
18 | } catch (e: SerializationException) {
19 | decoder.decodeString().toIntOrNull() ?: -1
20 | }
21 | }
22 |
23 | override fun serialize(encoder: Encoder, value: Int) {
24 | encoder.encodeInt(value)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/client-wrapper/models/src/commonMain/kotlin/GlobalTransferInfo.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.models
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class GlobalTransferInfo(
8 | /** Global download rate (bytes/s) */
9 | @SerialName("dl_info_speed") val dlInfoSpeed: Long,
10 | /** Data downloaded this session (bytes) */
11 | @SerialName("dl_info_data") val dlInfoData: Long,
12 | /** Global upload rate (bytes/s) */
13 | @SerialName("up_info_speed") val upInfoSpeed: Long,
14 | /** Data uploaded this session (bytes) */
15 | @SerialName("up_info_data") val upInfoData: Long,
16 | /** Download rate limit (bytes/s) */
17 | @SerialName("dl_rate_limit") val dlRateLimit: Long,
18 | /** Upload rate limit (bytes/s) */
19 | @SerialName("up_rate_limit") val upRateLimit: Long,
20 | /** DHT nodes connected to */
21 | @SerialName("dht_nodes") val dhtNodes: Int,
22 | /** Connection status */
23 | @SerialName("connection_status") val connectionStatus: ConnectionStatus,
24 | /** True if torrent queueing is enabled */
25 | @SerialName("queueing") val queueing: Boolean = false,
26 | /** True if alternative speed limits are enabled */
27 | @SerialName("use_alt_speed_limits") val useAltSpeedLimits: Boolean = false,
28 | /** Transfer list refresh interval (milliseconds) */
29 | @SerialName("refresh_interval") val refreshInterval: Long = -1,
30 | )
31 |
32 | @Serializable
33 | enum class ConnectionStatus {
34 | @SerialName("connected") CONNECTED,
35 | @SerialName("firewalled") FIREWALLED,
36 | @SerialName("disconnected") DISCONNECTED
37 | }
38 |
--------------------------------------------------------------------------------
/client-wrapper/models/src/commonMain/kotlin/KeyMergingTransformer.kt:
--------------------------------------------------------------------------------
1 | package qbittorrent.models
2 |
3 | import kotlinx.serialization.builtins.*
4 | import kotlinx.serialization.json.*
5 |
6 | internal object KeyMergingTransformer :
7 | JsonTransformingSerializer