From 7df32305682e0c273988ac317e6fc0f1dc0c3995 Mon Sep 17 00:00:00 2001 From: Rodrigo Henriques Date: Thu, 8 Jun 2017 22:51:57 -0300 Subject: [PATCH] Set up initial contract to autocomplete places (#1) * improve demo sample to test textView observable * define contract for api and implement initial unit tests with mocks * creating entities to deserialize json data into domain objects * add google maps api with retrofit implementation * create unit tests to cover query window behavior * add requested changes * add .travis.yml * simplify travis.yml * add jdk to travis.yml * handle local.properties absece * solve lint problems * fix travis.yml to accept all licenses * fix travis.yml again * remove constraint layout from dependencies --- .travis.yml | 31 ++++ build.gradle | 14 +- demo/build.gradle | 31 +++- .../a99/rxplaces/ExampleInstrumentedTest.java | 26 --- demo/src/main/AndroidManifest.xml | 8 +- .../java/com/a99/rxplaces/DemoActivity.java | 13 -- .../com/a99/rxplaces/demo/DemoActivity.kt | 55 ++++++ demo/src/main/res/layout/activity_demo.xml | 31 ++-- demo/src/main/res/values/strings.xml | 1 + .../com/a99/rxplaces/ExampleUnitTest.java | 17 -- library/build.gradle | 20 +++ .../a99/rxplaces/ExampleInstrumentedTest.java | 26 --- .../main/kotlin/com/a99/rxplaces/Entities.kt | 42 +++++ .../kotlin/com/a99/rxplaces/GoogleMapsApi.kt | 53 ++++++ .../rxplaces/PlacesAutocompleteRepository.kt | 7 + .../PlacesAutocompleteRepositoryImpl.kt | 26 +++ .../kotlin/com/a99/rxplaces/RxAutocomplete.kt | 79 +++++++++ .../main/kotlin/com/a99/rxplaces/RxPlaces.kt | 12 -- .../kotlin/com/a99/rxplaces/RxTextView.kt | 38 +++++ .../kotlin/com/a99/rxplaces/TextWatchers.kt | 23 +++ .../com/a99/rxplaces/ExampleUnitTest.java | 17 -- .../a99/rxplaces/RxAutocompleteStressTest.kt | 159 ++++++++++++++++++ .../com/a99/rxplaces/TestSchedulerRule.kt | 39 +++++ 23 files changed, 636 insertions(+), 132 deletions(-) create mode 100644 .travis.yml delete mode 100644 demo/src/androidTest/java/com/a99/rxplaces/ExampleInstrumentedTest.java delete mode 100644 demo/src/main/java/com/a99/rxplaces/DemoActivity.java create mode 100644 demo/src/main/java/com/a99/rxplaces/demo/DemoActivity.kt delete mode 100644 demo/src/test/java/com/a99/rxplaces/ExampleUnitTest.java delete mode 100644 library/src/androidTest/java/com/a99/rxplaces/ExampleInstrumentedTest.java create mode 100644 library/src/main/kotlin/com/a99/rxplaces/Entities.kt create mode 100644 library/src/main/kotlin/com/a99/rxplaces/GoogleMapsApi.kt create mode 100644 library/src/main/kotlin/com/a99/rxplaces/PlacesAutocompleteRepository.kt create mode 100644 library/src/main/kotlin/com/a99/rxplaces/PlacesAutocompleteRepositoryImpl.kt create mode 100644 library/src/main/kotlin/com/a99/rxplaces/RxAutocomplete.kt delete mode 100644 library/src/main/kotlin/com/a99/rxplaces/RxPlaces.kt create mode 100644 library/src/main/kotlin/com/a99/rxplaces/RxTextView.kt create mode 100644 library/src/main/kotlin/com/a99/rxplaces/TextWatchers.kt delete mode 100644 library/src/test/java/com/a99/rxplaces/ExampleUnitTest.java create mode 100644 library/src/test/java/com/a99/rxplaces/RxAutocompleteStressTest.kt create mode 100644 library/src/test/java/com/a99/rxplaces/TestSchedulerRule.kt diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a891495 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,31 @@ +language: android + +android: + components: + # Uncomment the lines below if you want to + # use the latest revision of Android SDK Tools + - tools + - build-tools-25.0.3 + - platform-tools + - extra-android-m2repository + - extra-google-android-support + + - android-25 + + licenses: + - android-sdk-license-.+ + - '.+' + + cache: + directories: + - $HOME/.gradle/caches/2.8 + - $HOME/.gradle/caches/jars-1 + - $HOME/.gradle/daemon + - $HOME/.gradle/native + - $HOME/.gradle/wrapper + +jdk: + - oraclejdk8 + +script: + - ./gradlew test \ No newline at end of file diff --git a/build.gradle b/build.gradle index edb9a7a..3bcf6be 100644 --- a/build.gradle +++ b/build.gradle @@ -1,13 +1,23 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.1.2-4' + ext.kotlin_version = '1.1.2-3' ext.rxjava_version = '1.3.0' + ext.rxandroid_version = '1.2.1' + ext.retrofit_version = "2.2.0" + ext.okhttp_ersion = "3.6.0" + ext.support_version = "25.3.1" + ext.junit_version = "4.12" + ext.hamcrest_version = "1.3" + ext.mockito_kotlin_version = "1.5.0" + ext.espresso_version = "2.2.2" + ext.constraintlayout_version = "1.0.2" + repositories { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:2.3.2' + classpath 'com.android.tools.build:gradle:2.3.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/demo/build.gradle b/demo/build.gradle index 7cb9dec..3dfea2e 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -1,16 +1,29 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +String getApiKey() { + try { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + return properties.getProperty('api.key') + } catch (Exception ignored) {} + + return "empty" +} + android { compileSdkVersion 25 buildToolsVersion "25.0.3" defaultConfig { - applicationId "com.a99.rxplaces" + applicationId "com.a99.rxplaces.demo" minSdkVersion 15 + //noinspection OldTargetApi targetSdkVersion 25 versionCode 1 versionName "1.0" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + + buildConfigField "String", "GOOGLE_MAPS_API_KEY", "\"${getApiKey()}\"" } buildTypes { release { @@ -18,17 +31,25 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + + lintOptions { + abortOnError false + } } dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) + compile project(":library") + + compile "io.reactivex:rxandroid:$rxandroid_version" + compile "com.android.support:appcompat-v7:$support_version" + compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" + + testCompile 'junit:junit:4.12' + androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', { exclude group: 'com.android.support', module: 'support-annotations' }) - compile 'com.android.support:appcompat-v7:25.3.1' - compile 'com.android.support.constraint:constraint-layout:1.0.2' - testCompile 'junit:junit:4.12' - compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" } repositories { diff --git a/demo/src/androidTest/java/com/a99/rxplaces/ExampleInstrumentedTest.java b/demo/src/androidTest/java/com/a99/rxplaces/ExampleInstrumentedTest.java deleted file mode 100644 index 5bd2256..0000000 --- a/demo/src/androidTest/java/com/a99/rxplaces/ExampleInstrumentedTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.a99.rxplaces; - -import android.content.Context; -import android.support.test.InstrumentationRegistry; -import android.support.test.runner.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.*; - -/** - * Instrumentation test, which will execute on an Android device. - * - * @see Testing documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - @Test - public void useAppContext() throws Exception { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getTargetContext(); - - assertEquals("com.a99.rxplaces", appContext.getPackageName()); - } -} diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index 2fc6c85..353fe8f 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + xmlns:tools="http://schemas.android.com/tools" + package="com.a99.rxplaces.demo"> + + + android:theme="@style/AppTheme" + tools:ignore="GoogleAppIndexingWarning"> diff --git a/demo/src/main/java/com/a99/rxplaces/DemoActivity.java b/demo/src/main/java/com/a99/rxplaces/DemoActivity.java deleted file mode 100644 index 4ecdbd2..0000000 --- a/demo/src/main/java/com/a99/rxplaces/DemoActivity.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.a99.rxplaces; - -import android.support.v7.app.AppCompatActivity; -import android.os.Bundle; - -public class DemoActivity extends AppCompatActivity { - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_demo); - } -} diff --git a/demo/src/main/java/com/a99/rxplaces/demo/DemoActivity.kt b/demo/src/main/java/com/a99/rxplaces/demo/DemoActivity.kt new file mode 100644 index 0000000..ac411de --- /dev/null +++ b/demo/src/main/java/com/a99/rxplaces/demo/DemoActivity.kt @@ -0,0 +1,55 @@ +package com.a99.rxplaces.demo + +import android.os.Bundle +import android.support.v7.app.AppCompatActivity +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import com.a99.rxplaces.AutocompleteState +import com.a99.rxplaces.Prediction +import com.a99.rxplaces.RxAutocomplete +import rx.android.schedulers.AndroidSchedulers + +class DemoActivity : AppCompatActivity() { + + val editText by lazy { findViewById(R.id.input) as EditText } + val loading: View by lazy { findViewById(R.id.loading) } + val outputContainer by lazy { findViewById(R.id.outputContainer) as LinearLayout } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_demo) + + val rxAutocomplete = RxAutocomplete.create(BuildConfig.GOOGLE_MAPS_API_KEY) + + rxAutocomplete.stateStream() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { autocompleteState -> + loading.visibility = when (autocompleteState) { + AutocompleteState.QUERYING -> VISIBLE + else -> GONE + } + } + + rxAutocomplete.observe(editText) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { showData(it) } + } + + private fun showData(predictions: List) { + outputContainer.removeAllViews() + + predictions + .map { createTextView(it) } + .forEach { outputContainer.addView(it) } + } + + private fun createTextView(it: Prediction): TextView { + val textView = TextView(this) + textView.text = it.description + return textView + } +} diff --git a/demo/src/main/res/layout/activity_demo.xml b/demo/src/main/res/layout/activity_demo.xml index 70d2a84..42ffccc 100644 --- a/demo/src/main/res/layout/activity_demo.xml +++ b/demo/src/main/res/layout/activity_demo.xml @@ -1,18 +1,25 @@ - + android:orientation="vertical"> - + + - - + android:visibility="gone"/> + + + + diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml index 4afd5ec..2683d87 100644 --- a/demo/src/main/res/values/strings.xml +++ b/demo/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ RxPlaces + Where do you want to go? diff --git a/demo/src/test/java/com/a99/rxplaces/ExampleUnitTest.java b/demo/src/test/java/com/a99/rxplaces/ExampleUnitTest.java deleted file mode 100644 index 3390318..0000000 --- a/demo/src/test/java/com/a99/rxplaces/ExampleUnitTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.a99.rxplaces; - -import org.junit.Test; - -import static org.junit.Assert.*; - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() throws Exception { - assertEquals(4, 2 + 2); - } -} \ No newline at end of file diff --git a/library/build.gradle b/library/build.gradle index d5ad6e4..28c4eda 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -7,6 +7,7 @@ android { defaultConfig { minSdkVersion 15 + //noinspection OldTargetApi targetSdkVersion 25 versionCode 1 versionName "1.0" @@ -26,12 +27,31 @@ android { main.java.srcDirs += 'src/main/kotlin' test.java.srcDirs += 'src/test/kotlin' } + + lintOptions { + disable 'InvalidPackage' + } } dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) + compile "com.android.support:support-annotations:$support_version" compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" compile "io.reactivex:rxjava:$rxjava_version" + compile "io.reactivex:rxandroid:$rxandroid_version" + compile "com.squareup.okhttp3:logging-interceptor:$okhttp_ersion" + compile "com.squareup.retrofit2:retrofit:$retrofit_version" + compile "com.squareup.retrofit2:converter-gson:$retrofit_version" + compile "com.squareup.retrofit2:adapter-rxjava:$retrofit_version" + + androidTestCompile("com.android.support.test.espresso:espresso-core:$espresso_version", { + exclude group: 'com.android.support', module: 'support-annotations' + }) + + testCompile "junit:junit:$junit_version" + testCompile "org.hamcrest:hamcrest-core:$hamcrest_version" + testCompile "org.hamcrest:hamcrest-library:$hamcrest_version" + testCompile "com.nhaarman:mockito-kotlin-kt1.1:$mockito_kotlin_version" } repositories { mavenCentral() diff --git a/library/src/androidTest/java/com/a99/rxplaces/ExampleInstrumentedTest.java b/library/src/androidTest/java/com/a99/rxplaces/ExampleInstrumentedTest.java deleted file mode 100644 index b07b453..0000000 --- a/library/src/androidTest/java/com/a99/rxplaces/ExampleInstrumentedTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.a99.rxplaces; - -import android.content.Context; -import android.support.test.InstrumentationRegistry; -import android.support.test.runner.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.*; - -/** - * Instrumentation test, which will execute on an Android device. - * - * @see Testing documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - @Test - public void useAppContext() throws Exception { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getTargetContext(); - - assertEquals("com.a99.rxplaces.test", appContext.getPackageName()); - } -} diff --git a/library/src/main/kotlin/com/a99/rxplaces/Entities.kt b/library/src/main/kotlin/com/a99/rxplaces/Entities.kt new file mode 100644 index 0000000..fe27d67 --- /dev/null +++ b/library/src/main/kotlin/com/a99/rxplaces/Entities.kt @@ -0,0 +1,42 @@ +package com.a99.rxplaces + +import com.google.gson.annotations.SerializedName + +data class Prediction( + val id: String, + val description: String, + @SerializedName("place_id") + val placeId: String, + val reference: String, + val terms: List = listOf(), + val types: List = listOf(), + @SerializedName("matched_substrings") + val matchedSubstrings: List = listOf(), + @SerializedName("structured_formatting") + val structured_formatting: StructuredFormatting = StructuredFormatting("") +) + +data class Term( + val value: String, + val offset: Int +) + +data class MatchedSubstring( + val offset: Int, + val length: Int +) + +data class StructuredFormatting( + val main_text: String, + val main_text_matched_substrings: List = listOf(), + val secondary_text: String = "" +) + +data class PlaceAutocompleteResponse( + val status: String, + val predictions: List +) + +enum class AutocompleteState { + QUERYING, SUCCESS, FAILURE +} \ No newline at end of file diff --git a/library/src/main/kotlin/com/a99/rxplaces/GoogleMapsApi.kt b/library/src/main/kotlin/com/a99/rxplaces/GoogleMapsApi.kt new file mode 100644 index 0000000..f2f5650 --- /dev/null +++ b/library/src/main/kotlin/com/a99/rxplaces/GoogleMapsApi.kt @@ -0,0 +1,53 @@ +package com.a99.rxplaces + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import retrofit2.http.Query +import rx.Single + +interface GoogleMapsApi { + @GET("/maps/api/place/autocomplete/json") + fun getPlaceAutocomplete( + @Query("key") key: String, + @Query("input") input: String, + @Query("offset") offset: Int? = null, + @Query("location") location: String? = null, + @Query("radius") radius: Int? = null, + @Query("language") language: String? = null, + @Query("types") types: String? = null, + @Query("components") components: String? = null, + @Query("strictbounds") strictBounds: Boolean? = null + ): Single + + companion object { + const val URL = "https://maps.googleapis.com" + + fun create(): GoogleMapsApi { + val httpLoggingInterceptor = HttpLoggingInterceptor() + + httpLoggingInterceptor.level = when { + BuildConfig.DEBUG -> HttpLoggingInterceptor.Level.BODY + else -> HttpLoggingInterceptor.Level.NONE + } + + val client = OkHttpClient.Builder() + .addInterceptor(httpLoggingInterceptor) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(URL) + .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .client(client) + .build() + + val api = retrofit.create(GoogleMapsApi::class.java) + + return api + } + } +} \ No newline at end of file diff --git a/library/src/main/kotlin/com/a99/rxplaces/PlacesAutocompleteRepository.kt b/library/src/main/kotlin/com/a99/rxplaces/PlacesAutocompleteRepository.kt new file mode 100644 index 0000000..ca1c999 --- /dev/null +++ b/library/src/main/kotlin/com/a99/rxplaces/PlacesAutocompleteRepository.kt @@ -0,0 +1,7 @@ +package com.a99.rxplaces + +import rx.Single + +interface PlacesAutocompleteRepository { + fun query(input: String, types: Array, components: Array) : Single> +} \ No newline at end of file diff --git a/library/src/main/kotlin/com/a99/rxplaces/PlacesAutocompleteRepositoryImpl.kt b/library/src/main/kotlin/com/a99/rxplaces/PlacesAutocompleteRepositoryImpl.kt new file mode 100644 index 0000000..5350fae --- /dev/null +++ b/library/src/main/kotlin/com/a99/rxplaces/PlacesAutocompleteRepositoryImpl.kt @@ -0,0 +1,26 @@ +package com.a99.rxplaces + +import rx.Single + +internal class PlacesAutocompleteRepositoryImpl +constructor(val apiKey: String, val googleMapsApi: GoogleMapsApi) : PlacesAutocompleteRepository { + + override fun query( + input: String, + types: Array, + components: Array + ): Single> { + return googleMapsApi.getPlaceAutocomplete(apiKey, input) + .flatMap { (status, predictions) -> + if (status == STATUS_OK) { + return@flatMap Single.fromCallable { predictions } + } else { + return@flatMap Single.error>(Exception("Failure with status $status")) + } + } + } + + companion object { + private const val STATUS_OK = "OK" + } +} diff --git a/library/src/main/kotlin/com/a99/rxplaces/RxAutocomplete.kt b/library/src/main/kotlin/com/a99/rxplaces/RxAutocomplete.kt new file mode 100644 index 0000000..1ab04e8 --- /dev/null +++ b/library/src/main/kotlin/com/a99/rxplaces/RxAutocomplete.kt @@ -0,0 +1,79 @@ +package com.a99.rxplaces + +import android.support.annotation.VisibleForTesting +import android.util.Log +import android.widget.TextView +import rx.Observable +import rx.Scheduler +import rx.schedulers.Schedulers +import rx.subjects.PublishSubject +import java.util.concurrent.TimeUnit + +class RxAutocomplete internal constructor( + val scheduler: Scheduler, + val repository: PlacesAutocompleteRepository, + val logger: (String, String) -> Unit = { _, _ -> }) { + + var minKeyStroke: Int = 3 + var queryInterval: Pair = 2L to TimeUnit.SECONDS + + private val autocompleteStateSubject = PublishSubject.create() + + fun stateStream(): Observable { + return autocompleteStateSubject.onBackpressureLatest() + } + + fun observe( + textView: TextView, + types: Array = arrayOf("address"), + components: Array = arrayOf("country:br")): Observable> { + + val dataSource = RxTextView.textChanges(textView) + .map { it.toString() } + + return observe(dataSource, types, components) + } + + fun observe( + dataSource: Observable, + types: Array = arrayOf("address"), + components: Array = arrayOf("country:br")): Observable> { + + return dataSource + .observeOn(scheduler) + .filter { it.length > minKeyStroke } + .doOnNext { logger("RxAutocomplete", "Received: $it") } + .buffer(queryInterval.first, queryInterval.second) + .filter { it.isNotEmpty() } + .map { it.last() } + .concatMap { input -> + repository.query(input, types, components) + .doOnSubscribe { logger("RxAutocomplete", "START QUERY: $input") } + .doOnSubscribe { autocompleteStateSubject.onNext(AutocompleteState.QUERYING) } + .doOnSuccess { autocompleteStateSubject.onNext(AutocompleteState.SUCCESS) } + .doOnError { autocompleteStateSubject.onNext(AutocompleteState.FAILURE) } + .toObservable() + .onErrorResumeNext { Observable.empty() } + } + } + + companion object { + fun create( + apiKey: String, + scheduler: Scheduler = Schedulers.io(), + logger: (String, String) -> Unit = { tag, message -> Log.d(tag, message) } + ): RxAutocomplete { + val repository = PlacesAutocompleteRepositoryImpl(apiKey, GoogleMapsApi.create()) + return RxAutocomplete(scheduler, repository, logger) + } + + @VisibleForTesting + internal fun create( + scheduler: Scheduler, + repository: PlacesAutocompleteRepository, + logger: (String, String) -> Unit + ): RxAutocomplete { + return RxAutocomplete(scheduler, repository, logger) + } + } +} diff --git a/library/src/main/kotlin/com/a99/rxplaces/RxPlaces.kt b/library/src/main/kotlin/com/a99/rxplaces/RxPlaces.kt deleted file mode 100644 index 93f5248..0000000 --- a/library/src/main/kotlin/com/a99/rxplaces/RxPlaces.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.a99.rxplaces - -import android.widget.EditText - - -class RxPlaces { - companion object { - fun observe(editText: EditText) { - - } - } -} \ No newline at end of file diff --git a/library/src/main/kotlin/com/a99/rxplaces/RxTextView.kt b/library/src/main/kotlin/com/a99/rxplaces/RxTextView.kt new file mode 100644 index 0000000..fa85fe3 --- /dev/null +++ b/library/src/main/kotlin/com/a99/rxplaces/RxTextView.kt @@ -0,0 +1,38 @@ +package com.a99.rxplaces + +import android.widget.TextView +import rx.Observable +import rx.Subscriber +import rx.android.MainThreadSubscription +import rx.android.MainThreadSubscription.verifyMainThread + +class RxTextView { + + companion object { + fun textChanges(textView: TextView): Observable { + val observable = Observable.create { subscriber -> + try { + verifyMainThread() + + val watcher = TextWatchers.from(subscriber) + + textView.addTextChangedListener(watcher) + + subscriber.onUnsubscribe { textView.addTextChangedListener(watcher) } + } catch (t: Throwable) { + subscriber.onError(t) + } + } + + return observable.onBackpressureLatest() + } + + infix fun Subscriber.onUnsubscribe(function: () -> Unit) { + add(object : MainThreadSubscription() { + override fun onUnsubscribe() { + function() + } + }) + } + } +} \ No newline at end of file diff --git a/library/src/main/kotlin/com/a99/rxplaces/TextWatchers.kt b/library/src/main/kotlin/com/a99/rxplaces/TextWatchers.kt new file mode 100644 index 0000000..8d5598f --- /dev/null +++ b/library/src/main/kotlin/com/a99/rxplaces/TextWatchers.kt @@ -0,0 +1,23 @@ +package com.a99.rxplaces + +import android.text.Editable +import android.text.TextWatcher +import rx.Subscriber + +internal class TextWatchers { + companion object { + fun from(subscriber: Subscriber) : TextWatcher { + return object : TextWatcher { + override fun afterTextChanged(s: Editable?) {} + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (!subscriber.isUnsubscribed) { + subscriber.onNext(s) + } + } + } + } + } +} \ No newline at end of file diff --git a/library/src/test/java/com/a99/rxplaces/ExampleUnitTest.java b/library/src/test/java/com/a99/rxplaces/ExampleUnitTest.java deleted file mode 100644 index 3390318..0000000 --- a/library/src/test/java/com/a99/rxplaces/ExampleUnitTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.a99.rxplaces; - -import org.junit.Test; - -import static org.junit.Assert.*; - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() throws Exception { - assertEquals(4, 2 + 2); - } -} \ No newline at end of file diff --git a/library/src/test/java/com/a99/rxplaces/RxAutocompleteStressTest.kt b/library/src/test/java/com/a99/rxplaces/RxAutocompleteStressTest.kt new file mode 100644 index 0000000..343ea9d --- /dev/null +++ b/library/src/test/java/com/a99/rxplaces/RxAutocompleteStressTest.kt @@ -0,0 +1,159 @@ +package com.a99.rxplaces + +import com.nhaarman.mockito_kotlin.any +import com.nhaarman.mockito_kotlin.doReturn +import com.nhaarman.mockito_kotlin.mock +import com.nhaarman.mockito_kotlin.never +import com.nhaarman.mockito_kotlin.times +import com.nhaarman.mockito_kotlin.verify +import org.junit.Rule +import org.junit.Test +import rx.Observable +import rx.Single +import rx.observers.TestSubscriber +import rx.subjects.TestSubject +import java.util.Random +import java.util.concurrent.TimeUnit + +class RxAutocompleteStressTest { + @Rule @JvmField val testSchedulerRule = TestSchedulerRule() + val testScheduler = testSchedulerRule.testScheduler + val repository = mock { + on { + query(any(), any(), any()) + } doReturn Single.fromCallable { listOf() } + } + + @Test + fun manyInputs_samePace() { + // given + val testSubscriber = TestSubscriber() + val words = listOf("avenida brasil", "rua alvorada", "avenida rio branco") + + val from = simulateTyping(Observable.from(words)) + .zipWith(Observable.interval(100, TimeUnit.MILLISECONDS), { word, _ -> word }) + + val rxAutocomplete = createRxAutoComplete(repository) + + rxAutocomplete.observe(from) + .subscribe(testSubscriber) + + // when + shiftTime(10, TimeUnit.SECONDS) + + // then + testSubscriber.assertNoErrors() + testSubscriber.assertValueCount(3) + + verify(repository, times(3)).query(any(), any(), any()) + } + + @Test + fun manyInputs_differentPace_oneQuery() { + // given + val testSubscriber = TestSubscriber() + val testSubject = TestSubject.create(testScheduler) + val rxAutocomplete = createRxAutoComplete(repository) + + rxAutocomplete.observe(testSubject) + .subscribe(testSubscriber) + + // when + testSubject.onNext("aven") + shiftTime(300, TimeUnit.MILLISECONDS) + verify(repository, never()).query(any(), any(), any()) + + testSubject.onNext("aveni") + shiftTime(500, TimeUnit.MILLISECONDS) + verify(repository, never()).query(any(), any(), any()) + + testSubject.onNext("avenida") + shiftTime(600, TimeUnit.MILLISECONDS) + verify(repository, never()).query(any(), any(), any()) + + testSubject.onNext("avenida bra") + shiftTime(600, TimeUnit.MILLISECONDS) + verify(repository).query(any(), any(), any()) + + // then + testSubscriber.assertNoErrors() + testSubscriber.assertValueCount(1) + } + + @Test + fun manyInputs_differentPace_manyQueries() { + // given + val testSubscriber = TestSubscriber() + val testSubject = TestSubject.create(testScheduler) + val rxAutocomplete = createRxAutoComplete(repository) + + rxAutocomplete.observe(testSubject) + .subscribe(testSubscriber) + + // when + testSubject.onNext("aven") + shiftTime(1, TimeUnit.SECONDS) + verify(repository, never()).query(any(), any(), any()) + + testSubject.onNext("aveni") + shiftTime(700, TimeUnit.MILLISECONDS) + verify(repository, never()).query(any(), any(), any()) + + testSubject.onNext("avenida") + shiftTime(500, TimeUnit.MILLISECONDS) + verify(repository).query(any(), any(), any()) + + testSubject.onNext("avenida bra") + shiftTime(2, TimeUnit.SECONDS) + verify(repository, times(2)).query(any(), any(), any()) + + // then + testSubscriber.assertNoErrors() + testSubscriber.assertValueCount(2) + } + + @Test + fun randomInputs_inShortRandomInterval() { + val testSubscriber = TestSubscriber() + val randomInterval = { Random().nextInt(1000).toLong() } + + val randomInputs = Observable.range(1, Int.MAX_VALUE) + .map { "Input $it" } + .delay { Observable.interval(randomInterval(), TimeUnit.MILLISECONDS) } + + val repository = mock { + on { + query(any(), any(), any()) + } doReturn Single.fromCallable { listOf() } + } + + val rxAutocomplete = createRxAutoComplete(repository) + + rxAutocomplete.observe(randomInputs) + .subscribe(testSubscriber) + + // when + shiftTime(10, TimeUnit.SECONDS) + + // then + testSubscriber.assertNoErrors() + testSubscriber.assertValueCount(5) + + verify(repository, times(5)).query(any(), any(), any()) + } + + private fun createRxAutoComplete(repository: PlacesAutocompleteRepository) = RxAutocomplete.create(testScheduler, repository, testSchedulerRule::logger) + + private fun shiftTime(interval: Long, timeUnit: TimeUnit) { + testSchedulerRule.testScheduler.advanceTimeBy(interval, timeUnit) + } + + private fun simulateTyping(words: Observable): Observable { + return words.map { it.toCharArray() } + .concatMap { charArray -> + Observable.from(charArray.toList()) + .map { it.toString() } + .scan { accumulator: String, next: String -> accumulator + next } + } + } +} \ No newline at end of file diff --git a/library/src/test/java/com/a99/rxplaces/TestSchedulerRule.kt b/library/src/test/java/com/a99/rxplaces/TestSchedulerRule.kt new file mode 100644 index 0000000..122c8e0 --- /dev/null +++ b/library/src/test/java/com/a99/rxplaces/TestSchedulerRule.kt @@ -0,0 +1,39 @@ +package com.a99.rxplaces + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import rx.plugins.RxJavaHooks +import rx.schedulers.TestScheduler +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class TestSchedulerRule : TestRule { + val testScheduler = TestScheduler() + + fun logger(tag: String, message: String): Unit { + val formattedDate = Date(testScheduler.now()).format() + System.out.println("$tag @ $formattedDate: $message") + } + + override fun apply(base: Statement, description: Description): Statement { + return object : Statement() { + override fun evaluate() { + RxJavaHooks.setOnIOScheduler { testScheduler } + RxJavaHooks.setOnComputationScheduler { testScheduler } + RxJavaHooks.setOnNewThreadScheduler { testScheduler } + + try { + base.evaluate() + } finally { + RxJavaHooks.reset() + } + } + } + } + + fun Date.format(): String { + return SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(this) + } +} \ No newline at end of file