├── app
├── .gitignore
├── screenshots
│ └── search_screenshot.jpg
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_round.png
│ │ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_round.png
│ │ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_round.png
│ │ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_round.png
│ │ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_round.png
│ │ │ │ └── ic_launcher_foreground.png
│ │ │ ├── values
│ │ │ │ ├── ic_launcher_background.xml
│ │ │ │ ├── strings.xml
│ │ │ │ ├── colors.xml
│ │ │ │ └── styles.xml
│ │ │ ├── drawable
│ │ │ │ ├── cursor_white.xml
│ │ │ │ ├── ic_arrow_upward_white_24dp.xml
│ │ │ │ ├── ic_arrow_downward_white_24dp.xml
│ │ │ │ ├── ic_save_white_24dp.xml
│ │ │ │ ├── ic_date_range_white_24dp.xml
│ │ │ │ └── ic_search_black_24dp.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── layout
│ │ │ │ ├── activity_main.xml
│ │ │ │ ├── item_network_state.xml
│ │ │ │ ├── fragment_search.xml
│ │ │ │ └── item_search_result.xml
│ │ │ └── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── masterwok
│ │ │ │ └── tpbsearchandroid
│ │ │ │ ├── extensions
│ │ │ │ ├── ViewExtensions.kt
│ │ │ │ ├── ContextExtensions.kt
│ │ │ │ └── RecyclerViewExtensions.kt
│ │ │ │ ├── activities
│ │ │ │ └── MainActivity.kt
│ │ │ │ ├── paging
│ │ │ │ ├── search
│ │ │ │ │ ├── TorrentResultDiffCallback.kt
│ │ │ │ │ ├── TpbDataFactory.kt
│ │ │ │ │ ├── TpbItemViewHolder.kt
│ │ │ │ │ └── TpbDataSource.kt
│ │ │ │ └── common
│ │ │ │ │ ├── NetworkState.kt
│ │ │ │ │ ├── NetworkStateViewHolder.kt
│ │ │ │ │ └── NetworkPagedListAdapter.kt
│ │ │ │ ├── viewmodels
│ │ │ │ └── SearchViewModel.kt
│ │ │ │ └── fragments
│ │ │ │ └── SearchFragment.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── masterwok
│ │ │ └── tpbsearchandroid
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── masterwok
│ │ └── tpbsearchandroid
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle
├── tpbsearchandroid
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ └── values
│ │ │ │ └── strings.xml
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── com
│ │ │ └── masterwok
│ │ │ └── tpbsearchandroid
│ │ │ ├── models
│ │ │ ├── QueryResult.kt
│ │ │ └── TorrentResult.kt
│ │ │ ├── common
│ │ │ ├── AndroidJob.kt
│ │ │ ├── InterruptAsync.kt
│ │ │ └── extensions
│ │ │ │ └── DeferredExtensions.kt
│ │ │ ├── contracts
│ │ │ └── QueryService.kt
│ │ │ ├── services
│ │ │ └── QueryService.kt
│ │ │ ├── extensions
│ │ │ └── ElementExtensions.kt
│ │ │ └── constants
│ │ │ └── Hosts.kt
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── masterwok
│ │ │ └── tpbsearchandroid
│ │ │ └── ExampleUnitTest.java
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── masterwok
│ │ └── tpbsearchandroid
│ │ └── ExampleInstrumentedTest.java
├── proguard-rules.pro
└── build.gradle
├── settings.gradle
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .idea
├── vcs.xml
├── runConfigurations.xml
├── codeStyles
│ └── Project.xml
└── misc.xml
├── gradle.properties
├── LICENSE
├── .gitignore
├── gradlew.bat
├── README.md
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/tpbsearchandroid/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app', ':tpbsearchandroid'
2 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/screenshots/search_screenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/screenshots/search_screenshot.jpg
--------------------------------------------------------------------------------
/tpbsearchandroid/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | tpbsearchandroid
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/masterwok/tpb-search-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FC0DEA
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | TPB Search Android
3 | RETRY
4 |
5 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/cursor_white.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/tpbsearchandroid/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_arrow_upward_white_24dp.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_arrow_downward_white_24dp.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/extensions/ViewExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.extensions
2 |
3 | import android.view.View
4 |
5 | /**
6 | * Dismiss soft input (keyboard) from the window using a [View] context.
7 | */
8 | fun View.dismissKeyboard() = context
9 | .getInputMethodManager()
10 | .hideSoftInputFromWindow(
11 | windowToken
12 | , 0
13 | )
14 |
--------------------------------------------------------------------------------
/app/src/test/java/com/masterwok/tpbsearchandroid/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/activities/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.activities
2 |
3 | import android.os.Bundle
4 | import android.support.v7.app.AppCompatActivity
5 | import com.masterwok.tpbsearchandroid.R
6 |
7 | class MainActivity : AppCompatActivity() {
8 |
9 | override fun onCreate(savedInstanceState: Bundle?) {
10 | super.onCreate(savedInstanceState)
11 | setContentView(R.layout.activity_main)
12 | }
13 |
14 | }
15 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_save_white_24dp.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/tpbsearchandroid/src/test/java/com/masterwok/tpbsearchandroid/ExampleUnitTest.java:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid;
2 |
3 | import org.junit.Test;
4 |
5 | import static org.junit.Assert.*;
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * @see Testing documentation
11 | */
12 | public class ExampleUnitTest {
13 | @Test
14 | public void addition_isCorrect() {
15 | assertEquals(4, 2 + 2);
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_date_range_white_24dp.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_search_black_24dp.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/paging/search/TorrentResultDiffCallback.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.paging.search
2 |
3 | import android.support.v7.util.DiffUtil
4 | import com.masterwok.tpbsearchandroid.models.TorrentResult
5 |
6 | val TorrentResultDiffCallback = object : DiffUtil.ItemCallback() {
7 | override fun areItemsTheSame(
8 | oldItem: TorrentResult?
9 | , newItem: TorrentResult?
10 | ): Boolean = oldItem == newItem
11 |
12 | override fun areContentsTheSame(
13 | oldItem: TorrentResult?
14 | , newItem: TorrentResult?
15 | ): Boolean = oldItem?.magnet == newItem?.magnet
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
15 |
16 |
--------------------------------------------------------------------------------
/tpbsearchandroid/src/main/java/com/masterwok/tpbsearchandroid/models/QueryResult.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.models
2 |
3 | data class QueryResult(
4 | var state: State = State.PENDING
5 | , var pageIndex: Int = 0
6 | , var lastPageIndex: Int = 0
7 | , var items: List = ArrayList()
8 | ) {
9 | enum class State {
10 | PENDING,
11 | SUCCESS,
12 | INVALID,
13 | ERROR
14 | }
15 |
16 |
17 | fun isSuccessful(): Boolean = state == State.SUCCESS
18 |
19 | fun getItemCount(): Int = items.size
20 |
21 | override fun toString(): String = "State: $state" +
22 | ", Page: $pageIndex/$lastPageIndex" +
23 | ", Item Count: ${items.size}"
24 | }
25 |
26 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx1536m
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 |
15 |
--------------------------------------------------------------------------------
/tpbsearchandroid/src/main/java/com/masterwok/tpbsearchandroid/common/AndroidJob.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.common
2 |
3 | import android.arch.lifecycle.Lifecycle
4 | import android.arch.lifecycle.LifecycleObserver
5 | import android.arch.lifecycle.OnLifecycleEvent
6 | import kotlinx.coroutines.experimental.Job
7 |
8 |
9 | /**
10 | * A Kotlin coroutine [@see Job] that cancels itself when the lifecycle it's
11 | * bound to is destroyed. This class can be used as a parent job to prevent
12 | * memory leaks and null reference exceptions.
13 | */
14 | class AndroidJob(lifecycle: Lifecycle) : Job by Job(), LifecycleObserver {
15 | init {
16 | lifecycle.addObserver(this)
17 | }
18 |
19 | @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
20 | fun destroy() = cancel()
21 | }
--------------------------------------------------------------------------------
/tpbsearchandroid/src/main/java/com/masterwok/tpbsearchandroid/models/TorrentResult.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.models
2 |
3 | /**
4 | * A single result item. This represents a single row from the search
5 | * results on the pirate bay.
6 | */
7 | data class TorrentResult(
8 | val title: String
9 | , val magnet: String
10 | , val infoHash: String
11 | , val seeders: Int
12 | , val leechers: Int
13 | , val displayUploadedOn: String
14 | , val displaySize: String
15 | ) {
16 | override fun toString(): String {
17 | return "title: $title" +
18 | ", seeders: $seeders" +
19 | ", leechers: $leechers" +
20 | ", infoHash: $infoHash" +
21 | ", magnet: $magnet"
22 | }
23 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/masterwok/tpbsearchandroid/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid
2 |
3 | import android.support.test.InstrumentationRegistry
4 | import android.support.test.runner.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getTargetContext()
22 | assertEquals("com.masterwok.tpbsearchandroid", appContext.packageName)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/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.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/tpbsearchandroid/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.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/paging/common/NetworkState.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.paging.common
2 |
3 |
4 | /**
5 | * This enumeration represents the current state of some network request.
6 | *
7 | */
8 | enum class NetworkState(val value: Int) {
9 |
10 | /**
11 | * Request is in progress.
12 | */
13 | LOADING(0),
14 |
15 | /**
16 | * Request has completed successfully.
17 | */
18 | LOADED(1),
19 |
20 | /**
21 | * Request resulted in an error.
22 | */
23 | ERROR(2);
24 |
25 | companion object {
26 | private val map = NetworkState
27 | .values()
28 | .associateBy(NetworkState::value)
29 |
30 | /**
31 | * Convert an [Int] [value] to a [NetworkState].
32 | */
33 | fun getValue(value: Int) = map[value]
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/extensions/ContextExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.extensions
2 |
3 | import android.content.Context
4 | import android.os.Build
5 | import android.support.annotation.ColorRes
6 | import android.support.v4.content.ContextCompat
7 | import android.view.inputmethod.InputMethodManager
8 |
9 | /**
10 | * Get the [InputMethodManager] using some [Context].
11 | */
12 | fun Context.getInputMethodManager(): InputMethodManager {
13 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
14 | return getSystemService(InputMethodManager::class.java)
15 | }
16 |
17 | return getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
18 | }
19 |
20 |
21 | /**
22 | * Get color using [ContextCompat] and the provided [id].
23 | */
24 | internal fun Context.getCompatColor(@ColorRes id: Int) = ContextCompat.getColor(this, id)
25 |
--------------------------------------------------------------------------------
/tpbsearchandroid/src/androidTest/java/com/masterwok/tpbsearchandroid/ExampleInstrumentedTest.java:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid;
2 |
3 | import android.content.Context;
4 | import android.support.test.InstrumentationRegistry;
5 | import android.support.test.runner.AndroidJUnit4;
6 |
7 | import org.junit.Test;
8 | import org.junit.runner.RunWith;
9 |
10 | import static org.junit.Assert.*;
11 |
12 | /**
13 | * Instrumented test, which will execute on an Android device.
14 | *
15 | * @see Testing documentation
16 | */
17 | @RunWith(AndroidJUnit4.class)
18 | public class ExampleInstrumentedTest {
19 | @Test
20 | public void useAppContext() {
21 | // Context of the app under test.
22 | Context appContext = InstrumentationRegistry.getTargetContext();
23 |
24 | assertEquals("com.masterwok.tpbsearchandroid.test", appContext.getPackageName());
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #313A3B
4 | #3F4B4C
5 | #5D5179
6 |
7 | #4C5B5C
8 | #3891A6
9 | #94E495
10 | #92c9b1
11 | #5D5179
12 | #EF6F6C
13 | #F5EE9E
14 |
15 | #FF000000
16 | #FFFFFFFF
17 |
18 | #CF000000
19 |
20 | @color/castPurple
21 | #7F000000
22 |
23 | #7F000000
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/extensions/RecyclerViewExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.extensions
2 |
3 | import android.support.v7.widget.RecyclerView
4 |
5 | /**
6 | * Disable the initial scroll that occurs when inserting items into a list when the
7 | * initial scroll position is 0. This seems to occur when using a [@see PagedListAdapter]
8 | * however this may be limited to some use cases. If a scroll view is auto scrolling to
9 | * the bottom when items are inserted, then try applying this extension to the adapter.
10 | */
11 | fun RecyclerView.Adapter.disableInitialInsertScroll(
12 | layoutManager: RecyclerView.LayoutManager
13 | ) {
14 | registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
15 | override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
16 | if (positionStart == 0) {
17 | layoutManager.scrollToPosition(0)
18 | }
19 | }
20 | })
21 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
12 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/tpbsearchandroid/src/main/java/com/masterwok/tpbsearchandroid/contracts/QueryService.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("EXPERIMENTAL_FEATURE_WARNING")
2 |
3 | package com.masterwok.tpbsearchandroid.contracts
4 |
5 | import com.masterwok.tpbsearchandroid.models.QueryResult
6 | import com.masterwok.tpbsearchandroid.models.TorrentResult
7 |
8 | const val DefaultRequestTimeout = 5000
9 | const val DefaultQueryTimeout = 10000L
10 |
11 |
12 | /**
13 | * Contract that provides simple interface for querying The Pirate Bay.
14 | */
15 | interface QueryService {
16 |
17 | /**
18 | * Simultaneously query each host using the provided [query] and [pageIndex]. The [query]
19 | * is the search query, and the [pageIndex] is the result page index. Each request will
20 | * timeout after some [requestTimeoutMs] and the whole query will timeout after some
21 | * [queryTimeoutMs].
22 | */
23 | suspend fun query(
24 | query: String
25 | , pageIndex: Int = 0
26 | , queryTimeoutMs: Long = DefaultQueryTimeout
27 | , requestTimeoutMs: Int = DefaultRequestTimeout
28 | ): QueryResult
29 |
30 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Jonathan Trowbridge
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
14 |
15 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/tpbsearchandroid/src/main/java/com/masterwok/tpbsearchandroid/common/InterruptAsync.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("EXPERIMENTAL_FEATURE_WARNING")
2 |
3 | package com.masterwok.tpbsearchandroid.common
4 |
5 | import kotlinx.coroutines.experimental.*
6 |
7 | internal fun interruptAsync(
8 | executorCoroutineDispatcher: ExecutorCoroutineDispatcher
9 | , start: CoroutineStart = CoroutineStart.DEFAULT
10 | , parent: Job? = null
11 | , onCompletion: CompletionHandler? = null
12 | , block: suspend CoroutineScope.() -> T
13 | ): Deferred {
14 | var thread: Thread? = null
15 |
16 | val deferred = async(
17 | context = executorCoroutineDispatcher
18 | , start = start
19 | , parent = parent
20 | , onCompletion = onCompletion
21 | ) {
22 | thread = Thread.currentThread()
23 |
24 | return@async block()
25 | }
26 |
27 | deferred.invokeOnCompletion(true, true) {
28 | if (deferred.isCancelled
29 | && thread != null
30 | && thread?.isAlive == true
31 | ) {
32 | thread?.interrupt()
33 | thread = null
34 | }
35 | }
36 |
37 | return deferred
38 | }
39 |
40 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_network_state.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
17 |
18 |
25 |
26 |
33 |
34 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/paging/search/TpbDataFactory.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.paging.search
2 |
3 | import android.arch.lifecycle.MutableLiveData
4 | import android.arch.paging.DataSource
5 | import com.masterwok.tpbsearchandroid.contracts.QueryService
6 | import com.masterwok.tpbsearchandroid.models.TorrentResult
7 | import kotlinx.coroutines.experimental.Job
8 |
9 | class TpbDataFactory constructor(
10 | private val queryService: QueryService
11 | , private val rootJob: Job
12 | , private val verboseLogging: Boolean = false
13 | ) : DataSource.Factory() {
14 |
15 | private val searchLiveData = MutableLiveData()
16 |
17 | private var query: String? = null
18 |
19 | override fun create(): DataSource {
20 | val dataSource = TpbDataSource(
21 | queryService
22 | , rootJob
23 | , query
24 | , verboseLogging
25 | )
26 |
27 | searchLiveData.postValue(dataSource)
28 |
29 | return dataSource
30 | }
31 |
32 | fun getMutableLiveData() = searchLiveData
33 |
34 | fun setQuery(query: String?) {
35 | this.query = query
36 |
37 | searchLiveData
38 | .value
39 | ?.invalidate()
40 | }
41 |
42 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Built application files
2 | *.apk
3 | *.ap_
4 |
5 | # Files for the ART/Dalvik VM
6 | *.dex
7 |
8 | # Java class files
9 | *.class
10 |
11 | # Generated files
12 | bin/
13 | gen/
14 | out/
15 |
16 | # Gradle files
17 | .gradle/
18 | build/
19 |
20 | # Local configuration file (sdk path, etc)
21 | local.properties
22 |
23 | # Proguard folder generated by Eclipse
24 | proguard/
25 |
26 | # Log Files
27 | *.log
28 |
29 | # Android Studio Navigation editor temp files
30 | .navigation/
31 |
32 | # Android Studio captures folder
33 | captures/
34 |
35 | # IntelliJ
36 | *.iml
37 | .idea/workspace.xml
38 | .idea/tasks.xml
39 | .idea/gradle.xml
40 | .idea/assetWizardSettings.xml
41 | .idea/dictionaries
42 | .idea/libraries
43 | .idea/caches
44 |
45 | # Keystore files
46 | # Uncomment the following line if you do not want to check your keystore files in.
47 | #*.jks
48 |
49 | # External native build folder generated in Android Studio 2.2 and later
50 | .externalNativeBuild
51 |
52 | # Google Services (e.g. APIs or Firebase)
53 | google-services.json
54 |
55 | # Freeline
56 | freeline.py
57 | freeline/
58 | freeline_project_description.json
59 |
60 | # fastlane
61 | fastlane/report.xml
62 | fastlane/Preview.html
63 | fastlane/screenshots
64 | fastlane/test_output
65 | fastlane/readme.md
66 | *.iml
67 | .gradle
68 | /local.properties
69 | /.idea/caches/build_file_checksums.ser
70 | /.idea/libraries
71 | /.idea/modules.xml
72 | /.idea/workspace.xml
73 | .DS_Store
74 | /build
75 | /captures
76 | .externalNativeBuild
77 |
--------------------------------------------------------------------------------
/tpbsearchandroid/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.library'
2 | apply plugin: 'kotlin-android'
3 | apply plugin: 'kotlin-android-extensions'
4 | apply plugin: 'kotlin-kapt'
5 |
6 | android {
7 | compileSdkVersion 27
8 |
9 | defaultConfig {
10 | minSdkVersion 14
11 | targetSdkVersion 27
12 | versionCode 1
13 | versionName "1.0"
14 |
15 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
16 |
17 | }
18 |
19 | buildTypes {
20 | release {
21 | minifyEnabled false
22 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
23 | }
24 | }
25 |
26 | }
27 |
28 | ext {
29 | version_kotlinx_coroutines = '0.25.3'
30 | version_support = '27.1.1'
31 | }
32 |
33 | dependencies {
34 | implementation fileTree(dir: 'libs', include: ['*.jar'])
35 |
36 | implementation "com.android.support:appcompat-v7:$version_support"
37 | testImplementation 'junit:junit:4.12'
38 | androidTestImplementation 'com.android.support.test:runner:1.0.2'
39 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
40 |
41 | // Kotlin co-routines
42 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
43 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version_kotlinx_coroutines"
44 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version_kotlinx_coroutines"
45 |
46 | // Jsoup
47 | implementation 'org.jsoup:jsoup:1.11.3'
48 |
49 | }
50 | repositories {
51 | mavenCentral()
52 | }
53 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
13 |
19 |
22 |
25 |
26 |
27 |
28 |
34 |
35 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.application'
2 |
3 | apply plugin: 'kotlin-android'
4 |
5 | apply plugin: 'kotlin-android-extensions'
6 |
7 | android {
8 | compileSdkVersion 27
9 | defaultConfig {
10 | applicationId "com.masterwok.tpbsearchandroid"
11 | minSdkVersion 14
12 | targetSdkVersion 27
13 | versionCode 1
14 | versionName "1.0"
15 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
16 | }
17 | buildTypes {
18 | release {
19 | minifyEnabled false
20 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
21 | }
22 | }
23 | }
24 |
25 | ext {
26 | version_support = '27.1.1'
27 | version_kotlinx_coroutines = '0.25.3'
28 | version_lifecycle = '1.1.1'
29 | version_paging = '1.0.1'
30 | }
31 |
32 | dependencies {
33 | implementation fileTree(dir: 'libs', include: ['*.jar'])
34 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
35 |
36 | // Support
37 | implementation "com.android.support:appcompat-v7:$version_support"
38 | implementation "com.android.support:cardview-v7:$version_support"
39 |
40 | // Kotlin co-routines
41 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
42 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version_kotlinx_coroutines"
43 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version_kotlinx_coroutines"
44 |
45 | // View Model and LiveData
46 | implementation "android.arch.lifecycle:extensions:$version_lifecycle"
47 |
48 | // Paging
49 | implementation "android.arch.paging:runtime:$version_paging"
50 |
51 | // The Pirate Bay Search Library
52 | implementation project(path: ':tpbsearchandroid')
53 |
54 | implementation 'com.android.support.constraint:constraint-layout:1.1.3'
55 | testImplementation 'junit:junit:4.12'
56 | androidTestImplementation 'com.android.support.test:runner:1.0.2'
57 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
58 | }
59 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/paging/search/TpbItemViewHolder.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.paging.search
2 |
3 | import android.support.v7.widget.RecyclerView
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import com.masterwok.tpbsearchandroid.R
8 | import com.masterwok.tpbsearchandroid.paging.common.NetworkPagedListAdapter
9 | import com.masterwok.tpbsearchandroid.models.TorrentResult
10 | import kotlinx.android.synthetic.main.item_search_result.view.*
11 |
12 | class TpbItemViewHolder(
13 | itemView: View
14 | , private val onClick: (torrentResult: TorrentResult?) -> Unit
15 | ) : RecyclerView.ViewHolder(itemView)
16 | , NetworkPagedListAdapter.NetworkViewHolder {
17 |
18 | companion object {
19 | fun create(
20 | parent: ViewGroup
21 | , onClick: (torrentResult: TorrentResult?) -> Unit
22 | ): TpbItemViewHolder {
23 | val view = LayoutInflater
24 | .from(parent.context)
25 | .inflate(R.layout.item_search_result, parent, false)
26 |
27 | return TpbItemViewHolder(view, onClick)
28 | }
29 | }
30 |
31 | private var model: TorrentResult? = null
32 |
33 | init {
34 | itemView.relativeLayoutSearchResult.setOnClickListener {
35 | onClick(model)
36 | }
37 | }
38 |
39 | private fun clear() {
40 | itemView.textViewTitle.text = null
41 | itemView.textViewUploadedOn.text = null
42 | itemView.textViewSize.text = null
43 | itemView.textViewSeeders.text = null
44 | itemView.textViewLeechers.text = null
45 | }
46 |
47 | override fun configure(model: TorrentResult?) {
48 | this.model = model
49 |
50 | if (model == null) {
51 | clear()
52 | return
53 | }
54 |
55 | itemView.textViewTitle.text = model.title
56 | itemView.textViewUploadedOn.text = model.displayUploadedOn
57 | itemView.textViewSize.text = model.displaySize
58 | itemView.textViewSeeders.text = model.seeders.toString()
59 | itemView.textViewLeechers.text = model.leechers.toString()
60 | }
61 |
62 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_search.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
14 |
15 |
27 |
28 |
37 |
38 |
39 |
40 |
44 |
45 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/paging/common/NetworkStateViewHolder.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.paging.common
2 |
3 | import android.support.v7.widget.RecyclerView
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import com.masterwok.tpbsearchandroid.R
8 | import kotlinx.android.synthetic.main.item_network_state.view.*
9 |
10 |
11 | /**
12 | * This view holder represents [NetworkState] in a [RecyclerView].
13 | */
14 | class NetworkStateViewHolder(
15 | itemView: View
16 | , private val retryCallback: () -> Unit
17 | ) : RecyclerView.ViewHolder(itemView) {
18 |
19 | companion object {
20 | fun create(parent: ViewGroup, retryCallback: () -> Unit): NetworkStateViewHolder {
21 | val view = LayoutInflater
22 | .from(parent.context)
23 | .inflate(R.layout.item_network_state, parent, false)
24 |
25 | return NetworkStateViewHolder(view, retryCallback)
26 | }
27 | }
28 |
29 | init {
30 | itemView.buttonRetry.setOnClickListener {
31 | retryCallback()
32 | }
33 | }
34 |
35 | fun configure(networkState: NetworkState?) {
36 | if (networkState == null) {
37 | return
38 | }
39 |
40 | when (networkState) {
41 | NetworkState.LOADING -> setLoadingViewState()
42 | NetworkState.LOADED -> setLoadedViewState()
43 | NetworkState.ERROR -> setErrorViewState("Something went wrong...")
44 | }
45 | }
46 |
47 | private fun setLoadingViewState() {
48 | itemView.progressBar.visibility = View.VISIBLE
49 | itemView.textViewErrorMessage.visibility = View.GONE
50 | itemView.buttonRetry.visibility = View.GONE
51 | }
52 |
53 | private fun setLoadedViewState() {
54 | itemView.progressBar.visibility = View.GONE
55 | itemView.textViewErrorMessage.visibility = View.GONE
56 | itemView.buttonRetry.visibility = View.GONE
57 | }
58 |
59 | private fun setErrorViewState(errorMessage: String) {
60 | itemView.progressBar.visibility = View.GONE
61 | itemView.buttonRetry.visibility = View.VISIBLE
62 | itemView.textViewErrorMessage.visibility = View.VISIBLE
63 | itemView.textViewErrorMessage.text = errorMessage
64 | }
65 |
66 | }
67 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://jitpack.io/#masterwok/tpb-search-android)
2 |
3 | # [deprecated] tpb-search-android
4 |
5 | I'm not longer maintaining this project due to time constraints.
6 |
7 | An Android library for querying magnets from [thepiratebay.org](https://thepiratebay.org).
8 |
9 | When a query is started, the library attempts to query against all defined [hosts](https://github.com/masterwok/tpb-search-android/blob/master/tpbsearchandroid/src/main/java/com/masterwok/tpbsearchandroid/constants/Hosts.kt) simultaneously until an endpoint successfully returns a [QueryResult](https://github.com/masterwok/tpb-search-android/blob/master/tpbsearchandroid/src/main/java/com/masterwok/tpbsearchandroid/models/QueryResult.kt) containing [TorrentResult](https://github.com/masterwok/tpb-search-android/blob/master/tpbsearchandroid/src/main/java/com/masterwok/tpbsearchandroid/models/TorrentResult.kt) instances. When this happens, all pending queries are cancelled. A request to an endpoint will timeout after the defined ```requestTimeoutMs```. The query as a whole will timeout after the defined ```queryTimeoutMs```.
10 |
11 | Please see the companion demo application of this library for a detailed example of how to use this library alongside the [Android JetPack Paging](https://developer.android.com/topic/libraries/architecture/paging/) library.
12 |
13 |
14 | ## Usage
15 |
16 | Simply invoke ```QueryService.query(..)``` to query for magnets. For example, to query for *The Hobbit from 1977* the first page of results with a query timeout of 10,000 milliseconds, a timeout per site of 5,000 milliseconds, and a maximum successful response count of 5, one would do the following:
17 |
18 | ```kotlin
19 | val queryService: QuerySerivce = QueryService(
20 | queryFactories = QueryFactories
21 | )
22 |
23 | ...
24 |
25 | launch() {
26 | val queryResult = queryService.query(
27 | query = "The Hobbit 1977"
28 | , pageIndex = 0
29 | , queryTimeout = 10000L
30 | , requestTimeout = 5000
31 | ): QueryResult
32 | }
33 | ```
34 |
35 | ## Configuration
36 |
37 | Add this in your root build.gradle at the end of repositories:
38 | ```gradle
39 | allprojects {
40 | repositories {
41 | maven { url "https://jitpack.io" }
42 | }
43 | }
44 | ```
45 | and add the following in the dependent module:
46 |
47 | ```gradle
48 | dependencies {
49 | implementation 'com.github.masterwok:tpb-search-android:0.0.3'
50 | }
51 | ```
52 |
53 | ## Projects using tpb-search-android
54 | - [Bit Cast](https://play.google.com/store/apps/details?id=com.masterwok.bitcast)
55 |
56 | ## Demo Screenshot
57 |
58 |
59 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/viewmodels/SearchViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.viewmodels
2 |
3 | import android.arch.lifecycle.LiveData
4 | import android.arch.lifecycle.Transformations
5 | import android.arch.lifecycle.ViewModel
6 | import android.arch.paging.LivePagedListBuilder
7 | import android.arch.paging.PagedList
8 | import com.masterwok.tpbsearchandroid.constants.QueryFactories
9 | import com.masterwok.tpbsearchandroid.contracts.QueryService
10 | import com.masterwok.tpbsearchandroid.models.TorrentResult
11 | import com.masterwok.tpbsearchandroid.paging.common.NetworkState
12 | import com.masterwok.tpbsearchandroid.paging.search.TpbDataFactory
13 | import com.masterwok.tpbsearchandroid.paging.search.TpbDataSource
14 | import kotlinx.coroutines.experimental.Job
15 | import java.util.concurrent.Executors
16 |
17 |
18 | class SearchViewModel : ViewModel() {
19 |
20 | // In a real world app this dependency should be injected.
21 | private val queryService: QueryService = com
22 | .masterwok
23 | .tpbsearchandroid
24 | .services
25 | .QueryService(
26 | QueryFactories
27 | )
28 |
29 | private val rootJob = Job()
30 |
31 | private val executor = Executors.newFixedThreadPool(5)
32 |
33 | private val searchDataFactory = TpbDataFactory(
34 | queryService
35 | , rootJob
36 | , verboseLogging = true
37 | )
38 |
39 | private val pagedListConfig = PagedList.Config.Builder()
40 | .setEnablePlaceholders(false)
41 | .setInitialLoadSizeHint(30)
42 | .setPageSize(30)
43 | .build()
44 |
45 | private val searchResultItemLiveData = LivePagedListBuilder(
46 | searchDataFactory
47 | , pagedListConfig
48 | ).setFetchExecutor(executor).build()
49 |
50 | private val networkState = Transformations.switchMap(
51 | searchDataFactory.getMutableLiveData()
52 | ) { dataSource: TpbDataSource? -> dataSource?.networkState }
53 |
54 | private fun invalidate() = searchDataFactory
55 | .getMutableLiveData()
56 | .value
57 | ?.invalidate()
58 |
59 | fun getSearchResultLiveData(): LiveData> = searchResultItemLiveData
60 |
61 | fun getNetworkStateLiveData(): LiveData = networkState
62 |
63 | fun retry() = searchDataFactory
64 | .getMutableLiveData()
65 | .value
66 | ?.retry()
67 |
68 | fun refresh() = invalidate()
69 |
70 | fun query(query: String?) {
71 | if (query == null || query.isEmpty()) {
72 | searchDataFactory.setQuery(null)
73 | return
74 | }
75 |
76 | searchDataFactory.setQuery(query)
77 | }
78 |
79 | override fun onCleared() {
80 | super.onCleared()
81 |
82 | // Ensure any pending retry or refresh is cancelled.
83 | rootJob.cancel()
84 | }
85 |
86 | }
87 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/paging/common/NetworkPagedListAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.paging.common
2 |
3 | import android.arch.paging.PagedListAdapter
4 | import android.support.v7.util.DiffUtil
5 | import android.support.v7.widget.RecyclerView
6 | import android.view.ViewGroup
7 |
8 | class NetworkPagedListAdapter(
9 | diffCallback: DiffUtil.ItemCallback
10 | , private val retryCallback: () -> Unit
11 | , private val resultViewHolderFactory: (parent: ViewGroup) -> RecyclerView.ViewHolder
12 | ) : PagedListAdapter(diffCallback) {
13 |
14 | interface NetworkViewHolder {
15 | fun configure(model: T)
16 | }
17 |
18 | private enum class ViewType(val value: Int) {
19 | NETWORK(0),
20 | ITEM(1);
21 |
22 | companion object {
23 | private val map = ViewType
24 | .values()
25 | .associateBy(ViewType::value)
26 |
27 | fun getValue(value: Int) = map[value]
28 | }
29 | }
30 |
31 | private var networkState: NetworkState? = null
32 |
33 | private fun hasExtraRow(): Boolean = networkState != null
34 | && networkState != NetworkState.LOADED
35 |
36 | fun setNetworkState(newNetworkState: NetworkState?) {
37 | val previousNetworkState = networkState
38 | val previousExtraRow = hasExtraRow()
39 |
40 | networkState = newNetworkState
41 |
42 | val hasExtraRow = hasExtraRow()
43 |
44 | if (previousExtraRow != hasExtraRow) {
45 | if (previousExtraRow) {
46 | notifyItemRemoved(itemCount)
47 | } else {
48 | notifyItemInserted(itemCount)
49 | }
50 | } else if (hasExtraRow && previousNetworkState != newNetworkState) {
51 | notifyItemChanged(itemCount - 1)
52 | }
53 | }
54 |
55 | override fun getItemCount(): Int {
56 | return super.getItemCount() + if (hasExtraRow()) 1 else 0
57 | }
58 |
59 | override fun getItemViewType(position: Int): Int =
60 | if (hasExtraRow() && position == itemCount - 1) {
61 | ViewType.NETWORK.value
62 | } else {
63 | ViewType.ITEM.value
64 | }
65 |
66 | override fun onCreateViewHolder(
67 | parent: ViewGroup
68 | , viewType: Int
69 | ): RecyclerView.ViewHolder = when (ViewType.getValue(viewType)) {
70 | ViewType.NETWORK -> NetworkStateViewHolder.create(parent, retryCallback)
71 | ViewType.ITEM -> resultViewHolderFactory.invoke(parent)
72 | else -> throw RuntimeException("Unexpected view type in network adapter.")
73 | }
74 |
75 | override fun onBindViewHolder(
76 | holder: RecyclerView.ViewHolder
77 | , position: Int
78 | ) {
79 | if(holder is NetworkStateViewHolder) {
80 | holder.configure(networkState)
81 | return
82 | }
83 |
84 | @Suppress("UNCHECKED_CAST")
85 | (holder as NetworkViewHolder).configure(getItem(position))
86 | }
87 | }
--------------------------------------------------------------------------------
/tpbsearchandroid/src/main/java/com/masterwok/tpbsearchandroid/services/QueryService.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("EXPERIMENTAL_FEATURE_WARNING")
2 |
3 | package com.masterwok.tpbsearchandroid.services
4 |
5 | import com.masterwok.tpbsearchandroid.common.extensions.awaitCount
6 | import com.masterwok.tpbsearchandroid.contracts.QueryService
7 | import com.masterwok.tpbsearchandroid.extensions.getQueryResult
8 | import com.masterwok.tpbsearchandroid.common.interruptAsync
9 | import com.masterwok.tpbsearchandroid.models.QueryResult
10 | import com.masterwok.tpbsearchandroid.models.TorrentResult
11 | import kotlinx.coroutines.experimental.*
12 | import org.jsoup.Jsoup
13 | import java.util.concurrent.Executors
14 |
15 | class QueryService constructor(
16 | private val queryFactories: List<(query: String, pageIndex: Int) -> String>
17 | ) : QueryService {
18 |
19 | private val queryExecutor = Executors
20 | .newCachedThreadPool()
21 | .asCoroutineDispatcher()
22 |
23 |
24 | override suspend fun query(
25 | query: String
26 | , pageIndex: Int
27 | , queryTimeoutMs: Long
28 | , requestTimeoutMs: Int
29 | ): QueryResult = getFirstValidResult(
30 | queryFactories = queryFactories
31 | , query = query
32 | , pageIndex = pageIndex
33 | , requestTimeoutMs = requestTimeoutMs
34 | , queryTimeoutMs = queryTimeoutMs
35 | )
36 |
37 | private suspend fun getFirstValidResult(
38 | queryFactories: List<(query: String, pageIndex: Int) -> String>
39 | , query: String
40 | , pageIndex: Int
41 | , requestTimeoutMs: Int
42 | , queryTimeoutMs: Long
43 | ): QueryResult = createDeferredQueries(
44 | queryFactories = queryFactories
45 | , query = query
46 | , pageIndex = pageIndex
47 | , requestTimeoutMs = requestTimeoutMs
48 | ).awaitCount(
49 | count = 1
50 | , timeoutMs = queryTimeoutMs
51 | , keepUnsuccessful = false
52 | , predicate = { queryResult -> queryResult?.isSuccessful() == true }
53 | ).firstOrNull()
54 | ?: QueryResult(state = QueryResult.State.ERROR)
55 |
56 | private fun createDeferredQueries(
57 | queryFactories: List<(query: String, pageIndex: Int) -> String>
58 | , query: String
59 | , pageIndex: Int
60 | , requestTimeoutMs: Int
61 | ) = queryFactories.map { queryFactory ->
62 | interruptAsync(queryExecutor, start = CoroutineStart.LAZY) {
63 | val queryResult = queryEndpoint(
64 | queryFactory(query, pageIndex)
65 | , pageIndex = pageIndex
66 | , requestTimeoutMs = requestTimeoutMs
67 | )
68 |
69 | yield()
70 |
71 | queryResult
72 | }
73 | }
74 |
75 | private fun queryEndpoint(
76 | url: String
77 | , pageIndex: Int
78 | , requestTimeoutMs: Int
79 | ): QueryResult {
80 | return try {
81 | Jsoup.connect(url)
82 | .timeout(requestTimeoutMs)
83 | .get()
84 | .getQueryResult(pageIndex)
85 | } catch (ex: Exception) {
86 | QueryResult(state = QueryResult.State.ERROR)
87 | }
88 | }
89 |
90 | }
91 |
92 |
--------------------------------------------------------------------------------
/tpbsearchandroid/src/main/java/com/masterwok/tpbsearchandroid/common/extensions/DeferredExtensions.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("EXPERIMENTAL_FEATURE_WARNING", "unused")
2 |
3 | package com.masterwok.tpbsearchandroid.common.extensions
4 |
5 | import kotlinx.coroutines.experimental.Deferred
6 | import kotlinx.coroutines.experimental.channels.ticker
7 | import kotlinx.coroutines.experimental.selects.whileSelect
8 | import java.util.concurrent.CopyOnWriteArraySet
9 |
10 |
11 | /**
12 | * Await the provided [count] of [Deferred] instances to complete before
13 | * some [timeoutMs] occurs. Results will include unsuccessful items should
14 | * [keepUnsuccessful] be set to true. Results are considered successful
15 | * if they pass the provided [predicate].
16 | */
17 | suspend fun List>.awaitCount(
18 | count: Int
19 | , timeoutMs: Long
20 | , keepUnsuccessful: Boolean
21 | , predicate: (T) -> Boolean
22 | ): List {
23 | require(count <= size)
24 |
25 | val toAwait = CopyOnWriteArraySet>(this)
26 | val result = ArrayList()
27 | val ticker = ticker(timeoutMs)
28 | var successCount = 0
29 |
30 | whileSelect {
31 | ticker.onReceive {
32 | toAwait.forEach { deferred -> deferred.cancel() }
33 | false
34 | }
35 |
36 | toAwait.forEach { deferred ->
37 | deferred.onAwait {
38 | toAwait.remove(deferred)
39 |
40 | // Break out of the whileSelect if all deferred instances are cancelled or completed.
41 | if (toAwait.all { deferred -> deferred.isCancelled || deferred.isCompleted }) {
42 | return@onAwait false
43 | }
44 |
45 | if (predicate(it)) {
46 | result.add(it)
47 | successCount++
48 | } else if (keepUnsuccessful) {
49 | result.add(it)
50 | }
51 |
52 | successCount < count
53 | }
54 | }
55 | }
56 |
57 | // Ensure all deferred instances are cancelled should the success count be reached before
58 | // the timeout occurs.
59 | toAwait.forEach { deferred -> deferred.cancel() }
60 |
61 | return result
62 | }
63 |
64 | /**
65 | * Await the provided [count] of [Deferred] instances to complete before
66 | * some [timeoutMs] occurs.
67 | */
68 | suspend fun List>.awaitCount(
69 | count: Int
70 | , timeoutMs: Long
71 | ): List {
72 | require(count <= size)
73 |
74 | val toAwait = CopyOnWriteArraySet>(this)
75 | val result = ArrayList()
76 | val ticker = ticker(timeoutMs)
77 |
78 | whileSelect {
79 | ticker.onReceive {
80 | toAwait.forEach { deferred -> deferred.cancel() }
81 | false
82 | }
83 |
84 | toAwait.forEach { deferred ->
85 | deferred.onAwait {
86 | toAwait.remove(deferred)
87 |
88 | // Break out of the whileSelect if all deferred instances are cancelled or completed.
89 | if (toAwait.all { deferred -> deferred.isCancelled || deferred.isCompleted }) {
90 | return@onAwait false
91 | }
92 |
93 | result.add(it)
94 |
95 | result.size < count
96 | }
97 | }
98 | }
99 |
100 | // Ensure all deferred instances are cancelled should the success count be reached before
101 | // the timeout occurs.
102 | toAwait.forEach { deferred -> deferred.cancel() }
103 |
104 | return result
105 | }
106 |
107 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/fragments/SearchFragment.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.fragments
2 |
3 | import android.arch.lifecycle.Observer
4 | import android.content.Context
5 | import android.os.Bundle
6 | import android.support.v4.app.Fragment
7 | import android.support.v7.widget.LinearLayoutManager
8 | import android.view.KeyEvent
9 | import android.view.LayoutInflater
10 | import android.view.View
11 | import android.view.ViewGroup
12 | import com.masterwok.tpbsearchandroid.R
13 | import com.masterwok.tpbsearchandroid.extensions.disableInitialInsertScroll
14 | import com.masterwok.tpbsearchandroid.extensions.dismissKeyboard
15 | import com.masterwok.tpbsearchandroid.extensions.getCompatColor
16 | import com.masterwok.tpbsearchandroid.models.TorrentResult
17 | import com.masterwok.tpbsearchandroid.paging.search.TpbItemViewHolder
18 | import com.masterwok.tpbsearchandroid.paging.search.TorrentResultDiffCallback
19 | import com.masterwok.tpbsearchandroid.paging.common.NetworkPagedListAdapter
20 | import com.masterwok.tpbsearchandroid.viewmodels.SearchViewModel
21 | import kotlinx.android.synthetic.main.fragment_search.*
22 |
23 | class SearchFragment : Fragment() {
24 |
25 | // In real world app, this would be injected.
26 | private val viewModel: SearchViewModel = SearchViewModel()
27 |
28 | private lateinit var searchAdapter: NetworkPagedListAdapter
29 |
30 | override fun onAttach(context: Context?) {
31 | super.onAttach(context)
32 |
33 | searchAdapter = NetworkPagedListAdapter(
34 | TorrentResultDiffCallback
35 | , { viewModel.retry() }
36 | , { parent -> TpbItemViewHolder.create(parent, {}) }
37 | )
38 |
39 | viewModel.getSearchResultLiveData().observe(this, Observer {
40 | swipeRefreshLayoutSearch.isRefreshing = false
41 | searchAdapter.submitList(it)
42 | })
43 |
44 | viewModel.getNetworkStateLiveData().observe(this, Observer {
45 | searchAdapter.setNetworkState(it)
46 | })
47 | }
48 |
49 | override fun onCreateView(
50 | inflater: LayoutInflater
51 | , container: ViewGroup?
52 | , savedInstanceState: Bundle?
53 | ): View = inflater.inflate(
54 | R.layout.fragment_search
55 | , container
56 | , false
57 | )
58 |
59 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
60 | super.onViewCreated(view, savedInstanceState)
61 |
62 | swipeRefreshLayoutSearch.setProgressBackgroundColorSchemeColor(
63 | context!!.getCompatColor(R.color.castPurple)
64 | )
65 |
66 | subscribeToViewComponents()
67 | initSearchRecyclerView()
68 | }
69 |
70 |
71 | private fun initSearchRecyclerView() {
72 | searchRecyclerView.apply {
73 | this.layoutManager = LinearLayoutManager(context)
74 | adapter = searchAdapter.apply {
75 | disableInitialInsertScroll(layoutManager)
76 | }
77 | }
78 | }
79 |
80 | private fun subscribeToViewComponents() {
81 | fun queryAndDismissKeyboard() {
82 | imageButtonSearch.dismissKeyboard()
83 |
84 | viewModel.query(editTextSearch.text.toString())
85 | }
86 |
87 | imageButtonSearch.setOnClickListener {
88 | queryAndDismissKeyboard()
89 | }
90 |
91 | swipeRefreshLayoutSearch.setOnRefreshListener {
92 | viewModel.refresh()
93 | }
94 |
95 | editTextSearch.setOnKeyListener { _, _, keyEvent ->
96 | if (keyEvent.action == KeyEvent.ACTION_DOWN) {
97 | return@setOnKeyListener false
98 | }
99 |
100 | when (keyEvent.keyCode) {
101 | KeyEvent.KEYCODE_DPAD_CENTER -> queryAndDismissKeyboard()
102 | KeyEvent.KEYCODE_ENTER -> queryAndDismissKeyboard()
103 | else -> return@setOnKeyListener false
104 | }
105 |
106 | true
107 | }
108 | }
109 | }
--------------------------------------------------------------------------------
/tpbsearchandroid/src/main/java/com/masterwok/tpbsearchandroid/extensions/ElementExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.extensions
2 |
3 | import com.masterwok.tpbsearchandroid.models.QueryResult
4 | import com.masterwok.tpbsearchandroid.models.TorrentResult
5 | import org.jsoup.nodes.Element
6 | import java.util.*
7 |
8 | private const val SearchResultPath = "table#searchResult tbody tr"
9 | private const val TitleSelectPath = "td:nth-child(2) > div"
10 | private const val MagnetSelectPath = "td:nth-child(2) > a:nth-child(2)"
11 | private const val SeedersSelectPath = "td:nth-child(3)"
12 | private const val LeechersSelectPath = "td:nth-child(4)"
13 | private const val PageSelectPath = "body > div:nth-child(6) > a"
14 | private const val PageSelectPathB = "#content > div:nth-child(3) > a"
15 | private const val InfoSelector = "td:nth-child(2) > font"
16 |
17 | private val InfoRegex = Regex("""Uploaded\s*([\d\W]*),\s*Size\s*(.*),""")
18 | private val InfoHashRegex = Regex("btih:(.*)&dn")
19 |
20 |
21 | internal fun Element?.isValidResult(): Boolean = this
22 | ?.select(SearchResultPath)
23 | ?.isNotEmpty() == true
24 |
25 |
26 | internal fun Element?.getQueryResult(pageIndex: Int): QueryResult {
27 | if (!isValidResult()) {
28 | return QueryResult(state = QueryResult.State.INVALID)
29 | }
30 |
31 | val items = this?.select(SearchResultPath)
32 | ?.mapNotNull { it.tryParseSearchResultItem() }
33 | ?.sortedByDescending { it.seeders }
34 | ?.distinctBy { it.infoHash }
35 | ?.toList()
36 | ?: ArrayList()
37 |
38 | return QueryResult(
39 | state = QueryResult.State.SUCCESS
40 | , pageIndex = pageIndex
41 | , lastPageIndex = this?.tryParseLastPageIndex() ?: 0
42 | , items = items
43 | )
44 | }
45 |
46 | private fun Element.tryParseLastPageIndex(): Int {
47 | val pageCount: Int
48 | var pageLinks = select(PageSelectPath)
49 |
50 | if (pageLinks.isEmpty()) {
51 | pageLinks = select(PageSelectPathB)
52 | }
53 |
54 | val imageLink = pageLinks
55 | .last()
56 | ?.select("img")
57 | ?.firstOrNull()
58 |
59 | // Doesn't have arrow/next image link (last page).
60 | if (imageLink == null) {
61 | val last = pageLinks
62 | .lastOrNull()
63 | ?.text()
64 | ?: "0"
65 |
66 | pageCount = Integer.parseInt(last) + 1
67 | } else {
68 | // Has arrow/next image link, drop it and get the value of last one.
69 | val last = pageLinks
70 | .dropLast(1)
71 | .lastOrNull()
72 | ?.text()
73 | ?: "0"
74 |
75 | pageCount = Integer.parseInt(last)
76 | }
77 |
78 | // Ensure last page index >= 0
79 | return Math.max(pageCount - 1, 0)
80 | }
81 |
82 | private fun Element.tryParseSearchResultItem(): TorrentResult? {
83 | try {
84 | val magnet = select(MagnetSelectPath)
85 | ?.first()
86 | ?.attr("href")
87 | ?: ""
88 |
89 | val infoTextMatch = getInfoTextMatch()
90 |
91 | val title = select(TitleSelectPath)
92 | ?.first()
93 | ?.text()
94 | ?: ""
95 |
96 | val seedersText = select(SeedersSelectPath)
97 | ?.first()
98 | ?.text()
99 | ?: "0"
100 |
101 | val leechersText = select(LeechersSelectPath)
102 | ?.first()
103 | ?.text()
104 | ?: "0"
105 |
106 | return TorrentResult(
107 | title = title
108 | , magnet = magnet
109 | , infoHash = getInfoHash(magnet)
110 | , seeders = Integer.parseInt(seedersText)
111 | , leechers = Integer.parseInt(leechersText)
112 | , displayUploadedOn = getUploadedOn(infoTextMatch)
113 | , displaySize = getSize(infoTextMatch)
114 |
115 | )
116 | } catch (ex: Exception) {
117 | return null
118 | }
119 | }
120 |
121 | private fun Element.getInfoTextMatch(): MatchResult? {
122 | val infoText = select(InfoSelector)
123 | ?.text()
124 |
125 | return InfoRegex.find(infoText ?: "")
126 | }
127 |
128 | private fun getCurrentYear() = Calendar
129 | .getInstance()
130 | .get(Calendar.YEAR)
131 |
132 | private fun getUploadedOn(infoTextMatch: MatchResult?): String {
133 | val text = infoTextMatch
134 | ?.groupValues
135 | ?.get(1)
136 | ?: ""
137 |
138 | return if (text.contains(':')) {
139 | "${text.substringBefore(' ')}-${getCurrentYear()}"
140 | } else {
141 | (text).replace("""[\s]""".toRegex(), "-")
142 | }
143 | }
144 |
145 | private fun getSize(infoTextMatch: MatchResult?): String = infoTextMatch
146 | ?.groupValues
147 | ?.get(2)
148 | ?: ""
149 |
150 |
151 | private fun getInfoHash(magnet: String): String = InfoHashRegex
152 | .find(magnet)
153 | ?.groupValues
154 | ?.get(1)
155 | ?: ""
156 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/tpbsearchandroid/src/main/java/com/masterwok/tpbsearchandroid/constants/Hosts.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.constants
2 |
3 | import java.net.URLEncoder
4 |
5 | private const val QueryEncoding = "UTF-8"
6 |
7 | /**
8 | * Create a query using absolute path style.
9 | */
10 | private fun pathQuery(
11 | host: String
12 | , query: String
13 | , pageIndex: Int
14 | ): String {
15 | val encodedQuery = URLEncoder.encode(query, QueryEncoding)
16 |
17 | return "$host/search/$encodedQuery/$pageIndex/7"
18 | }
19 |
20 |
21 | /**
22 | * Create a query using the query string style.
23 | */
24 | private fun queryStringQuery(
25 | host: String
26 | , query: String
27 | , pageIndex: Int
28 | ): String {
29 | val encodedQuery = URLEncoder.encode(query, QueryEncoding)
30 |
31 | return "$host/s/?q=$encodedQuery&page=$pageIndex&orderby=99"
32 | }
33 |
34 |
35 | /**
36 | * A [List] of factories for creating a query string URL. Clones have different ways of
37 | * creating query URLs. Luckily it seems like there are only a few at the moment. As more
38 | * are added, query factories can be added and adjusted for each of the defined hosts.
39 | */
40 | @JvmField
41 | val QueryFactories: List<(query: String, pageIndex: Int) -> String> = listOf(
42 | { query, pageIndex -> queryStringQuery("https://thepiratebay.org", query, pageIndex) }
43 | , { query, pageIndex -> queryStringQuery("https://thepiratebay.online", query, pageIndex) }
44 | , { query, pageIndex -> queryStringQuery("https://superbay.in", query, pageIndex) }
45 | , { query, pageIndex -> queryStringQuery("https://superbay.in", query, pageIndex) }
46 | , { query, pageIndex -> queryStringQuery("https://piratebays.be", query, pageIndex) }
47 | , { query, pageIndex -> queryStringQuery("https://piratebays.fi", query, pageIndex) }
48 | , { query, pageIndex -> queryStringQuery("https://indiapirate.com", query, pageIndex) }
49 | , { query, pageIndex -> queryStringQuery("https://pirate.tel", query, pageIndex) }
50 | , { query, pageIndex -> queryStringQuery("https://piratebay.nz", query, pageIndex) }
51 | , { query, pageIndex -> queryStringQuery("https://piratebay6.org", query, pageIndex) }
52 | , { query, pageIndex -> queryStringQuery("https://uktpb.net", query, pageIndex) }
53 | , { query, pageIndex -> queryStringQuery("https://thepirateproxy.in", query, pageIndex) }
54 | , { query, pageIndex -> queryStringQuery("https://piratesbay.fi", query, pageIndex) }
55 | , { query, pageIndex -> queryStringQuery("https://proxyproxyproxy.net", query, pageIndex) }
56 | , { query, pageIndex -> queryStringQuery("https://piratepirate.in", query, pageIndex) }
57 | , { query, pageIndex -> queryStringQuery("https://fastpirate.link", query, pageIndex) }
58 | , { query, pageIndex -> queryStringQuery("https://thepiratebay.click", query, pageIndex) }
59 | , { query, pageIndex -> queryStringQuery("https://gameofbay.eu", query, pageIndex) }
60 | , { query, pageIndex -> queryStringQuery("https://freepirate.eu", query, pageIndex) }
61 | , { query, pageIndex -> queryStringQuery("https://freepirate.org", query, pageIndex) }
62 | , { query, pageIndex -> queryStringQuery("https://pirateproxy.fi", query, pageIndex) }
63 | , { query, pageIndex -> queryStringQuery("https://proxybayproxy.net", query, pageIndex) }
64 | , { query, pageIndex -> queryStringQuery("https://tpbproxy.fi", query, pageIndex) }
65 | , { query, pageIndex -> queryStringQuery("https://freeproxy.click", query, pageIndex) }
66 | , { query, pageIndex -> queryStringQuery("https://piratepirate.net", query, pageIndex) }
67 | , { query, pageIndex -> queryStringQuery("https://piratebays.top", query, pageIndex) }
68 | , { query, pageIndex -> queryStringQuery("https://proxyproxy.fi", query, pageIndex) }
69 | , { query, pageIndex -> queryStringQuery("https://piratemirror.org", query, pageIndex) }
70 | , { query, pageIndex -> queryStringQuery("https://piratebaypirate.net", query, pageIndex) }
71 | , { query, pageIndex -> queryStringQuery("https://tpbproxy.click", query, pageIndex) }
72 | , { query, pageIndex -> queryStringQuery("https://thepirateproxy.click", query, pageIndex) }
73 | , { query, pageIndex -> queryStringQuery("https://thepirateway.click", query, pageIndex) }
74 | , { query, pageIndex -> queryStringQuery("https://proxybay.blue", query, pageIndex) }
75 | , { query, pageIndex -> queryStringQuery("https://piratepiratepirate.org", query, pageIndex) }
76 | , { query, pageIndex -> queryStringQuery("https://unblocktpb.org", query, pageIndex) }
77 | , { query, pageIndex -> queryStringQuery("https://tpbunblock.net", query, pageIndex) }
78 | , { query, pageIndex -> queryStringQuery("https://ukpirateproxy.com", query, pageIndex) }
79 | , { query, pageIndex -> queryStringQuery("https://thepiratebayproxy.net", query, pageIndex) }
80 | , { query, pageIndex -> queryStringQuery("https://tpbproxy.in", query, pageIndex) }
81 | , { query, pageIndex -> queryStringQuery("https://thepiratebayproxy.in", query, pageIndex) }
82 | , { query, pageIndex -> queryStringQuery("https://tpb.review", query, pageIndex) }
83 | , { query, pageIndex -> queryStringQuery("https://Piratebayproxy.in", query, pageIndex) }
84 | , { query, pageIndex -> queryStringQuery("https://proxybay.life", query, pageIndex) }
85 | , { query, pageIndex -> queryStringQuery("https://tpb.fun", query, pageIndex) }
86 | , { query, pageIndex -> queryStringQuery("https://thepiratebayproxy.one", query, pageIndex) }
87 | , { query, pageIndex -> queryStringQuery("https://pirateproxy.bid", query, pageIndex) }
88 | , { query, pageIndex -> queryStringQuery("https://pirateproxy.men", query, pageIndex) }
89 | , { query, pageIndex -> queryStringQuery("https://thepiratebay-org.prox.space", query, pageIndex) }
90 | , { query, pageIndex -> pathQuery("https://priatebays.fi", query, pageIndex) }
91 | , { query, pageIndex -> pathQuery("https://pirateproxy.sh", query, pageIndex) }
92 | , { query, pageIndex -> pathQuery("https://thepiratebay.red", query, pageIndex) }
93 | , { query, pageIndex -> pathQuery("https://tpbmirror.org", query, pageIndex) }
94 | )
95 |
96 |
97 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_search_result.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
14 |
15 |
24 |
25 |
36 |
37 |
46 |
47 |
54 |
55 |
64 |
65 |
72 |
73 |
82 |
83 |
84 |
93 |
94 |
100 |
101 |
110 |
111 |
112 |
121 |
122 |
128 |
129 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
--------------------------------------------------------------------------------
/app/src/main/java/com/masterwok/tpbsearchandroid/paging/search/TpbDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.masterwok.tpbsearchandroid.paging.search
2 |
3 |
4 | import android.arch.lifecycle.MutableLiveData
5 | import android.arch.paging.PageKeyedDataSource
6 | import android.util.Log
7 | import com.masterwok.tpbsearchandroid.contracts.DefaultRequestTimeout
8 | import com.masterwok.tpbsearchandroid.paging.common.NetworkState
9 | import com.masterwok.tpbsearchandroid.contracts.QueryService
10 | import com.masterwok.tpbsearchandroid.models.QueryResult
11 | import com.masterwok.tpbsearchandroid.models.TorrentResult
12 | import kotlinx.coroutines.experimental.CoroutineStart
13 | import kotlinx.coroutines.experimental.Job
14 | import kotlinx.coroutines.experimental.launch
15 | import kotlinx.coroutines.experimental.runBlocking
16 | import kotlinx.coroutines.experimental.sync.Mutex
17 | import kotlinx.coroutines.experimental.sync.withLock
18 |
19 | class TpbDataSource constructor(
20 | private val queryService: QueryService
21 | , private val rootJob: Job
22 | , private val query: String?
23 | , private val verboseLogging: Boolean = false
24 | ) : PageKeyedDataSource() {
25 |
26 | companion object {
27 | private const val Tag = "TpbDataSource"
28 |
29 | private const val QueryTimeout = 10000L
30 | }
31 |
32 | val networkState: MutableLiveData = MutableLiveData()
33 |
34 | private val searchResults = ArrayList()
35 | private var lastPageIndex: Int = 0
36 | private var retryJob: Job? = null
37 | private var queryJob: Job? = null
38 | private val mutex = Mutex()
39 |
40 | override fun loadInitial(
41 | params: LoadInitialParams
42 | , callback: LoadInitialCallback
43 | ) {
44 | queryJob?.cancel()
45 |
46 | if (query == null) {
47 | return
48 | }
49 |
50 | networkState.postValue(NetworkState.LOADING)
51 |
52 | queryJob = launch {
53 | mutex.withLock {
54 |
55 | val requestedLoadSize = params.requestedLoadSize
56 | val queryResult = queryService.query(
57 | query = query
58 | , pageIndex = 0
59 | , queryTimeoutMs = QueryTimeout
60 | , requestTimeoutMs = DefaultRequestTimeout
61 | )
62 |
63 | if (queryResult.isSuccessful()) {
64 | searchResults.addAll(queryResult.items)
65 | lastPageIndex = queryResult.lastPageIndex
66 |
67 | networkState.postValue(NetworkState.LOADED)
68 |
69 | callback.onResult(getItemRange(0, requestedLoadSize - 1), null, 1)
70 |
71 | return@withLock
72 | }
73 |
74 | setRetry { loadInitial(params, callback) }
75 | networkState.postValue(NetworkState.ERROR)
76 | }
77 | }
78 | }
79 |
80 | override fun loadAfter(
81 | params: LoadParams
82 | , callback: LoadCallback
83 | ) {
84 | if (query == null) {
85 | return
86 | }
87 |
88 | networkState.postValue(NetworkState.LOADING)
89 |
90 | launch {
91 | mutex.withLock {
92 | val pageIndex = params.key.toInt()
93 | val requestedLoadSize = params.requestedLoadSize
94 | val itemOffset = pageIndex * requestedLoadSize
95 | val endIndex = itemOffset + requestedLoadSize
96 | val isLastPage = pageIndex == lastPageIndex
97 |
98 | if (verboseLogging) {
99 | Log.d(Tag, "page: $pageIndex/$lastPageIndex")
100 | }
101 |
102 | // Already have items in requested range
103 | if (endIndex <= searchResults.size - 1) {
104 | networkState.postValue(NetworkState.LOADED)
105 |
106 | if (verboseLogging) {
107 | Log.d(Tag, "Already had range, page: $pageIndex/$lastPageIndex")
108 | }
109 |
110 | callback.onResult(
111 | getItemRange(itemOffset, endIndex)
112 | , if (isLastPage) null else pageIndex + 1L
113 | )
114 |
115 | return@withLock
116 | }
117 |
118 | val queryResult = queryService.query(query, pageIndex, QueryTimeout)
119 |
120 | if (verboseLogging) {
121 | Log.d(Tag, queryResult.toString())
122 | }
123 |
124 | if (queryResult.isSuccessful()) {
125 | networkState.postValue(NetworkState.LOADED)
126 |
127 | searchResults.addAll(queryResult.items)
128 |
129 | callback.onResult(
130 | getItemRange(itemOffset, endIndex)
131 | , if (isLastPage) null else pageIndex + 1L
132 | )
133 |
134 | return@withLock
135 | }
136 |
137 | // Timeout or error, retry request.
138 | if (queryResult.state == QueryResult.State.ERROR) {
139 | networkState.postValue(NetworkState.ERROR)
140 | setRetry { loadAfter(params, callback) }
141 | return@withLock
142 | }
143 |
144 | if (queryResult.state == QueryResult.State.INVALID) {
145 | // Invalid and last page (can't skip), return empty results.
146 | if (isLastPage) {
147 | networkState.postValue(NetworkState.LOADED)
148 | callback.onResult(ArrayList(), null)
149 | return@withLock
150 | }
151 |
152 | // Invalid and not last page, skip to next page.
153 | networkState.postValue(NetworkState.ERROR)
154 | val skipPageParams = LoadParams(params.key + 1, params.requestedLoadSize)
155 | setRetry { loadAfter(skipPageParams, callback) }
156 | }
157 | }
158 | }
159 | }
160 |
161 | override fun loadBefore(
162 | params: LoadParams
163 | , callback: LoadCallback
164 | ) {
165 | // Intentionally left blank..
166 | }
167 |
168 | fun retry() {
169 | retryJob?.start()
170 | }
171 |
172 | private fun setRetry(action: () -> Unit) {
173 | retryJob = launch(parent = rootJob, start = CoroutineStart.LAZY) {
174 | action()
175 | }
176 | }
177 |
178 | override fun invalidate() = runBlocking {
179 | queryJob?.cancel()
180 |
181 | searchResults.clear()
182 | lastPageIndex = 0
183 |
184 | super.invalidate()
185 | }
186 |
187 | private fun getItemRange(
188 | startIndex: Int
189 | , endIndex: Int
190 | ): ArrayList {
191 | val lastItemIndex = Math.max(0, searchResults.size - 1)
192 | val toIndex = Math.min(endIndex, lastItemIndex)
193 |
194 | return if (startIndex > toIndex) {
195 | ArrayList()
196 | } else {
197 | ArrayList(searchResults.subList(startIndex, toIndex))
198 | }
199 | }
200 |
201 | }
202 |
203 |
--------------------------------------------------------------------------------