├── config ├── signing │ ├── fake.p12 │ └── debug.keystore ├── lint │ └── lint.xml ├── proguard │ └── proguard-rules.txt ├── checkstyle │ └── checkstyle.xml └── detekt │ └── detekt.yml ├── settings.gradle ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── scan.gradle.kts ├── wrapper.gradle.kts ├── compile.gradle.kts ├── quality.gradle ├── project-schema.json └── dependencies.gradle.kts ├── gradle.properties ├── src ├── test │ ├── resources │ │ ├── ic_launcher.png │ │ ├── trending_results.json │ │ └── search_results.json │ └── kotlin │ │ ├── burrows │ │ └── apps │ │ │ └── example │ │ │ └── gif │ │ │ ├── data │ │ │ └── rest │ │ │ │ ├── model │ │ │ │ ├── MediaDtoTest.kt │ │ │ │ ├── GifDtoTest.kt │ │ │ │ ├── ResultDtoTest.kt │ │ │ │ ├── RiffsyResponseDtoTest.kt │ │ │ │ └── DtoConstructorTest.kt │ │ │ │ └── repository │ │ │ │ └── RiffsyApiClientTest.kt │ │ │ └── presentation │ │ │ ├── SchedulerProviderTest.kt │ │ │ ├── adapter │ │ │ └── model │ │ │ │ └── ImageInfoModelTest.kt │ │ │ └── main │ │ │ └── MainPresenterTest.kt │ │ └── test │ │ ├── ImmediateSchedulerProvider.kt │ │ └── TestBase.kt ├── main │ ├── res │ │ ├── mipmap-hdpi │ │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ │ └── ic_launcher.png │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.png │ │ ├── values │ │ │ ├── dimens.xml │ │ │ ├── colors.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ ├── menu │ │ │ └── menu_fragment_main.xml │ │ ├── drawable │ │ │ └── ic_search_white_24dp.xml │ │ └── layout │ │ │ ├── list_item.xml │ │ │ ├── activity_main.xml │ │ │ └── dialog_preview.xml │ ├── kotlin │ │ └── burrows │ │ │ └── apps │ │ │ └── example │ │ │ └── gif │ │ │ ├── presentation │ │ │ ├── di │ │ │ │ ├── scope │ │ │ │ │ └── PerActivity.kt │ │ │ │ ├── module │ │ │ │ │ ├── LeakCanaryModule.kt │ │ │ │ │ ├── AppModule.kt │ │ │ │ │ ├── RiffsyModule.kt │ │ │ │ │ └── NetModule.kt │ │ │ │ └── component │ │ │ │ │ ├── AppComponent.kt │ │ │ │ │ └── ActivityComponent.kt │ │ │ ├── IBaseView.kt │ │ │ ├── IBasePresenter.kt │ │ │ ├── IBaseSchedulerProvider.kt │ │ │ ├── adapter │ │ │ │ ├── model │ │ │ │ │ └── ImageInfoModel.kt │ │ │ │ ├── GifItemDecoration.kt │ │ │ │ └── GifAdapter.kt │ │ │ ├── main │ │ │ │ ├── IMainPresenter.kt │ │ │ │ ├── IMainView.kt │ │ │ │ ├── MainPresenter.kt │ │ │ │ └── MainActivity.kt │ │ │ └── SchedulerProvider.kt │ │ │ ├── data │ │ │ └── rest │ │ │ │ ├── model │ │ │ │ ├── GifDto.kt │ │ │ │ ├── ResultDto.kt │ │ │ │ ├── MediaDto.kt │ │ │ │ └── RiffsyResponseDto.kt │ │ │ │ └── repository │ │ │ │ ├── ImageApiRepository.kt │ │ │ │ └── RiffsyApiClient.kt │ │ │ └── App.kt │ ├── AndroidManifest.xml │ └── assets │ │ └── open_source_licenses.html ├── androidTest │ ├── resources │ │ ├── ic_launcher.png │ │ ├── trending_results.json │ │ └── search_results.json │ ├── AndroidManifest.xml │ └── kotlin │ │ ├── test │ │ ├── CustomTestRunner.kt │ │ └── AndroidTestBase.kt │ │ └── burrows │ │ └── apps │ │ └── example │ │ └── gif │ │ └── presentation │ │ ├── main │ │ └── MainActivityTest.kt │ │ └── adapter │ │ └── GifAdapterTest.kt └── debug │ ├── AndroidManifest.xml │ └── kotlin │ └── burrows │ └── apps │ └── example │ └── gif │ └── DebugApp.kt ├── .editorconfig ├── README.md ├── .gitignore ├── .travis.yml ├── gradlew.bat ├── gradlew └── LICENSE /config/signing/fake.p12: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.setName("android-gif-example") 2 | rootProject.setBuildFileName("build.gradle.kts") 3 | -------------------------------------------------------------------------------- /config/signing/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angebagui/android-gif-example/master/config/signing/debug.keystore -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angebagui/android-gif-example/master/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # https://github.com/gradle/kotlin-dsl/releases/tag/v0.8.0 2 | org.gradle.script.lang.kotlin.accessors.auto=true 3 | -------------------------------------------------------------------------------- /src/test/resources/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angebagui/android-gif-example/master/src/test/resources/ic_launcher.png -------------------------------------------------------------------------------- /src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angebagui/android-gif-example/master/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angebagui/android-gif-example/master/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /src/androidTest/resources/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angebagui/android-gif-example/master/src/androidTest/resources/ic_launcher.png -------------------------------------------------------------------------------- /src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angebagui/android-gif-example/master/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angebagui/android-gif-example/master/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angebagui/android-gif-example/master/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 0.5dp 4 | 135dp 5 | 6 | -------------------------------------------------------------------------------- /gradle/scan.gradle.kts: -------------------------------------------------------------------------------- 1 | apply { 2 | plugin("com.gradle.build-scan") 3 | } 4 | 5 | extensions["buildScan"].withGroovyBuilder { 6 | "setLicenseAgreementUrl"("https://gradle.com/terms-of-service") 7 | "setLicenseAgree"("yes") 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/di/scope/PerActivity.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.di.scope 2 | 3 | import javax.inject.Scope 4 | 5 | @Scope 6 | @Retention 7 | annotation class PerActivity 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.3.1-all.zip 6 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/IBaseView.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation 2 | 3 | /** 4 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 5 | */ 6 | interface IBaseView { 7 | fun setPresenter(presenter: T) 8 | } 9 | -------------------------------------------------------------------------------- /gradle/wrapper.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.gradle.api.tasks.wrapper.Wrapper 2 | 3 | task("wrapper") { 4 | description = "Generate Gradle wrapper." 5 | group = "build" 6 | 7 | gradleVersion = "4.3.1" 8 | distributionType = Wrapper.DistributionType.ALL 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/data/rest/model/GifDto.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.data.rest.model 2 | 3 | /** 4 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 5 | */ 6 | data class GifDto( 7 | var url: String? = "", 8 | var preview: String? = "") 9 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/IBasePresenter.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation 2 | 3 | /** 4 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 5 | */ 6 | interface IBasePresenter { 7 | fun subscribe() 8 | fun unsubscribe() 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/data/rest/model/ResultDto.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.data.rest.model 2 | 3 | /** 4 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 5 | */ 6 | data class ResultDto( 7 | var media: List? = emptyList(), 8 | var title: String? = "") 9 | -------------------------------------------------------------------------------- /src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FF448AFF 4 | #FF03A9F4 5 | #FF0288D1 6 | #AA000000 7 | 8 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/data/rest/model/MediaDto.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.data.rest.model 2 | 3 | import com.squareup.moshi.Json 4 | 5 | /** 6 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 7 | */ 8 | data class MediaDto(@field:Json(name = "tinygif") var gif: GifDto? = GifDto()) // Bug in 1.5.0 9 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/IBaseSchedulerProvider.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation 2 | 3 | import io.reactivex.Scheduler 4 | 5 | /** 6 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 7 | */ 8 | interface IBaseSchedulerProvider { 9 | fun io(): Scheduler 10 | fun ui(): Scheduler 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # Change these settings to your own preference 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | 8 | # We recommend you to keep these unchanged 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Gif Example App 4 | Search 5 | Search Gifs 6 | Top Trending Gifs 7 | Gif image 8 | 9 | -------------------------------------------------------------------------------- /src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/adapter/model/ImageInfoModel.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.adapter.model 2 | 3 | /** 4 | * Model for the GifAdapter in order to display the gifs. 5 | * 6 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 7 | */ 8 | data class ImageInfoModel(var url: String? = null, 9 | var previewUrl: String? = null) 10 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/data/rest/model/RiffsyResponseDto.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.data.rest.model 2 | 3 | /** 4 | * Riffsy Api Response. 5 | * eg. https://api.riffsy.com/v1/search?key=LIVDSRZULELA&tag=goodluck&limit=10 6 | * 7 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 8 | */ 9 | data class RiffsyResponseDto( 10 | var results: List? = emptyList(), 11 | var page: Double? = null) 12 | -------------------------------------------------------------------------------- /src/androidTest/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/main/IMainPresenter.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.main 2 | 3 | import burrows.apps.example.gif.presentation.IBasePresenter 4 | 5 | /** 6 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 7 | */ 8 | interface IMainPresenter : IBasePresenter { 9 | fun clearImages() 10 | fun loadTrendingImages(next: Double?) 11 | fun loadSearchImages(searchString: String, next: Double?) 12 | } 13 | -------------------------------------------------------------------------------- /src/androidTest/kotlin/test/CustomTestRunner.kt: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import android.os.Bundle 4 | import android.support.multidex.MultiDex 5 | import android.support.test.runner.AndroidJUnitRunner 6 | 7 | /** 8 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 9 | */ 10 | class CustomTestRunner : AndroidJUnitRunner() { 11 | override fun onCreate(arguments: Bundle?) { 12 | MultiDex.installInstrumentation(context, targetContext) 13 | super.onCreate(arguments) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/test/kotlin/burrows/apps/example/gif/data/rest/model/MediaDtoTest.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.data.rest.model 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.junit.Test 5 | import test.TestBase 6 | 7 | /** 8 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 9 | */ 10 | class MediaDtoTest : TestBase() { 11 | private val TEST_GIF = GifDto() 12 | private var sut = MediaDto().apply { gif = TEST_GIF } 13 | 14 | @Test fun testGetGif() { 15 | assertThat(sut.gif).isEqualTo(TEST_GIF) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/kotlin/test/ImmediateSchedulerProvider.kt: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import burrows.apps.example.gif.presentation.IBaseSchedulerProvider 4 | import io.reactivex.Scheduler 5 | import io.reactivex.schedulers.Schedulers 6 | 7 | /** 8 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 9 | */ 10 | class ImmediateSchedulerProvider : IBaseSchedulerProvider { 11 | override fun io(): Scheduler { 12 | return Schedulers.trampoline() 13 | } 14 | 15 | override fun ui(): Scheduler { 16 | return Schedulers.trampoline() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/res/menu/menu_fragment_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 13 | 14 | -------------------------------------------------------------------------------- /src/main/res/drawable/ic_search_white_24dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/main/IMainView.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.main 2 | 3 | import burrows.apps.example.gif.data.rest.model.RiffsyResponseDto 4 | import burrows.apps.example.gif.presentation.IBaseView 5 | import burrows.apps.example.gif.presentation.adapter.model.ImageInfoModel 6 | 7 | /** 8 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 9 | */ 10 | interface IMainView : IBaseView { 11 | fun clearImages() 12 | fun addImages(response: RiffsyResponseDto) 13 | fun showDialog(imageInfoModel: ImageInfoModel) 14 | fun isActive(): Boolean 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/SchedulerProvider.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation 2 | 3 | import io.reactivex.Scheduler 4 | import io.reactivex.android.schedulers.AndroidSchedulers 5 | import io.reactivex.schedulers.Schedulers 6 | import javax.inject.Inject 7 | 8 | /** 9 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 10 | */ 11 | class SchedulerProvider @Inject constructor() : IBaseSchedulerProvider { 12 | override fun io(): Scheduler { 13 | return Schedulers.io() 14 | } 15 | 16 | override fun ui(): Scheduler { 17 | return AndroidSchedulers.mainThread() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/debug/kotlin/burrows/apps/example/gif/DebugApp.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif 2 | 3 | import android.os.StrictMode 4 | import android.os.StrictMode.ThreadPolicy 5 | import android.os.StrictMode.VmPolicy 6 | 7 | /** 8 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 9 | */ 10 | class DebugApp : App() { 11 | override fun onCreate() { 12 | StrictMode.setThreadPolicy(ThreadPolicy.Builder() 13 | .detectAll() 14 | .penaltyLog() 15 | .build()) 16 | StrictMode.setVmPolicy(VmPolicy.Builder() 17 | .detectAll() 18 | .penaltyLog() 19 | .build()) 20 | 21 | super.onCreate() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/kotlin/burrows/apps/example/gif/data/rest/model/GifDtoTest.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.data.rest.model 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.junit.Test 5 | import test.TestBase 6 | 7 | /** 8 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 9 | */ 10 | class GifDtoTest : TestBase() { 11 | private var sut = GifDto().apply { url = STRING_UNIQUE; preview = STRING_UNIQUE2 } 12 | 13 | @Test fun testGetUrl() { 14 | assertThat(sut.url).isEqualTo(STRING_UNIQUE) 15 | } 16 | 17 | @Test fun testGetPreview() { 18 | assertThat(sut.preview).isEqualTo(STRING_UNIQUE2) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/di/module/LeakCanaryModule.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.di.module 2 | 3 | import android.app.Application 4 | import burrows.apps.example.gif.presentation.di.scope.PerActivity 5 | import com.squareup.leakcanary.LeakCanary 6 | import com.squareup.leakcanary.RefWatcher 7 | import dagger.Module 8 | import dagger.Provides 9 | 10 | /** 11 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 12 | */ 13 | @Module 14 | class LeakCanaryModule { 15 | @Provides @PerActivity fun providesRefWatcher(application: Application): RefWatcher { 16 | return LeakCanary.install(application) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/di/module/AppModule.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.di.module 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import dagger.Module 6 | import dagger.Provides 7 | 8 | import javax.inject.Singleton 9 | 10 | /** 11 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 12 | */ 13 | @Module 14 | class AppModule(val application: Application) { 15 | @Provides @Singleton fun providesApplication(): Application { 16 | return application 17 | } 18 | 19 | @Provides @Singleton fun providesApplicationContext(): Context { 20 | return application.applicationContext 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/test/kotlin/burrows/apps/example/gif/presentation/SchedulerProviderTest.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation 2 | 3 | import io.reactivex.android.schedulers.AndroidSchedulers 4 | import io.reactivex.schedulers.Schedulers 5 | import com.google.common.truth.Truth.assertThat 6 | import org.junit.Test 7 | 8 | /** 9 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 10 | */ 11 | class SchedulerProviderTest { 12 | private val sut = SchedulerProvider() 13 | 14 | @Test fun testIo() { 15 | assertThat(sut.io()).isEqualTo(Schedulers.io()) 16 | } 17 | 18 | @Test fun testUi() { 19 | assertThat(sut.ui()).isEqualTo(AndroidSchedulers.mainThread()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/kotlin/burrows/apps/example/gif/data/rest/model/ResultDtoTest.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.data.rest.model 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.junit.Test 5 | import test.TestBase 6 | 7 | /** 8 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 9 | */ 10 | class ResultDtoTest : TestBase() { 11 | private val TEST_MEDIAS = arrayListOf() 12 | private var sut = ResultDto().apply { media = TEST_MEDIAS; title = STRING_UNIQUE } 13 | 14 | @Test fun testGetMedia() { 15 | assertThat(sut.media).isEqualTo(TEST_MEDIAS) 16 | } 17 | 18 | @Test fun testGetTitle() { 19 | assertThat(sut.title).isEqualTo(STRING_UNIQUE) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/kotlin/burrows/apps/example/gif/data/rest/model/RiffsyResponseDtoTest.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.data.rest.model 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.junit.Test 5 | import test.TestBase 6 | 7 | /** 8 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 9 | */ 10 | class RiffsyResponseDtoTest : TestBase() { 11 | private val TEST_RESULTS = arrayListOf() 12 | private var sut = RiffsyResponseDto().apply { results = TEST_RESULTS; page = DOUBLE_RANDOM } 13 | 14 | @Test fun testGetResults() { 15 | assertThat(sut.results).isEqualTo(TEST_RESULTS) 16 | } 17 | 18 | @Test fun testGetNext() { 19 | assertThat(sut.page).isEqualTo(DOUBLE_RANDOM) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/di/module/RiffsyModule.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.di.module 2 | 3 | import burrows.apps.example.gif.BuildConfig 4 | import burrows.apps.example.gif.data.rest.repository.RiffsyApiClient 5 | import burrows.apps.example.gif.presentation.di.scope.PerActivity 6 | import dagger.Module 7 | import dagger.Provides 8 | import retrofit2.Retrofit 9 | 10 | /** 11 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 12 | */ 13 | @Module 14 | class RiffsyModule { 15 | @Provides @PerActivity fun providesRiffsyApi(retrofit: Retrofit.Builder): RiffsyApiClient { 16 | return retrofit 17 | .baseUrl(BuildConfig.BASE_URL) 18 | .build() 19 | .create(RiffsyApiClient::class.java) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /config/lint/lint.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 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/App.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif 2 | 3 | import android.annotation.SuppressLint 4 | import android.support.multidex.MultiDexApplication 5 | import burrows.apps.example.gif.presentation.di.component.ActivityComponent 6 | import burrows.apps.example.gif.presentation.di.component.AppComponent 7 | 8 | /** 9 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 10 | */ 11 | @SuppressLint("Registered") 12 | open class App : MultiDexApplication() { 13 | lateinit var appComponent: AppComponent 14 | lateinit var activityComponent: ActivityComponent 15 | 16 | override fun onCreate() { 17 | super.onCreate() 18 | 19 | // Setup components 20 | appComponent = AppComponent.init(this) 21 | activityComponent = ActivityComponent.init(appComponent) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/di/component/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.di.component 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import burrows.apps.example.gif.App 6 | import burrows.apps.example.gif.presentation.di.module.AppModule 7 | import dagger.Component 8 | 9 | import javax.inject.Singleton 10 | 11 | /** 12 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 13 | */ 14 | @Singleton 15 | @Component(modules = arrayOf(AppModule::class)) 16 | interface AppComponent { 17 | // Injections 18 | fun inject(app: App) 19 | 20 | // Expose to subgraphs 21 | fun application(): Application 22 | fun context(): Context 23 | 24 | // Setup components dependencies and modules 25 | companion object { 26 | fun init(application: Application): AppComponent { 27 | return DaggerAppComponent.builder() 28 | .appModule(AppModule(application)) 29 | .build() 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/res/layout/list_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 16 | 17 | 26 | 27 | -------------------------------------------------------------------------------- /src/test/kotlin/burrows/apps/example/gif/presentation/adapter/model/ImageInfoModelTest.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.adapter.model 2 | 3 | import nl.jqno.equalsverifier.EqualsVerifier.forClass 4 | import nl.jqno.equalsverifier.Warning 5 | import com.google.common.truth.Truth.assertThat 6 | import org.junit.Test 7 | import test.TestBase 8 | 9 | /** 10 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 11 | */ 12 | class ImageInfoModelTest : TestBase() { 13 | private var sut = ImageInfoModel().apply { url = STRING_UNIQUE; previewUrl = STRING_UNIQUE2 } 14 | 15 | @Test fun testGetUrl() { 16 | assertThat(sut.url).isEqualTo(STRING_UNIQUE) 17 | } 18 | 19 | @Test fun testGetUrlPreview() { 20 | assertThat(sut.previewUrl).isEqualTo(STRING_UNIQUE2) 21 | } 22 | 23 | @Test fun testEqualsGashCode() { 24 | forClass(ImageInfoModel::class.java) 25 | .withPrefabValues(ImageInfoModel::class.java, ImageInfoModel(), ImageInfoModel().apply { url = STRING_UNIQUE }) 26 | .suppress(Warning.NONFINAL_FIELDS) 27 | .verify() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/data/rest/repository/ImageApiRepository.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.data.rest.repository 2 | 3 | import android.content.Context 4 | import burrows.apps.example.gif.R 5 | import com.bumptech.glide.Glide 6 | import com.bumptech.glide.RequestBuilder 7 | import com.bumptech.glide.load.engine.DiskCacheStrategy 8 | import com.bumptech.glide.load.resource.gif.GifDrawable 9 | import com.bumptech.glide.request.RequestOptions 10 | import javax.inject.Inject 11 | 12 | /** 13 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 14 | */ 15 | class ImageApiRepository @Inject constructor(private val context: Context) { 16 | private val imageHeight: Int = context.resources.getDimensionPixelSize(R.dimen.gif_image_width) 17 | private val imageWidth: Int 18 | 19 | init { 20 | this.imageWidth = imageHeight 21 | } 22 | 23 | fun load(url: String?): RequestBuilder { 24 | return Glide.with(context) 25 | .asGif() 26 | .apply(RequestOptions.noTransformation() 27 | .error(R.mipmap.ic_launcher) 28 | .fallback(R.mipmap.ic_launcher) 29 | .override(imageWidth, imageHeight) 30 | .diskCacheStrategy(DiskCacheStrategy.RESOURCE)) 31 | .load(url) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/di/component/ActivityComponent.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.di.component 2 | 3 | import burrows.apps.example.gif.presentation.adapter.GifAdapter 4 | import burrows.apps.example.gif.presentation.di.module.LeakCanaryModule 5 | import burrows.apps.example.gif.presentation.di.module.NetModule 6 | import burrows.apps.example.gif.presentation.di.module.RiffsyModule 7 | import burrows.apps.example.gif.presentation.di.scope.PerActivity 8 | import burrows.apps.example.gif.presentation.main.MainActivity 9 | import dagger.Component 10 | 11 | /** 12 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 13 | */ 14 | @PerActivity 15 | @Component( 16 | dependencies = arrayOf(AppComponent::class), 17 | modules = arrayOf(NetModule::class, RiffsyModule::class, LeakCanaryModule::class)) 18 | interface ActivityComponent { 19 | // Injections 20 | fun inject(mainActivity: MainActivity) 21 | fun inject(gifAdapter: GifAdapter) 22 | 23 | // Setup components dependencies and modules 24 | companion object { 25 | fun init(appComponent: AppComponent): ActivityComponent { 26 | return DaggerActivityComponent.builder() 27 | .appComponent(appComponent) 28 | .build() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/adapter/GifItemDecoration.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.adapter 2 | 3 | import android.graphics.Rect 4 | import android.support.v7.widget.RecyclerView 5 | import android.view.View 6 | 7 | /** 8 | * RecyclerView ItemDecoration for custom space divider. 9 | * 10 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 11 | */ 12 | class GifItemDecoration(private val offSet: Int, private val columns: Int) : RecyclerView.ItemDecoration() { 13 | override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, 14 | state: RecyclerView.State) { 15 | super.getItemOffsets(outRect, view, parent, state) 16 | 17 | val position = parent.getChildLayoutPosition(view) 18 | val dataSize = state.itemCount 19 | 20 | // Apply inner right 21 | if (position % columns < columns - 1) outRect.right = offSet 22 | 23 | // Apply inner left 24 | if (position % columns > 0) outRect.left = offSet 25 | 26 | // Apply top padding 27 | if (position < columns) { 28 | outRect.bottom = 0 // Make the top of the RecyclerView have no padding 29 | } else { 30 | outRect.top = offSet 31 | } 32 | 33 | // Apply bottom padding 34 | if (position >= dataSize || position >= dataSize - columns) { 35 | outRect.bottom = 0 // Make the bottom of the RecyclerView have no padding 36 | } else { 37 | outRect.bottom = offSet 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 16 | 17 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android RecyclerView Gif Example in Kotlin with Kotlin DSL 2 | 3 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0) 4 | [![TravisCI Build](https://img.shields.io/travis/jaredsburrows/android-gif-example/master.svg)](https://travis-ci.org/jaredsburrows/android-gif-example) 5 | [![Coveralls Code Coverage](https://img.shields.io/coveralls/jaredsburrows/android-gif-example/master.svg?label=Code%20Coverage)](https://coveralls.io/github/jaredsburrows/android-gif-example?branch=master) 6 | [![Twitter Follow](https://img.shields.io/twitter/follow/jaredsburrows.svg?style=social)](https://twitter.com/jaredsburrows) 7 | 8 | 9 | Riffsy RecyclerView MVP Grid Example using Dagger 2, Retrofit 2, Moshi, RxJava 2, Junit and Espresso tests with Kotlin DSL! 10 | 11 | 12 | 13 | **Build the APK:** 14 | 15 | $ gradlew assembleDebug 16 | 17 | **Install the APK:** 18 | 19 | $ gradlew installDebug 20 | 21 | **Run the App:** 22 | 23 | $ gradlew runDebug 24 | 25 | ## Testing 26 | 27 | **Run [Junit](http://junit.org/junit4/) Unit Tests:** 28 | 29 | $ gradlew testDebug 30 | 31 | **Run [Espresso](https://developer.android.com/training/testing/ui-testing/espresso-testing.html) Instrumentation Tests:** 32 | 33 | $ gradlew connectedDebugAndroidTest 34 | 35 | ## Reports 36 | 37 | **Generate [JacocoReport](http://www.eclemma.org/jacoco/) Test Coverage Report:** 38 | 39 | $ gradlew jacocoDebugReport 40 | 41 | **Generate [Lint](http://developer.android.com/tools/help/lint.html) Report:** 42 | 43 | $ gradlew lintDebug 44 | -------------------------------------------------------------------------------- /gradle/compile.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.gradle.api.tasks.testing.logging.TestExceptionFormat 2 | 3 | // Turn on all warnings and errors 4 | tasks.withType { 5 | sourceCompatibility = rootProject.extra["javaVersion"] as String 6 | targetCompatibility = rootProject.extra["javaVersion"] as String 7 | 8 | // Show all warnings except boot classpath 9 | options.apply { 10 | compilerArgs.apply { 11 | add("-Xlint:all") // Turn on all warnings 12 | add("-Xlint:-options") // Turn off "missing" bootclasspath warning 13 | add("-Xlint:-path") // Turn off warning - annotation processing 14 | add("-Xlint:-processing") // Turn off warning about not claiming annotations 15 | add("-Xdiags:verbose") // Turn on verbose errors 16 | add("-Werror") // Turn warnings into errors 17 | } 18 | encoding = "utf-8" 19 | isIncremental = true 20 | isFork = true 21 | } 22 | } 23 | 24 | // Turn on logging for all tests, filter to show failures/skips only 25 | tasks.withType { 26 | testLogging { 27 | exceptionFormat = TestExceptionFormat.FULL 28 | showCauses = true 29 | showExceptions = true 30 | showStackTraces = true 31 | events("failed", "skipped") 32 | } 33 | 34 | val maxWorkerCount = gradle.startParameter.maxWorkerCount 35 | maxParallelForks = if (maxWorkerCount < 2) 1 else maxWorkerCount / 2 36 | } 37 | 38 | tasks.all { 39 | when (this) { 40 | is JavaForkOptions -> { 41 | // should improve memory on a 64bit JVM 42 | jvmArgs("-XX:+UseCompressedOops") 43 | // should avoid GradleWorkerMain to steal focus 44 | jvmArgs("-Djava.awt.headless=true") 45 | jvmArgs("-Dapple.awt.UIElement=true") 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 15 | 16 | 20 | 21 | 28 | 29 | 30 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/main/res/layout/dialog_preview.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 19 | 20 | 26 | 27 | 36 | 37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/test/kotlin/test/TestBase.kt: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import okhttp3.mockwebserver.MockResponse 4 | import org.junit.After 5 | import org.junit.Before 6 | import java.io.InputStreamReader 7 | import java.net.HttpURLConnection.HTTP_OK 8 | import java.util.Random 9 | import java.util.UUID.randomUUID 10 | 11 | /** 12 | * JUnit Tests. 13 | * 14 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 15 | */ 16 | @Suppress("unused") 17 | abstract class TestBase { 18 | companion object { 19 | const val MOCK_SERVER_PORT = 8080 20 | const val NUMBER_NEGATIVE_ONE = -1 21 | const val NUMBER_ZERO = 0 22 | const val NUMBER_ONE = 1 23 | const val STRING_EMPTY = "" 24 | @JvmField val STRING_NULL: String? = null 25 | @JvmField val STRING_UNIQUE = randomUUID().toString() 26 | @JvmField val STRING_UNIQUE2 = randomUUID().toString() + randomUUID().toString() 27 | @JvmField val STRING_UNIQUE3 = randomUUID().toString() 28 | @JvmField val INTEGER_RANDOM: Int = Random().nextInt() 29 | @JvmField val INTEGER_RANDOM_POSITIVE: Int = Random().nextInt(Integer.SIZE - 1) 30 | @JvmField val LONG_RANDOM: Long = Random().nextLong() 31 | @JvmField val INT_RANDOM: Int = Random().nextInt() 32 | @JvmField val DOUBLE_RANDOM: Double = Random().nextDouble() 33 | 34 | @JvmStatic fun getMockResponse(fileName: String): MockResponse { 35 | return MockResponse() 36 | .setStatus("HTTP/1.1 200") 37 | .setResponseCode(HTTP_OK) 38 | .setBody(parseText(fileName)) 39 | .addHeader("Content-type: application/json; charset=utf-8") 40 | } 41 | 42 | @JvmStatic private fun parseText(fileName: String): String { 43 | val inputStream = TestBase::class.java.getResourceAsStream(fileName) 44 | val text = InputStreamReader(inputStream).readText() 45 | inputStream.close() 46 | return text 47 | } 48 | } 49 | 50 | @Before open fun setUp() { 51 | } 52 | 53 | @After open fun tearDown() { 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/data/rest/repository/RiffsyApiClient.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.data.rest.repository 2 | 3 | import burrows.apps.example.gif.data.rest.model.RiffsyResponseDto 4 | import io.reactivex.Observable 5 | import retrofit2.http.GET 6 | import retrofit2.http.Query 7 | 8 | /** 9 | * Riffsy Api Service for getting "trending" and "search" api getResults. 10 | * 11 | * Custom Api interfaces for the Riffsy Api. 12 | * 13 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 14 | */ 15 | interface RiffsyApiClient { 16 | companion object { 17 | const val DEFAULT_LIMIT_COUNT = 24 18 | private const val API_KEY = "LIVDSRZULELA" 19 | } 20 | 21 | /** 22 | * Get trending gif results. 23 | * 24 | * URL: https://api.riffsy.com/ 25 | * Path: /v1/trending 26 | * Query: limit 27 | * Query: key 28 | * Query: pos 29 | * eg. https://api.riffsy.com/v1/trending?key=LIVDSRZULELA&limit=10&pos=1 30 | * 31 | * @param limit Limit getResults. 32 | * @param pos Position of getResults. 33 | * @return Response of trending getResults. 34 | */ 35 | @GET("/v1/trending?key=$API_KEY") fun getTrendingResults(@Query("limit") limit: Int, 36 | @Query("pos") pos: Double?): Observable // Allow passing null 37 | 38 | /** 39 | * Get search gif results by a search string. 40 | * 41 | * URL: https://api.riffsy.com/ 42 | * Path: /v1/search 43 | * Query: q 44 | * Query: limit 45 | * Query: key 46 | * Query: pos 47 | * eg. https://api.riffsy.com/v1/search?key=LIVDSRZULELA&tag=goodluck&limit=10&pos=1 48 | * 49 | * @param tag Search string to find gifs. 50 | * @param limit Limit getResults. 51 | * @param pos Position of getResults. 52 | * @return Response of search getResults. 53 | */ 54 | @GET("/v1/search?key=$API_KEY") fun getSearchResults(@Query("tag") tag: String, 55 | @Query("limit") limit: Int, 56 | @Query("pos") pos: Double?): Observable // Allow passing null 57 | } 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io 2 | 3 | ### Android ### 4 | # Built application files 5 | *.apk 6 | *.ap_ 7 | 8 | # Files for the Dalvik VM 9 | *.dex 10 | 11 | # Java class files 12 | *.class 13 | 14 | # Generated files 15 | bin/ 16 | gen/ 17 | 18 | # Gradle files 19 | .gradle/ 20 | build/ 21 | /*/build/ 22 | 23 | # Local configuration file (sdk path, etc) 24 | local.properties 25 | 26 | # Log Files 27 | *.log 28 | 29 | ### Android Patch ### 30 | gen-external-apklibs 31 | 32 | 33 | ### Intellij ### 34 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 35 | 36 | *.iml 37 | 38 | ## Directory-based project format: 39 | .idea/ 40 | # if you remove the above rule, at least ignore the following: 41 | 42 | # User-specific stuff: 43 | # .idea/workspace.xml 44 | # .idea/tasks.xml 45 | # .idea/dictionaries 46 | 47 | # Sensitive or high-churn files: 48 | # .idea/dataSources.ids 49 | # .idea/dataSources.xml 50 | # .idea/sqlDataSources.xml 51 | # .idea/dynamic.xml 52 | # .idea/uiDesigner.xml 53 | 54 | # Gradle: 55 | # .idea/gradle.xml 56 | # .idea/libraries 57 | 58 | # Mongo Explorer plugin: 59 | # .idea/mongoSettings.xml 60 | 61 | ## File-based project format: 62 | *.ipr 63 | *.iws 64 | 65 | ## Plugin-specific files: 66 | 67 | # IntelliJ 68 | out/ 69 | 70 | # mpeltonen/sbt-idea plugin 71 | .idea_modules/ 72 | 73 | # JIRA plugin 74 | atlassian-ide-plugin.xml 75 | 76 | # Crashlytics plugin (for Android Studio and IntelliJ) 77 | com_crashlytics_export_strings.xml 78 | crashlytics.properties 79 | crashlytics-build.properties 80 | 81 | 82 | ### Java ### 83 | *.class 84 | 85 | # Mobile Tools for Java (J2ME) 86 | .mtj.tmp/ 87 | 88 | # Package Files # 89 | *.jar 90 | *.war 91 | *.ear 92 | 93 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 94 | hs_err_pid* 95 | 96 | 97 | ### Gradle ### 98 | .gradle 99 | build/ 100 | 101 | # Ignore Gradle GUI config 102 | gradle-app.setting 103 | 104 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 105 | !gradle-wrapper.jar 106 | 107 | .DS_Store 108 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: android 2 | 3 | env: 4 | global: 5 | - ADB_INSTALL_TIMEOUT=8 6 | 7 | jdk: 8 | - oraclejdk8 9 | 10 | before_install: 11 | # Download SDK 12 | - echo yes | sdkmanager "tools" &>/dev/null 13 | - echo yes | sdkmanager "platform-tools" &>/dev/null 14 | - echo yes | sdkmanager "build-tools;27.0.1" &>/dev/null 15 | - echo yes | sdkmanager "platforms;android-27" &>/dev/null 16 | # Download emulator 17 | - echo yes | sdkmanager "platforms;android-19" &>/dev/null 18 | - echo yes | sdkmanager "system-images;android-19;default;armeabi-v7a" &>/dev/null 19 | # Update remaining dependencies and accept licenses 20 | - echo yes | sdkmanager --update &>/dev/null 21 | - echo yes | sdkmanager --licenses &>/dev/null 22 | # Setup emulator 23 | - echo no | avdmanager create avd --force -n test -k "system-images;android-19;default;armeabi-v7a" &>/dev/null 24 | - $ANDROID_HOME/emulator/emulator -avd test -no-audio -no-window & &>/dev/null 25 | 26 | install: 27 | # Build debug and release(run proguard) 28 | - ./gradlew clean assemble assembleDebugAndroidTest -Pci --stacktrace --scan 29 | 30 | before_script: 31 | # Make sure the emulator is available 32 | - android-wait-for-emulator 33 | 34 | # Turn off animations 35 | - adb shell settings put global window_animation_scale 0 & 36 | - adb shell settings put global transition_animation_scale 0 & 37 | - adb shell settings put global animator_duration_scale 0 & 38 | 39 | # For Multidex issue for devices API < 19 40 | - adb shell setprop dalvik.vm.dexopt-flags v=n,o=v 41 | - adb shell stop installd 42 | - adb shell start installd 43 | 44 | # Wake up 45 | - adb shell input keyevent 82 & 46 | 47 | script: 48 | # Run all checks 49 | - ./gradlew testDebug connectedDebugAndroidTest createDebugCoverageReport jacocoDebugReport coveralls -Pci --stacktrace --scan 50 | 51 | branches: 52 | except: 53 | - gh-pages 54 | 55 | sudo: false 56 | 57 | before_cache: 58 | - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock 59 | - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ 60 | 61 | cache: 62 | directories: 63 | - $HOME/.gradle/caches/ 64 | - $HOME/.gradle/wrapper/ 65 | - $HOME/.android/build-cache 66 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/main/MainPresenter.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.main 2 | 3 | import android.util.Log 4 | import burrows.apps.example.gif.data.rest.model.RiffsyResponseDto 5 | import burrows.apps.example.gif.data.rest.repository.RiffsyApiClient 6 | import burrows.apps.example.gif.presentation.IBaseSchedulerProvider 7 | import io.reactivex.Observable 8 | import io.reactivex.disposables.CompositeDisposable 9 | 10 | /** 11 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 12 | */ 13 | class MainPresenter(val view: IMainView, 14 | val repository: RiffsyApiClient, 15 | val provider: IBaseSchedulerProvider) : IMainPresenter { 16 | companion object { 17 | private val TAG = MainPresenter::class.java.simpleName // Can't be longer than 23 chars 18 | } 19 | private val disposable = CompositeDisposable() 20 | 21 | init { 22 | this.view.setPresenter(this) 23 | } 24 | 25 | override fun subscribe() {} 26 | 27 | override fun unsubscribe() { 28 | disposable.clear() 29 | } 30 | 31 | override fun clearImages() { 32 | // Clear current data 33 | view.clearImages() 34 | } 35 | 36 | /** 37 | * Load gif trending images. 38 | */ 39 | override fun loadTrendingImages(next: Double?) { 40 | loadImages(repository.getTrendingResults(RiffsyApiClient.DEFAULT_LIMIT_COUNT, next)) 41 | } 42 | 43 | /** 44 | * Search gifs based on user input. 45 | * 46 | * @param searchString User input. 47 | */ 48 | override fun loadSearchImages(searchString: String, next: Double?) { 49 | loadImages(repository.getSearchResults(searchString, RiffsyApiClient.DEFAULT_LIMIT_COUNT, next)) 50 | } 51 | 52 | /** 53 | * Common code for subscription. 54 | * 55 | * @param observable Observable to added to the subscription. 56 | */ 57 | fun loadImages(observable: Observable) { 58 | disposable.add(observable 59 | .subscribeOn(provider.io()) 60 | .observeOn(provider.ui()) 61 | .subscribe({ riffsyResponse -> 62 | if (!view.isActive()) return@subscribe 63 | 64 | // Iterate over data from response and grab the urls 65 | view.addImages(riffsyResponse) 66 | }, { throwable -> 67 | Log.e(TAG, "onError", throwable) // java.lang.UnsatisfiedLinkError - unit tests 68 | })) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/di/module/NetModule.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.di.module 2 | 3 | import android.app.Application 4 | import burrows.apps.example.gif.BuildConfig 5 | import burrows.apps.example.gif.presentation.di.scope.PerActivity 6 | import com.squareup.moshi.Moshi 7 | import com.squareup.moshi.Rfc3339DateJsonAdapter 8 | import dagger.Module 9 | import dagger.Provides 10 | import okhttp3.Cache 11 | import okhttp3.OkHttpClient 12 | import okhttp3.logging.HttpLoggingInterceptor 13 | import okhttp3.logging.HttpLoggingInterceptor.Level 14 | import retrofit2.Retrofit 15 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 16 | import retrofit2.converter.moshi.MoshiConverterFactory 17 | import java.io.File 18 | import java.util.Date 19 | import java.util.concurrent.TimeUnit 20 | 21 | /** 22 | * Creates services based on Retrofit interfaces. 23 | * 24 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 25 | */ 26 | @Module 27 | class NetModule { 28 | companion object { 29 | private const val CLIENT_TIME_OUT = 10L 30 | private const val CLIENT_CACHE_SIZE = 10 * 1024 * 1024L // 10 MiB 31 | private const val CLIENT_CACHE_DIRECTORY = "http" 32 | } 33 | 34 | @Provides @PerActivity fun providesCache(application: Application): Cache { 35 | return Cache(File(application.cacheDir, CLIENT_CACHE_DIRECTORY), CLIENT_CACHE_SIZE) 36 | } 37 | 38 | @Provides @PerActivity fun providesMoshi(): Moshi { 39 | return Moshi.Builder() 40 | .add(Date::class.java, Rfc3339DateJsonAdapter().nullSafe()) 41 | .build() 42 | } 43 | 44 | @Provides @PerActivity fun providesOkHttpClient(cache: Cache): OkHttpClient { 45 | return OkHttpClient.Builder() 46 | .addInterceptor(HttpLoggingInterceptor() 47 | .setLevel(if (BuildConfig.DEBUG) Level.BODY else Level.NONE)) 48 | .connectTimeout(CLIENT_TIME_OUT, TimeUnit.SECONDS) 49 | .writeTimeout(CLIENT_TIME_OUT, TimeUnit.SECONDS) 50 | .readTimeout(CLIENT_TIME_OUT, TimeUnit.SECONDS) 51 | .cache(cache) 52 | .build() 53 | } 54 | 55 | @Provides @PerActivity fun providesRetrofit(moshi: Moshi, 56 | okHttpClient: OkHttpClient): Retrofit.Builder { 57 | return Retrofit.Builder() 58 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 59 | .addConverterFactory(MoshiConverterFactory.create(moshi)) 60 | .client(okHttpClient) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /gradle/quality.gradle: -------------------------------------------------------------------------------- 1 | apply { 2 | plugin("com.android.application") 3 | plugin("jacoco") 4 | plugin("com.github.kt3k.coveralls") 5 | plugin("io.gitlab.arturbosch.detekt") 6 | } 7 | 8 | // Creates tasks based on the application build variant (productFlavor + buildType = variant) 9 | android.applicationVariants.all { variant -> 10 | def variantName = variant.name.capitalize() 11 | def autoGenerated = ['**/R.class', 12 | '**/R$*.class', 13 | '**/BuildConfig.*', 14 | '**/*ViewBinding*.*', 15 | '**/BR.class', 16 | '**/databinding/**', 17 | '**/Companion/**', 18 | '**/*Initializer*.*', 19 | '**/*Test*.*', 20 | '**/*$Lambda$*.*', 21 | '**/*Module.*', 22 | '**/*Dagger*.*', 23 | '**/*MembersInjector*.*', 24 | '**/*_Provide*Factory*.*'] 25 | /** 26 | * Generates Jacoco coverage reports based off the unit tests. 27 | */ 28 | task("jacoco${variantName}Report", type: JacocoReport, dependsOn: "test${variantName}UnitTest") { 29 | group "Reporting" 30 | description "Generate ${variantName} Jacoco coverage reports." 31 | 32 | reports { 33 | xml.enabled = true 34 | html.enabled = true 35 | } 36 | 37 | // variant.javaCompile.source does not work 38 | // traverses from starting point 39 | sourceDirectories = files(android.sourceSets.main.java.srcDirs + android.sourceSets.main.kotlin.srcDirs) 40 | classDirectories = fileTree(dir: "$buildDir/intermediates/classes/debug", excludes: autoGenerated) 41 | executionData = fileTree(dir: "$buildDir", includes: [ 42 | "jacoco/test${variantName}UnitTest.exec", // unit test - test${variantName}UnitTest 43 | "outputs/code-coverage/connected/*coverage.ec" // android test - create${variantName}CoverageReport 44 | ]) 45 | } 46 | } 47 | 48 | detekt { 49 | version = "1.0.0.RC4-3" 50 | profile("main") { 51 | input = "$projectDir/src/main/kotlin" 52 | config = rootProject.file("${project.rootDir}/config/detekt/detekt.yml") 53 | filters = ".*Test.*,.*/resources/.*,.*/tmp/.*" 54 | } 55 | } 56 | 57 | coveralls { 58 | jacocoReportPath = "${buildDir}/reports/jacoco/jacocoDebugReport/jacocoDebugReport.xml" 59 | } 60 | 61 | afterEvaluate { 62 | tasks.findByName("check").dependsOn("detektCheck") 63 | } 64 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/androidTest/kotlin/test/AndroidTestBase.kt: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import okhttp3.mockwebserver.MockResponse 4 | import okio.Buffer 5 | import okio.Okio 6 | import org.junit.After 7 | import org.junit.Before 8 | import java.io.InputStreamReader 9 | import java.net.HttpURLConnection.HTTP_OK 10 | import java.util.Random 11 | import java.util.UUID.randomUUID 12 | 13 | /** 14 | * JUnit Tests. 15 | * 16 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 17 | */ 18 | @Suppress("unused") 19 | abstract class AndroidTestBase { 20 | companion object { 21 | const val MOCK_SERVER_PORT = 8080 22 | const val NUMBER_NEGATIVE_ONE = -1 23 | const val NUMBER_ZERO = 0 24 | const val NUMBER_ONE = 1 25 | const val STRING_EMPTY = "" 26 | @JvmField val STRING_NULL: String? = null 27 | @JvmField val STRING_UNIQUE = randomUUID().toString() 28 | @JvmField val STRING_UNIQUE2 = randomUUID().toString() + randomUUID().toString() 29 | @JvmField val STRING_UNIQUE3 = randomUUID().toString() 30 | @JvmField val INTEGER_RANDOM: Int = Random().nextInt() 31 | @JvmField val INTEGER_RANDOM_POSITIVE: Int = Random().nextInt(Integer.SIZE - 1) 32 | @JvmField val LONG_RANDOM: Long = Random().nextLong() 33 | @JvmField val INT_RANDOM: Int = Random().nextInt() 34 | @JvmField val DOUBLE_RANDOM: Double = Random().nextDouble() 35 | 36 | @JvmStatic fun getMockResponse(fileName: String): MockResponse { 37 | return MockResponse() 38 | .setStatus("HTTP/1.1 200") 39 | .setResponseCode(HTTP_OK) 40 | .setBody(parseText(fileName)) 41 | .addHeader("Content-type: application/json; charset=utf-8") 42 | } 43 | 44 | @JvmStatic private fun parseText(fileName: String): String { 45 | val inputStream = AndroidTestBase::class.java.getResourceAsStream(fileName) 46 | val text = InputStreamReader(inputStream).readText() 47 | inputStream.close() 48 | return text 49 | } 50 | 51 | @JvmStatic fun getMockFileResponse(fileName: String): MockResponse { 52 | return MockResponse() 53 | .setStatus("HTTP/1.1 200") 54 | .setResponseCode(HTTP_OK) 55 | .setBody(parseImage(fileName)) 56 | .addHeader("content-type: image/png") 57 | } 58 | 59 | @JvmStatic private fun parseImage(fileName: String): Buffer { 60 | val inputStream = AndroidTestBase::class.java.getResourceAsStream(fileName) 61 | val source = Okio.source(inputStream) 62 | val result = Buffer() 63 | result.writeAll(source) 64 | inputStream.close() 65 | source.close() 66 | return result 67 | } 68 | } 69 | 70 | @Before open fun setUp() { 71 | } 72 | 73 | @After open fun tearDown() { 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /config/proguard/proguard-rules.txt: -------------------------------------------------------------------------------- 1 | ########################################################################################## 2 | # Kotlin 3 | ########################################################################################## 4 | -dontnote kotlin.** 5 | 6 | ########################################################################################## 7 | # Retrofit 2 8 | # http://square.github.io/retrofit/ 9 | ########################################################################################## 10 | # Platform calls Class.forName on types which do not exist on Android to determine platform. 11 | -dontnote retrofit2.Platform 12 | # Platform used when running on Java 8 VMs. Will not be used at runtime. 13 | -dontwarn retrofit2.Platform$Java8 14 | # Retain generic type information for use by reflection by converters and adapters. 15 | -keepattributes Signature 16 | # Retain declared checked exceptions for use by a Proxy instance. 17 | -keepattributes Exceptions 18 | -dontwarn retrofit2.** 19 | 20 | ########################################################################################## 21 | # Okhttp3 22 | # https://github.com/square/okhttp#proguard 23 | ########################################################################################## 24 | -dontwarn javax.annotation.Nullable 25 | -dontwarn javax.annotation.ParametersAreNonnullByDefault 26 | -dontwarn okhttp3.** 27 | -dontnote okhttp3.** 28 | 29 | ########################################################################################## 30 | # Okio 31 | # https://github.com/square/okio#proguard 32 | ########################################################################################## 33 | -dontwarn javax.annotation.Nullable 34 | -dontwarn javax.annotation.ParametersAreNonnullByDefault 35 | -dontwarn okio.** 36 | 37 | ########################################################################################## 38 | # Moshi 39 | # https://github.com/square/moshi#proguard 40 | ########################################################################################## 41 | -dontwarn okio.** 42 | -dontwarn javax.annotation.Nullable 43 | -dontwarn javax.annotation.ParametersAreNonnullByDefault 44 | -keepclasseswithmembers class * { 45 | @com.squareup.moshi.* ; 46 | } 47 | -keep @com.squareup.moshi.JsonQualifier interface * 48 | -dontnote com.squareup.moshi.** 49 | -keep class burrows.apps.example.gif.data.rest.model.** { *; } 50 | 51 | ########################################################################################## 52 | # Glide 53 | # https://github.com/bumptech/glide#proguard 54 | ########################################################################################## 55 | -keep public class * implements com.bumptech.glide.module.GlideModule 56 | -keep public class * extends com.bumptech.glide.AppGlideModule 57 | -keep public enum com.bumptech.glide.load.resource.bitmap.ImageHeaderParser$** { 58 | **[] $VALUES; 59 | public *; 60 | } 61 | 62 | ########################################################################################## 63 | # Others 64 | ########################################################################################## 65 | -dontnote android.net.http.** 66 | -dontnote org.apache.http.** 67 | -------------------------------------------------------------------------------- /src/test/kotlin/burrows/apps/example/gif/data/rest/model/DtoConstructorTest.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.data.rest.model 2 | 3 | import com.google.common.truth.Truth.assert_ 4 | import com.squareup.moshi.Json 5 | import org.junit.Before 6 | import org.junit.Test 7 | import org.reflections.Reflections 8 | import org.reflections.scanners.FieldAnnotationsScanner 9 | import org.reflections.scanners.MemberUsageScanner 10 | import org.reflections.scanners.MethodAnnotationsScanner 11 | import org.reflections.scanners.MethodParameterNamesScanner 12 | import org.reflections.scanners.MethodParameterScanner 13 | import org.reflections.scanners.SubTypesScanner 14 | import org.reflections.scanners.TypeAnnotationsScanner 15 | import org.reflections.util.ClasspathHelper 16 | import org.reflections.util.ConfigurationBuilder 17 | import org.reflections.util.FilterBuilder 18 | import java.lang.reflect.Modifier 19 | 20 | /** 21 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 22 | */ 23 | class DtoConstructorTest { 24 | private val MODEL_PACKAGE = "burrows.apps.example.gif.data.rest.model" 25 | private val DATA_TRANSPORT_OBJECT = "Dto" 26 | private lateinit var classes: Set> 27 | 28 | @Before fun setUp() { 29 | val reflections = Reflections(ConfigurationBuilder().filterInputsBy( 30 | FilterBuilder().includePackage(MODEL_PACKAGE)) 31 | .setUrls(ClasspathHelper.forClassLoader()) 32 | .setScanners(SubTypesScanner(false), TypeAnnotationsScanner(), 33 | FieldAnnotationsScanner(), MethodAnnotationsScanner(), 34 | MethodParameterScanner(), MethodParameterNamesScanner(), 35 | MemberUsageScanner())) 36 | classes = reflections.getSubTypesOf(Any::class.java) 37 | } 38 | 39 | @Test fun testMakeSureAllDtosHaveNoArgsPublicConstructors() { 40 | classes.forEach { clazz -> 41 | val className = clazz.name 42 | if (className.endsWith(DATA_TRANSPORT_OBJECT) && !hasNoArgConstructor(clazz)) { 43 | assert_().fail("Found no 'no-args' and public constructors in class $className") 44 | } 45 | } 46 | } 47 | 48 | @Test fun testMakeSureAllHaveNoFinalAnnotations() { 49 | // ASSERT 50 | classes.forEach { clazz -> 51 | val className = clazz.name 52 | if (className.endsWith(DATA_TRANSPORT_OBJECT) && hasNoFinalAnnotations(clazz)) { 53 | assert_().fail("Found finalized field with @SerializedNamed annotation in class $className") 54 | } 55 | } 56 | } 57 | 58 | /** 59 | * Make sure each DTO has a single no-args and public constructor: 60 | * public class Blah { 61 | * public Blah() { 62 | * } 63 | * } 64 | */ 65 | private fun hasNoArgConstructor(klass: Class<*>): Boolean { 66 | return klass.declaredConstructors.any { field -> 67 | field.parameterTypes.isEmpty() && Modifier.isPublic(field.modifiers) 68 | } 69 | } 70 | 71 | /** 72 | * None of this: 73 | * @SerializedNamed("blah") 74 | * private final String blah; 75 | */ 76 | private fun hasNoFinalAnnotations(klass: Class<*>): Boolean { 77 | return klass.declaredFields.any { field -> 78 | field.getAnnotation(Json::class.java) != null 79 | && Modifier.isFinal(field.modifiers) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/test/kotlin/burrows/apps/example/gif/presentation/main/MainPresenterTest.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.main 2 | 3 | import burrows.apps.example.gif.data.rest.model.RiffsyResponseDto 4 | import burrows.apps.example.gif.data.rest.repository.RiffsyApiClient 5 | import burrows.apps.example.gif.data.rest.repository.RiffsyApiClient.Companion.DEFAULT_LIMIT_COUNT 6 | import com.nhaarman.mockito_kotlin.any 7 | import com.nhaarman.mockito_kotlin.eq 8 | import com.nhaarman.mockito_kotlin.never 9 | import io.reactivex.Observable 10 | import org.junit.Before 11 | import org.junit.Test 12 | import org.mockito.Mock 13 | import org.mockito.Mockito.`when` 14 | import org.mockito.Mockito.times 15 | import org.mockito.Mockito.verify 16 | import org.mockito.MockitoAnnotations.initMocks 17 | import test.ImmediateSchedulerProvider 18 | import test.TestBase 19 | 20 | /** 21 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 22 | */ 23 | class MainPresenterTest : TestBase() { 24 | private val provider = ImmediateSchedulerProvider() 25 | @Mock private lateinit var view: IMainView 26 | @Mock private lateinit var repository: RiffsyApiClient 27 | private lateinit var sut: MainPresenter 28 | 29 | @Before override fun setUp() { 30 | super.setUp() 31 | 32 | initMocks(this) 33 | 34 | `when`(view.isActive()).thenReturn(true) 35 | } 36 | 37 | @Test fun testLoadTrendingImagesNotActive() { 38 | // Arrange 39 | val next = 0.0 40 | val response = RiffsyResponseDto() 41 | `when`(view.isActive()).thenReturn(false) 42 | sut = MainPresenter(view, repository, provider) 43 | `when`(repository.getTrendingResults(eq(DEFAULT_LIMIT_COUNT), eq(next))) 44 | .thenReturn(Observable.just(response)) 45 | 46 | // Act 47 | sut.loadTrendingImages(next) 48 | 49 | // Assert 50 | verify(view).isActive() 51 | verify(view, times(0)).addImages(eq(response)) 52 | } 53 | 54 | @Test fun testLoadTrendingImagesSuccess() { 55 | // Arrange 56 | val next = 0.0 57 | val response = RiffsyResponseDto() 58 | sut = MainPresenter(view, repository, provider) 59 | `when`(repository.getTrendingResults(eq(DEFAULT_LIMIT_COUNT), eq(next))) 60 | .thenReturn(Observable.just(response)) 61 | 62 | // Act 63 | sut.loadTrendingImages(next) 64 | 65 | // Assert 66 | verify(view).isActive() 67 | verify(view).addImages(eq(response)) 68 | } 69 | 70 | @Test fun testLoadSearchImagesSuccess() { 71 | // Arrange 72 | val searchString = "gifs" 73 | val next = 0.0 74 | val response = RiffsyResponseDto() 75 | sut = MainPresenter(view, repository, provider) 76 | `when`(repository.getSearchResults(eq(searchString), eq(DEFAULT_LIMIT_COUNT), eq(next))) 77 | .thenReturn(Observable.just(response)) 78 | 79 | // Act 80 | sut.loadSearchImages(searchString, next) 81 | 82 | // Assert 83 | verify(view).isActive() 84 | verify(view).addImages(eq(response)) 85 | } 86 | 87 | @Test fun testLoadSearchImagesViewInactive() { 88 | // Arrange 89 | `when`(view.isActive()).thenReturn(false) 90 | sut = MainPresenter(view, repository, provider) 91 | val response = RiffsyResponseDto() 92 | val observable = Observable.just(response) 93 | 94 | // Act 95 | sut.loadImages(observable) 96 | 97 | // Assert 98 | verify(view, never()).addImages(any()) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/androidTest/kotlin/burrows/apps/example/gif/presentation/main/MainActivityTest.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.main 2 | 3 | import android.support.test.espresso.Espresso.onView 4 | import android.support.test.espresso.action.ViewActions.click 5 | import android.support.test.espresso.action.ViewActions.closeSoftKeyboard 6 | import android.support.test.espresso.action.ViewActions.pressBack 7 | import android.support.test.espresso.action.ViewActions.typeText 8 | import android.support.test.espresso.assertion.ViewAssertions.matches 9 | import android.support.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition 10 | import android.support.test.espresso.matcher.RootMatchers.isDialog 11 | import android.support.test.espresso.matcher.ViewMatchers.* 12 | import android.support.test.filters.SmallTest 13 | import android.support.test.rule.ActivityTestRule 14 | import android.support.test.runner.AndroidJUnit4 15 | import android.support.v7.widget.RecyclerView 16 | import burrows.apps.example.gif.R 17 | import okhttp3.mockwebserver.Dispatcher 18 | import okhttp3.mockwebserver.MockResponse 19 | import okhttp3.mockwebserver.MockWebServer 20 | import okhttp3.mockwebserver.RecordedRequest 21 | import org.junit.AfterClass 22 | import org.junit.BeforeClass 23 | import org.junit.Ignore 24 | import org.junit.Rule 25 | import org.junit.Test 26 | import org.junit.runner.RunWith 27 | import test.AndroidTestBase 28 | import java.net.HttpURLConnection.HTTP_NOT_FOUND 29 | 30 | /** 31 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 32 | */ 33 | @SmallTest 34 | @RunWith(AndroidJUnit4::class) 35 | class MainActivityTest : AndroidTestBase() { 36 | companion object { 37 | private val server = MockWebServer() 38 | 39 | @BeforeClass @JvmStatic fun setUpClass() { 40 | server.start(MOCK_SERVER_PORT) 41 | server.setDispatcher(dispatcher) 42 | } 43 | 44 | @AfterClass @JvmStatic fun tearDownClass() { 45 | server.shutdown() 46 | } 47 | 48 | private val dispatcher = object : Dispatcher() { 49 | override fun dispatch(request: RecordedRequest): MockResponse { 50 | return when { 51 | request.path.contains("v1/trending") -> getMockResponse("/trending_results.json") 52 | request.path.contains("v1/search") -> getMockResponse("/search_results.json") 53 | request.path.contains("images") -> getMockFileResponse("/ic_launcher.png") 54 | else -> MockResponse().setResponseCode(HTTP_NOT_FOUND) 55 | } 56 | } 57 | } 58 | } 59 | 60 | @get:Rule val activityRule = ActivityTestRule(MainActivity::class.java, true, false) 61 | 62 | @Ignore // TODO on view 'Animations or transitions are enabled on the target device. For more info check: http://goo.gl/qVu1yV 63 | @Test fun testTrendingThenClickOpenDialog() { 64 | // Act 65 | activityRule.launchActivity(null) 66 | 67 | // Assert 68 | // Select 0, the response only contains 1 item 69 | onView(withId(R.id.recyclerView)) 70 | .perform(actionOnItemAtPosition(0, click())) 71 | onView(withId(R.id.gifDialogImage)) 72 | .inRoot(isDialog()) 73 | .check(matches(isDisplayed())) 74 | onView(withId(R.id.gifDialogImage)) 75 | .inRoot(isDialog()) 76 | .perform(pressBack()) 77 | } 78 | 79 | @Test fun testTrendingResultsThenSearchThenBackToTrending() { 80 | // Act 81 | activityRule.launchActivity(null) 82 | 83 | // Assert 84 | onView(withId(R.id.menu_search)) 85 | .perform(click()) 86 | // android.support.v7.appcompat.R.id.search_src_text sometimes is not found 87 | onView(withHint("Search Gifs")) 88 | .perform(click(), typeText("hello"), closeSoftKeyboard(), pressBack()) 89 | onView(withId(R.id.recyclerView)) 90 | .check(matches(isDisplayed())) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/test/resources/trending_results.json: -------------------------------------------------------------------------------- 1 | { 2 | "results": [ 3 | { 4 | "created": 1454870314.184911, 5 | "url": "http://localhost:8080/vi8i.gif", 6 | "media": [ 7 | { 8 | "nanomp4": { 9 | "url": "http://localhost:8080/videos/4426ee04396194236e08d9ea92d097da/mp4", 10 | "dims": [ 11 | 134, 12 | 134 13 | ], 14 | "duration": 2.8, 15 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 16 | }, 17 | "nanowebm": { 18 | "url": "http://localhost:8080/videos/a9c580354c1cf9c7fe508d833b149a2b/webm", 19 | "dims": [ 20 | 134, 21 | 134 22 | ], 23 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 24 | }, 25 | "tinygif": { 26 | "url": "http://localhost:8080/images/7d95a1f8a8750460a82b04451be26d69/raw", 27 | "dims": [ 28 | 220, 29 | 220 30 | ], 31 | "preview": "http://localhost:8080/images/511fdce5dc8f5f2b88ac2de6c74b92e7/raw", 32 | "size": 254129 33 | }, 34 | "tinymp4": { 35 | "url": "http://localhost:8080/videos/3a1c084de1515358253ccf9de65b13ad/mp4", 36 | "dims": [ 37 | 258, 38 | 258 39 | ], 40 | "duration": 2.8, 41 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 42 | }, 43 | "tinywebm": { 44 | "url": "http://localhost:8080/videos/f744fec958ab37549aced389efb17f26/webm", 45 | "dims": [ 46 | 258, 47 | 258 48 | ], 49 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 50 | }, 51 | "webm": { 52 | "url": "http://localhost:8080/videos/bda518316415c4760fe83ff6c12c03b6/webm", 53 | "dims": [ 54 | 480, 55 | 480 56 | ], 57 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 58 | }, 59 | "gif": { 60 | "url": "http://localhost:8080/images/f54932e6b9553a5538f31a5ddd78a9f3/raw", 61 | "dims": [ 62 | 480, 63 | 480 64 | ], 65 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw", 66 | "size": 3023910 67 | }, 68 | "mp4": { 69 | "url": "http://localhost:8080/videos/13acb847a95f79110ad7d18398aafc3b/mp4", 70 | "dims": [ 71 | 480, 72 | 480 73 | ], 74 | "duration": 2.8, 75 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 76 | }, 77 | "nanogif": { 78 | "url": "http://localhost:8080/images/578079cc0d23a71797f5d407b4613196/raw", 79 | "dims": [ 80 | 90, 81 | 90 82 | ], 83 | "preview": "http://localhost:8080/images/c24f05bea63e87419a895c30b424ab64/raw", 84 | "size": 54199 85 | }, 86 | "loopedmp4": { 87 | "url": "http://localhost:8080/videos/16833746a1b70265a185f74de420c313/mp4", 88 | "dims": [ 89 | 480, 90 | 480 91 | ], 92 | "duration": 8.4, 93 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 94 | } 95 | } 96 | ], 97 | "tags": [], 98 | "shares": 590182, 99 | "itemurl": "http://localhost:8080/view/riff/5039368/miss-you-GIF", 100 | "composite": null, 101 | "hasaudio": false, 102 | "title": "miss you", 103 | "id": "5039368" 104 | } 105 | ], 106 | "next": "1474504590.85657" 107 | } 108 | -------------------------------------------------------------------------------- /src/androidTest/resources/trending_results.json: -------------------------------------------------------------------------------- 1 | { 2 | "results": [ 3 | { 4 | "created": 1454870314.184911, 5 | "url": "http://localhost:8080/vi8i.gif", 6 | "media": [ 7 | { 8 | "nanomp4": { 9 | "url": "http://localhost:8080/videos/4426ee04396194236e08d9ea92d097da/mp4", 10 | "dims": [ 11 | 134, 12 | 134 13 | ], 14 | "duration": 2.8, 15 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 16 | }, 17 | "nanowebm": { 18 | "url": "http://localhost:8080/videos/a9c580354c1cf9c7fe508d833b149a2b/webm", 19 | "dims": [ 20 | 134, 21 | 134 22 | ], 23 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 24 | }, 25 | "tinygif": { 26 | "url": "http://localhost:8080/images/7d95a1f8a8750460a82b04451be26d69/raw", 27 | "dims": [ 28 | 220, 29 | 220 30 | ], 31 | "preview": "http://localhost:8080/images/511fdce5dc8f5f2b88ac2de6c74b92e7/raw", 32 | "size": 254129 33 | }, 34 | "tinymp4": { 35 | "url": "http://localhost:8080/videos/3a1c084de1515358253ccf9de65b13ad/mp4", 36 | "dims": [ 37 | 258, 38 | 258 39 | ], 40 | "duration": 2.8, 41 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 42 | }, 43 | "tinywebm": { 44 | "url": "http://localhost:8080/videos/f744fec958ab37549aced389efb17f26/webm", 45 | "dims": [ 46 | 258, 47 | 258 48 | ], 49 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 50 | }, 51 | "webm": { 52 | "url": "http://localhost:8080/videos/bda518316415c4760fe83ff6c12c03b6/webm", 53 | "dims": [ 54 | 480, 55 | 480 56 | ], 57 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 58 | }, 59 | "gif": { 60 | "url": "http://localhost:8080/images/f54932e6b9553a5538f31a5ddd78a9f3/raw", 61 | "dims": [ 62 | 480, 63 | 480 64 | ], 65 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw", 66 | "size": 3023910 67 | }, 68 | "mp4": { 69 | "url": "http://localhost:8080/videos/13acb847a95f79110ad7d18398aafc3b/mp4", 70 | "dims": [ 71 | 480, 72 | 480 73 | ], 74 | "duration": 2.8, 75 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 76 | }, 77 | "nanogif": { 78 | "url": "http://localhost:8080/images/578079cc0d23a71797f5d407b4613196/raw", 79 | "dims": [ 80 | 90, 81 | 90 82 | ], 83 | "preview": "http://localhost:8080/images/c24f05bea63e87419a895c30b424ab64/raw", 84 | "size": 54199 85 | }, 86 | "loopedmp4": { 87 | "url": "http://localhost:8080/videos/16833746a1b70265a185f74de420c313/mp4", 88 | "dims": [ 89 | 480, 90 | 480 91 | ], 92 | "duration": 8.4, 93 | "preview": "http://localhost:8080/images/3b44a20650e913466d6739fb05698c8c/raw" 94 | } 95 | } 96 | ], 97 | "tags": [], 98 | "shares": 590182, 99 | "itemurl": "http://localhost:8080/view/riff/5039368/miss-you-GIF", 100 | "composite": null, 101 | "hasaudio": false, 102 | "title": "miss you", 103 | "id": "5039368" 104 | } 105 | ], 106 | "next": "1474504590.85657" 107 | } 108 | -------------------------------------------------------------------------------- /src/test/resources/search_results.json: -------------------------------------------------------------------------------- 1 | { 2 | "weburl": "http://localhost:8080/search/hello", 3 | "results": [ 4 | { 5 | "created": 1470833834.230003, 6 | "url": "http://localhost:8080/ytel.gif", 7 | "media": [ 8 | { 9 | "nanomp4": { 10 | "url": "http://localhost:8080/videos/a53597e91c5d5ee989bce52fcb1ff142/mp4", 11 | "dims": [ 12 | 150, 13 | 138 14 | ], 15 | "duration": 2.0, 16 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 17 | }, 18 | "nanowebm": { 19 | "url": "http://localhost:8080/videos/9e54e727e3f615c52f611ce94ccfa400/webm", 20 | "dims": [ 21 | 150, 22 | 138 23 | ], 24 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 25 | }, 26 | "tinygif": { 27 | "url": "http://localhost:8080/images/6088f94e6eb5dd7584dedda0fe1e52e1/raw", 28 | "dims": [ 29 | 220, 30 | 203 31 | ], 32 | "preview": "http://localhost:8080/images/6f2ed339fbdb5c1270e29945ee1f0d77/raw", 33 | "size": 18228 34 | }, 35 | "tinymp4": { 36 | "url": "http://localhost:8080/videos/c927a84e57c7952aaeb4dd125cc898ed/mp4", 37 | "dims": [ 38 | 320, 39 | 296 40 | ], 41 | "duration": 2.0, 42 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 43 | }, 44 | "tinywebm": { 45 | "url": "http://localhost:8080/videos/b69f65ebe587be24c2786f264dde63d3/webm", 46 | "dims": [ 47 | 320, 48 | 296 49 | ], 50 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 51 | }, 52 | "webm": { 53 | "url": "http://localhost:8080/videos/01a8443eb106065b258391878c9f326b/webm", 54 | "dims": [ 55 | 540, 56 | 500 57 | ], 58 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 59 | }, 60 | "gif": { 61 | "url": "http://localhost:8080/images/5b6a39aa00312575583031d2de4edbd4/raw", 62 | "dims": [ 63 | 498, 64 | 461 65 | ], 66 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw", 67 | "size": 985952 68 | }, 69 | "mp4": { 70 | "url": "http://localhost:8080/videos/2127f26330edefc0a04fed944d15bfb1/mp4", 71 | "dims": [ 72 | 540, 73 | 500 74 | ], 75 | "duration": 2.0, 76 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 77 | }, 78 | "nanogif": { 79 | "url": "http://localhost:8080/images/f85bbd038a243b1a21ca3fe8667636dd/raw", 80 | "dims": [ 81 | 97, 82 | 90 83 | ], 84 | "preview": "http://localhost:8080/images/b583e30155de53bbcd43ec91b24f1f1b/raw", 85 | "size": 8338 86 | }, 87 | "loopedmp4": { 88 | "url": "http://localhost:8080/videos/26346a73f80b1e24c03d32599c72be77/mp4", 89 | "dims": [ 90 | 540, 91 | 500 92 | ], 93 | "duration": 6.0, 94 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 95 | } 96 | } 97 | ], 98 | "tags": [], 99 | "shares": 490519, 100 | "itemurl": "http://localhost:8080/view/riff/5793167/hi-GIF", 101 | "composite": null, 102 | "hasaudio": false, 103 | "title": "hi", 104 | "id": "5793167" 105 | } 106 | ], 107 | "next": "1470833834.230003" 108 | } 109 | -------------------------------------------------------------------------------- /src/androidTest/resources/search_results.json: -------------------------------------------------------------------------------- 1 | { 2 | "weburl": "http://localhost:8080/search/hello", 3 | "results": [ 4 | { 5 | "created": 1470833834.230003, 6 | "url": "http://localhost:8080/ytel.gif", 7 | "media": [ 8 | { 9 | "nanomp4": { 10 | "url": "http://localhost:8080/videos/a53597e91c5d5ee989bce52fcb1ff142/mp4", 11 | "dims": [ 12 | 150, 13 | 138 14 | ], 15 | "duration": 2.0, 16 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 17 | }, 18 | "nanowebm": { 19 | "url": "http://localhost:8080/videos/9e54e727e3f615c52f611ce94ccfa400/webm", 20 | "dims": [ 21 | 150, 22 | 138 23 | ], 24 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 25 | }, 26 | "tinygif": { 27 | "url": "http://localhost:8080/images/6088f94e6eb5dd7584dedda0fe1e52e1/raw", 28 | "dims": [ 29 | 220, 30 | 203 31 | ], 32 | "preview": "http://localhost:8080/images/6f2ed339fbdb5c1270e29945ee1f0d77/raw", 33 | "size": 18228 34 | }, 35 | "tinymp4": { 36 | "url": "http://localhost:8080/videos/c927a84e57c7952aaeb4dd125cc898ed/mp4", 37 | "dims": [ 38 | 320, 39 | 296 40 | ], 41 | "duration": 2.0, 42 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 43 | }, 44 | "tinywebm": { 45 | "url": "http://localhost:8080/videos/b69f65ebe587be24c2786f264dde63d3/webm", 46 | "dims": [ 47 | 320, 48 | 296 49 | ], 50 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 51 | }, 52 | "webm": { 53 | "url": "http://localhost:8080/videos/01a8443eb106065b258391878c9f326b/webm", 54 | "dims": [ 55 | 540, 56 | 500 57 | ], 58 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 59 | }, 60 | "gif": { 61 | "url": "http://localhost:8080/images/5b6a39aa00312575583031d2de4edbd4/raw", 62 | "dims": [ 63 | 498, 64 | 461 65 | ], 66 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw", 67 | "size": 985952 68 | }, 69 | "mp4": { 70 | "url": "http://localhost:8080/videos/2127f26330edefc0a04fed944d15bfb1/mp4", 71 | "dims": [ 72 | 540, 73 | 500 74 | ], 75 | "duration": 2.0, 76 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 77 | }, 78 | "nanogif": { 79 | "url": "http://localhost:8080/images/f85bbd038a243b1a21ca3fe8667636dd/raw", 80 | "dims": [ 81 | 97, 82 | 90 83 | ], 84 | "preview": "http://localhost:8080/images/b583e30155de53bbcd43ec91b24f1f1b/raw", 85 | "size": 8338 86 | }, 87 | "loopedmp4": { 88 | "url": "http://localhost:8080/videos/26346a73f80b1e24c03d32599c72be77/mp4", 89 | "dims": [ 90 | 540, 91 | 500 92 | ], 93 | "duration": 6.0, 94 | "preview": "http://localhost:8080/images/21accb6fe0c3d2ab562b9d429c7af88c/raw" 95 | } 96 | } 97 | ], 98 | "tags": [], 99 | "shares": 490519, 100 | "itemurl": "http://localhost:8080/view/riff/5793167/hi-GIF", 101 | "composite": null, 102 | "hasaudio": false, 103 | "title": "hi", 104 | "id": "5793167" 105 | } 106 | ], 107 | "next": "1470833834.230003" 108 | } 109 | -------------------------------------------------------------------------------- /src/main/assets/open_source_licenses.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Open source licenses 5 | 6 | 7 |

Notice for libraries:

8 | 112 | 113 |

Simplified BSD License

114 |
Simplified BSD License, http://www.opensource.org/licenses/bsd-license
115 |
116 |

The Apache Software License, Version 2.0

117 |
The Apache Software License, Version 2.0, http://www.apache.org/licenses/LICENSE-2.0.txt
118 |
119 |

CC0

120 |
CC0, http://creativecommons.org/publicdomain/zero/1.0/
121 | 122 | 123 | -------------------------------------------------------------------------------- /src/test/kotlin/burrows/apps/example/gif/data/rest/repository/RiffsyApiClientTest.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.data.rest.repository 2 | 3 | import burrows.apps.example.gif.data.rest.model.RiffsyResponseDto 4 | import burrows.apps.example.gif.data.rest.repository.RiffsyApiClient.Companion.DEFAULT_LIMIT_COUNT 5 | import io.reactivex.observers.TestObserver 6 | import okhttp3.OkHttpClient 7 | import okhttp3.logging.HttpLoggingInterceptor 8 | import okhttp3.mockwebserver.Dispatcher 9 | import okhttp3.mockwebserver.MockResponse 10 | import okhttp3.mockwebserver.MockWebServer 11 | import okhttp3.mockwebserver.RecordedRequest 12 | import com.google.common.truth.Truth.assertThat 13 | import org.junit.AfterClass 14 | import org.junit.Before 15 | import org.junit.BeforeClass 16 | import org.junit.Test 17 | import retrofit2.Retrofit 18 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 19 | import retrofit2.converter.moshi.MoshiConverterFactory 20 | import test.TestBase 21 | import java.net.HttpURLConnection.HTTP_NOT_FOUND 22 | 23 | /** 24 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 25 | */ 26 | class RiffsyApiClientTest : TestBase() { 27 | companion object { 28 | private val server = MockWebServer() 29 | 30 | @BeforeClass @JvmStatic fun setUpClass() { 31 | server.start(MOCK_SERVER_PORT) 32 | server.setDispatcher(dispatcher) 33 | } 34 | 35 | @AfterClass @JvmStatic fun tearDownClass() { 36 | server.shutdown() 37 | } 38 | 39 | private val dispatcher = object : Dispatcher() { 40 | override fun dispatch(request: RecordedRequest): MockResponse { 41 | return when { 42 | request.path.contains("/v1/trending") -> getMockResponse("/trending_results.json") 43 | request.path.contains("/v1/search") -> getMockResponse("/search_results.json") 44 | else -> MockResponse().setResponseCode(HTTP_NOT_FOUND) 45 | } 46 | } 47 | } 48 | } 49 | 50 | private lateinit var sut: RiffsyApiClient 51 | 52 | @Before override fun setUp() { 53 | super.setUp() 54 | 55 | sut = getRetrofit(server.url("/").toString()).build().create(RiffsyApiClient::class.java) 56 | } 57 | 58 | @Test fun testTrendingResultsUrlShouldParseCorrectly() { 59 | // Arrange 60 | val observer = TestObserver() 61 | 62 | // Act 63 | val observable = sut.getTrendingResults(DEFAULT_LIMIT_COUNT, null) 64 | val response = observable.blockingFirst() 65 | observer.assertNoErrors() 66 | 67 | // Assert 68 | assertThat(response.results?.get(0)?.media?.get(0)?.gif?.url) 69 | .contains("/images/7d95a1f8a8750460a82b04451be26d69/raw") 70 | } 71 | 72 | @Test fun testTrendingResultsUrlPreviewShouldParseCorrectly() { 73 | // Arrange 74 | val observer = TestObserver() 75 | 76 | // Act 77 | val observable = sut.getTrendingResults(DEFAULT_LIMIT_COUNT, null) 78 | val response = observable.blockingFirst() 79 | observer.assertNoErrors() 80 | 81 | // Assert 82 | assertThat(response.results?.get(0)?.media?.get(0)?.gif?.preview) 83 | .contains("/images/511fdce5dc8f5f2b88ac2de6c74b92e7/raw") 84 | } 85 | 86 | @Test fun testSearchResultsUrlShouldParseCorrectly() { 87 | // Arrange 88 | val observer = TestObserver() 89 | 90 | // Act 91 | val observable = sut.getSearchResults("hello", DEFAULT_LIMIT_COUNT, null) 92 | val response = observable.blockingFirst() 93 | observer.assertNoErrors() 94 | 95 | // Assert 96 | assertThat(response.results?.get(0)?.media?.get(0)?.gif?.url) 97 | .contains("/images/6088f94e6eb5dd7584dedda0fe1e52e1/raw") 98 | } 99 | 100 | @Test fun testSearchResultsUrlPreviewShouldParseCorrectly() { 101 | // Arrange 102 | val observer = TestObserver() 103 | 104 | // Act 105 | val observable = sut.getSearchResults("hello", DEFAULT_LIMIT_COUNT, null) 106 | val response = observable.blockingFirst() 107 | observer.assertNoErrors() 108 | 109 | // Assert 110 | assertThat(response.results?.get(0)?.media?.get(0)?.gif?.preview) 111 | .contains("/images/6f2ed339fbdb5c1270e29945ee1f0d77/raw") 112 | } 113 | 114 | private fun getRetrofit(baseUrl: String): Retrofit.Builder { 115 | return Retrofit.Builder() 116 | .baseUrl(baseUrl) 117 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 118 | .addConverterFactory(MoshiConverterFactory.create()) 119 | .client(OkHttpClient.Builder() 120 | .addInterceptor(HttpLoggingInterceptor() 121 | .setLevel(HttpLoggingInterceptor.Level.BODY)) 122 | .build() 123 | ) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /gradle/project-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | ":": { 3 | "conventions": { 4 | "base": "org.gradle.api.plugins.BasePluginConvention", 5 | "java": "org.gradle.api.plugins.JavaPluginConvention" 6 | }, 7 | "configurations": [ 8 | "androidJacocoAgent", 9 | "androidJacocoAnt", 10 | "androidTestAnnotationProcessor", 11 | "androidTestApi", 12 | "androidTestApk", 13 | "androidTestCompile", 14 | "androidTestCompileOnly", 15 | "androidTestDebugAnnotationProcessor", 16 | "androidTestDebugApi", 17 | "androidTestDebugApk", 18 | "androidTestDebugCompile", 19 | "androidTestDebugCompileOnly", 20 | "androidTestDebugImplementation", 21 | "androidTestDebugProvided", 22 | "androidTestDebugRuntimeOnly", 23 | "androidTestDebugWearApp", 24 | "androidTestImplementation", 25 | "androidTestProvided", 26 | "androidTestRuntimeOnly", 27 | "androidTestUtil", 28 | "androidTestWearApp", 29 | "annotationProcessor", 30 | "api", 31 | "apk", 32 | "archives", 33 | "compile", 34 | "compileOnly", 35 | "debugAndroidTestAnnotationProcessorClasspath", 36 | "debugAndroidTestCompileClasspath", 37 | "debugAndroidTestRuntimeClasspath", 38 | "debugAnnotationProcessor", 39 | "debugAnnotationProcessorClasspath", 40 | "debugApi", 41 | "debugApiElements", 42 | "debugApk", 43 | "debugCompile", 44 | "debugCompileClasspath", 45 | "debugCompileOnly", 46 | "debugImplementation", 47 | "debugMetadataElements", 48 | "debugProvided", 49 | "debugRuntimeClasspath", 50 | "debugRuntimeElements", 51 | "debugRuntimeOnly", 52 | "debugUnitTestAnnotationProcessorClasspath", 53 | "debugUnitTestCompileClasspath", 54 | "debugUnitTestRuntimeClasspath", 55 | "debugWearApp", 56 | "debugWearBundling", 57 | "default", 58 | "implementation", 59 | "jacocoAgent", 60 | "jacocoAnt", 61 | "kapt", 62 | "kaptAndroidTest", 63 | "kaptAndroidTestDebug", 64 | "kaptDebug", 65 | "kaptRelease", 66 | "kaptTest", 67 | "kaptTestDebug", 68 | "kaptTestRelease", 69 | "lintChecks", 70 | "provided", 71 | "releaseAnnotationProcessor", 72 | "releaseAnnotationProcessorClasspath", 73 | "releaseApi", 74 | "releaseApiElements", 75 | "releaseApk", 76 | "releaseCompile", 77 | "releaseCompileClasspath", 78 | "releaseCompileOnly", 79 | "releaseImplementation", 80 | "releaseMetadataElements", 81 | "releaseProvided", 82 | "releaseRuntimeClasspath", 83 | "releaseRuntimeElements", 84 | "releaseRuntimeOnly", 85 | "releaseUnitTestAnnotationProcessorClasspath", 86 | "releaseUnitTestCompileClasspath", 87 | "releaseUnitTestRuntimeClasspath", 88 | "releaseWearApp", 89 | "releaseWearBundling", 90 | "runtimeOnly", 91 | "testAnnotationProcessor", 92 | "testApi", 93 | "testApk", 94 | "testCompile", 95 | "testCompileOnly", 96 | "testDebugAnnotationProcessor", 97 | "testDebugApi", 98 | "testDebugApk", 99 | "testDebugCompile", 100 | "testDebugCompileOnly", 101 | "testDebugImplementation", 102 | "testDebugProvided", 103 | "testDebugRuntimeOnly", 104 | "testDebugWearApp", 105 | "testImplementation", 106 | "testProvided", 107 | "testReleaseAnnotationProcessor", 108 | "testReleaseApi", 109 | "testReleaseApk", 110 | "testReleaseCompile", 111 | "testReleaseCompileOnly", 112 | "testReleaseImplementation", 113 | "testReleaseProvided", 114 | "testReleaseRuntimeOnly", 115 | "testReleaseWearApp", 116 | "testRuntimeOnly", 117 | "testWearApp", 118 | "wearApp" 119 | ], 120 | "extensions": { 121 | "ext": "org.gradle.api.plugins.ExtraPropertiesExtension", 122 | "buildScan": "com.gradle.scan.plugin.internal.api.f", 123 | "defaultArtifacts": "org.gradle.api.internal.plugins.DefaultArtifactPublicationSet", 124 | "reporting": "org.gradle.api.reporting.ReportingExtension", 125 | "buildOutputs": "org.gradle.api.NamedDomainObjectContainer", 126 | "android": "com.android.build.gradle.AppExtension", 127 | "kotlin": "org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension", 128 | "kapt": "org.jetbrains.kotlin.gradle.plugin.KaptExtension", 129 | "androidExtensions": "org.jetbrains.kotlin.gradle.internal.AndroidExtensionsExtension", 130 | "play": "de.triplet.gradle.play.PlayPublisherPluginExtension", 131 | "dexcount": "com.getkeepsafe.dexcount.DexMethodCountExtension", 132 | "apkSize": "com.vanniktech.android.apk.size.ApkSizeExtension", 133 | "jacoco": "org.gradle.testing.jacoco.plugins.JacocoPluginExtension", 134 | "coveralls": "org.kt3k.gradle.plugin.CoverallsPluginExtension", 135 | "detekt": "io.gitlab.arturbosch.detekt.extensions.DetektExtension" 136 | } 137 | } 138 | } -------------------------------------------------------------------------------- /gradle/dependencies.gradle.kts: -------------------------------------------------------------------------------- 1 | // common 2 | val androidGradleVersion = "3.0.0" 3 | val kotlinVersion = "1.1.60" 4 | val supportLibraryVersion = "27.0.1" 5 | val daggerVersion = "2.12" // 2.13 classfile error 6 | val okHttpVersion = "3.9.0" 7 | val retrofitVersion = "2.3.0" 8 | val espressoVersion = "3.0.1" 9 | val leakCanaryVersion = "1.5" 10 | val multidexVersion = "1.0.2" 11 | val glideVersion = "4.3.1" 12 | val jacocoVersion = "0.7.4.201502262128" 13 | 14 | // android plugin 15 | extra["minSdkVersion"] = 19 16 | extra["targetSdkVersion"] = 27 17 | extra["compileSdkVersion"] = 27 18 | extra["buildToolsVersion"] = "27.0.1" 19 | extra["javaVersion"] = "1.8" 20 | extra["debugKeystoreUser"] = "androiddebugkey" 21 | extra["debugKeystorePass"] = "android" 22 | 23 | // classpath 24 | extra["gradle"] = "com.android.tools.build:gradle:$androidGradleVersion" 25 | extra["kotlinGradlePlugin"] = "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" 26 | extra["kotlinAndroidExtensions"] = "org.jetbrains.kotlin:kotlin-android-extensions:$kotlinVersion" 27 | extra["gradleAndroidCommandPlugin"] = "com.novoda:gradle-android-command-plugin:1.7.1" 28 | extra["playPublisher"] = "com.github.triplet.gradle:play-publisher:1.2.0" 29 | extra["buildScanPlugin"] = "com.gradle:build-scan-plugin:1.10.2" 30 | extra["dexcountGradlePlugin"] = "com.getkeepsafe.dexcount:dexcount-gradle-plugin:0.8.1" 31 | extra["gradleAndroidApkSizePlugin"] = "com.vanniktech:gradle-android-apk-size-plugin:0.4.0" 32 | extra["coverallsGradlePlugin"] = "org.kt3k.gradle.plugin:coveralls-gradle-plugin:2.8.2" 33 | extra["gradleVersionsPlugin"] = "com.github.ben-manes:gradle-versions-plugin:0.17.0" 34 | extra["gradleLicensePlugin"] = "com.jaredsburrows:gradle-license-plugin:0.7.0" 35 | extra["detektGradlePlugin"] = "gradle.plugin.io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.0.0.RC4-3" 36 | 37 | // implementation 38 | extra["design"] = "com.android.support:design:$supportLibraryVersion" 39 | extra["appcompatv7"] = "com.android.support:appcompat-v7:$supportLibraryVersion" 40 | extra["supportv4"] = "com.android.support:support-v4:$supportLibraryVersion" 41 | extra["recyclerviewv7"] = "com.android.support:recyclerview-v7:$supportLibraryVersion" 42 | extra["cardviewv7"] = "com.android.support:cardview-v7:$supportLibraryVersion" 43 | extra["supportAnnotations"] = "com.android.support:support-annotations:$supportLibraryVersion" 44 | extra["multidex"] = "com.android.support:multidex:$multidexVersion" 45 | extra["multidexInstrumentation"] = "com.android.support:multidex-instrumentation:$multidexVersion" 46 | extra["kotlinStdlib"] = "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" 47 | extra["rxAndroid"] = "io.reactivex.rxjava2:rxandroid:2.0.1" 48 | extra["rxJava"] = "io.reactivex.rxjava2:rxjava:2.1.6" 49 | extra["dagger"] = "com.google.dagger:dagger:$daggerVersion" 50 | extra["okhttp"] = "com.squareup.okhttp3:okhttp:$okHttpVersion" 51 | extra["loggingInterceptor"] = "com.squareup.okhttp3:logging-interceptor:$okHttpVersion" 52 | extra["retrofit"] = "com.squareup.retrofit2:retrofit:$retrofitVersion" 53 | extra["converterMoshi"] = "com.squareup.retrofit2:converter-moshi:$retrofitVersion" 54 | extra["moshiAdapters"] = "com.squareup.moshi:moshi-adapters:1.5.0" 55 | extra["adapterRxjava2"] = "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion" 56 | extra["glide"] = "com.github.bumptech.glide:glide:$glideVersion" 57 | extra["okhttp3Integration"] = "com.github.bumptech.glide:okhttp3-integration:$glideVersion@aar" 58 | 59 | // debugImplementation 60 | extra["leakcanaryAndroid"] = "com.squareup.leakcanary:leakcanary-android:$leakCanaryVersion" 61 | 62 | // releaseImplementation 63 | extra["leakcanaryAndroidNoOp"] = "com.squareup.leakcanary:leakcanary-android-no-op:$leakCanaryVersion" 64 | 65 | // kapt 66 | extra["daggerCompiler"] = "com.google.dagger:dagger-compiler:$daggerVersion" 67 | extra["glideCompiler"] = "com.github.bumptech.glide:compiler:$glideVersion" 68 | 69 | // androidTestImplementation 70 | extra["espressoCore"] = "com.android.support.test.espresso:espresso-core:$espressoVersion" 71 | extra["espressoIntents"] = "com.android.support.test.espresso:espresso-intents:$espressoVersion" 72 | extra["espressoContrib"] = "com.android.support.test.espresso:espresso-contrib:$espressoVersion" 73 | extra["testingSupportLib"] = "com.android.support.test:testing-support-lib:0.1" 74 | extra["runner"] = "com.android.support.test:runner:1.0.1" 75 | 76 | // androidTestUtil 77 | extra["orchestrator"] = "com.android.support.test:orchestrator:1.0.1" 78 | 79 | // testImplementation 80 | extra["junit"] = "junit:junit:4.12" 81 | extra["mockitoInline"] = "org.mockito:mockito-inline:2.12.0" 82 | extra["mockitoKotlin"] = "com.nhaarman:mockito-kotlin-kt1.1:1.5.0" 83 | extra["truth"] = "com.google.truth:truth:0.36" 84 | extra["equalsverifier"] = "nl.jqno.equalsverifier:equalsverifier:2.4" 85 | extra["mockwebserver"] = "com.squareup.okhttp3:mockwebserver:$okHttpVersion" 86 | extra["reflections"] = "org.reflections:reflections:0.9.11" 87 | 88 | // jacocoAgent/androidJacocoAgent 89 | extra["orgJacocoAgent"] = "org.jacoco:org.jacoco.agent:$jacocoVersion" 90 | 91 | // jacocoAnt/androidJacocoAnt 92 | extra["orgJacocoAnt"] = "org.jacoco:org.jacoco.ant:$jacocoVersion" 93 | -------------------------------------------------------------------------------- /src/androidTest/kotlin/burrows/apps/example/gif/presentation/adapter/GifAdapterTest.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.adapter 2 | 3 | import android.content.Context 4 | import android.support.test.InstrumentationRegistry 5 | import android.support.test.annotation.UiThreadTest 6 | import android.support.test.filters.SmallTest 7 | import android.support.test.runner.AndroidJUnit4 8 | import android.view.ViewGroup 9 | import android.widget.LinearLayout 10 | import burrows.apps.example.gif.data.rest.repository.ImageApiRepository 11 | import burrows.apps.example.gif.presentation.adapter.GifAdapter.OnItemClickListener 12 | import burrows.apps.example.gif.presentation.adapter.model.ImageInfoModel 13 | import com.google.common.truth.Truth.assertThat 14 | import org.junit.Before 15 | import org.junit.Test 16 | import org.junit.runner.RunWith 17 | import test.AndroidTestBase 18 | 19 | /** 20 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 21 | */ 22 | @SmallTest 23 | @RunWith(AndroidJUnit4::class) 24 | class GifAdapterTest : AndroidTestBase() { 25 | private val targetContext: Context = InstrumentationRegistry.getTargetContext() 26 | private val imageInfoModel = ImageInfoModel().apply { url = STRING_UNIQUE } 27 | private val imageInfoModel2 = ImageInfoModel().apply { url = STRING_UNIQUE2 } 28 | private val imageInfoModel3 = ImageInfoModel().apply { url = STRING_UNIQUE3 } 29 | private lateinit var onItemClickListener: TestOnItemClickListener 30 | private lateinit var imageApiRepository: ImageApiRepository 31 | private lateinit var viewHolder: GifAdapter.ViewHolder 32 | private lateinit var sut: GifAdapter 33 | 34 | @Before @UiThreadTest override fun setUp() { 35 | super.setUp() 36 | 37 | onItemClickListener = TestOnItemClickListener() 38 | imageApiRepository = ImageApiRepository(targetContext) 39 | sut = GifAdapter(onItemClickListener, imageApiRepository) 40 | sut.add(imageInfoModel) 41 | sut.add(imageInfoModel2) 42 | viewHolder = sut.onCreateViewHolder(LinearLayout(targetContext), 0) 43 | } 44 | 45 | @Test @UiThreadTest fun testOnCreateViewHolder() { 46 | // Arrange 47 | val parent = object : ViewGroup(targetContext) { 48 | override fun onLayout(b: Boolean, i: Int, i1: Int, i2: Int, i3: Int) {} 49 | } 50 | 51 | // Assert 52 | assertThat(sut.onCreateViewHolder(parent, 0)).isInstanceOf(GifAdapter.ViewHolder::class.java) 53 | } 54 | 55 | @Test @UiThreadTest fun testOnBindViewHolderOnAdapterItemClick() { 56 | // Arrange 57 | sut.clear() 58 | sut.add(imageInfoModel) 59 | sut.add(imageInfoModel2) 60 | sut.add(ImageInfoModel()) 61 | 62 | // Act 63 | sut.onBindViewHolder(viewHolder, 0) 64 | 65 | // Assert 66 | assertThat(viewHolder.itemView.performClick()).isTrue() 67 | } 68 | 69 | @Test fun testGetItem() { 70 | // Arrange 71 | sut.clear() 72 | 73 | // Act 74 | val imageInfo = ImageInfoModel() 75 | sut.add(imageInfo) 76 | 77 | // Assert 78 | assertThat(sut.getItem(0)).isEqualTo(imageInfo) 79 | } 80 | 81 | @Test @UiThreadTest fun onViewRecycled() { 82 | // Arrange 83 | sut.add(ImageInfoModel()) 84 | 85 | // Act 86 | sut.onBindViewHolder(viewHolder, 0) 87 | sut.onViewRecycled(viewHolder) 88 | } 89 | 90 | @Test fun testGetItemCountShouldReturnCorrectValues() { 91 | assertThat(sut.itemCount).isEqualTo(2) 92 | } 93 | 94 | @Test fun testGetListCountShouldReturnCorrectValues() { 95 | assertThat(sut.getItem(0)).isEqualTo(imageInfoModel) 96 | assertThat(sut.getItem(1)).isEqualTo(imageInfoModel2) 97 | } 98 | 99 | @Test fun testGetItemShouldReturnCorrectValues() { 100 | assertThat(sut.getItem(1)).isEqualTo(imageInfoModel2) 101 | } 102 | 103 | @Test fun testGetLocationShouldReturnCorrectValues() { 104 | assertThat(sut.getLocation(imageInfoModel2)).isEqualTo(1) 105 | } 106 | 107 | @Test fun testClearShouldClearAdapter() { 108 | // Act 109 | sut.clear() 110 | 111 | // Assert 112 | assertThat(sut.itemCount).isEqualTo(0) 113 | } 114 | 115 | @Test fun testAddObjectShouldReturnCorrectValues() { 116 | // Act 117 | sut.add(imageInfoModel3) 118 | 119 | // Assert 120 | assertThat(sut.getItem(0)).isEqualTo(imageInfoModel) 121 | assertThat(sut.getItem(1)).isEqualTo(imageInfoModel2) 122 | assertThat(sut.getItem(2)).isEqualTo(imageInfoModel3) 123 | } 124 | 125 | @Test fun testAddCollectionShouldReturnCorrectValues() { 126 | // Arrange 127 | val imageInfos = listOf(imageInfoModel3) 128 | 129 | // Act 130 | sut.addAll(imageInfos) 131 | 132 | // Assert 133 | assertThat(sut.getItem(0)).isEqualTo(imageInfoModel) 134 | assertThat(sut.getItem(1)).isEqualTo(imageInfoModel2) 135 | assertThat(sut.getItem(2)).isEqualTo(imageInfoModel3) 136 | } 137 | 138 | @Test fun testAddLocationObjectShouldReturnCorrectValues() { 139 | // Act 140 | sut.add(0, imageInfoModel3) 141 | 142 | // Assert 143 | assertThat(sut.getItem(0)).isEqualTo(imageInfoModel3) 144 | assertThat(sut.getItem(1)).isEqualTo(imageInfoModel) 145 | assertThat(sut.getItem(2)).isEqualTo(imageInfoModel2) 146 | } 147 | 148 | @Test fun testRemoveLocationObjectShouldReturnCorrectValues() { 149 | // Act 150 | sut.remove(0, imageInfoModel) 151 | 152 | // Assert 153 | assertThat(sut.getItem(0)).isEqualTo(imageInfoModel2) 154 | } 155 | 156 | @Test fun testRemoveObjectShouldReturnCorrectValues() { 157 | // Act 158 | sut.remove(imageInfoModel) 159 | 160 | // Assert 161 | assertThat(sut.getItem(0)).isEqualTo(imageInfoModel2) 162 | } 163 | 164 | @Test fun testRemoveLocationShouldReturnCorrectValues() { 165 | // Act 166 | sut.remove(0) 167 | 168 | // Assert 169 | assertThat(sut.getItem(0)).isEqualTo(imageInfoModel2) 170 | } 171 | 172 | class TestOnItemClickListener : OnItemClickListener { 173 | override fun onClick(imageInfoModel: ImageInfoModel) { 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/checkstyle/checkstyle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 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 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/adapter/GifAdapter.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.adapter 2 | 3 | import android.support.v7.widget.RecyclerView 4 | import android.util.Log 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import burrows.apps.example.gif.R 9 | import burrows.apps.example.gif.data.rest.repository.ImageApiRepository 10 | import burrows.apps.example.gif.presentation.adapter.model.ImageInfoModel 11 | import com.bumptech.glide.Glide 12 | import com.bumptech.glide.load.DataSource 13 | import com.bumptech.glide.load.engine.GlideException 14 | import com.bumptech.glide.load.resource.gif.GifDrawable 15 | import com.bumptech.glide.request.RequestListener 16 | import com.bumptech.glide.request.target.Target 17 | import kotlinx.android.synthetic.main.list_item.view.* 18 | 19 | /** 20 | * RecyclerView adapter for handling Gif Images in a Grid format. 21 | * 22 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 23 | */ 24 | class GifAdapter( 25 | private val onItemClickListener: GifAdapter.OnItemClickListener, 26 | private val repository: ImageApiRepository) : RecyclerView.Adapter() { 27 | companion object { 28 | private val TAG = GifAdapter::class.java.simpleName // Can't be longer than 23 chars 29 | } 30 | private val data = arrayListOf() 31 | 32 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 33 | return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_item, parent, false)) 34 | } 35 | 36 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 37 | val imageInfoModel = getItem(position) 38 | 39 | // Load images 40 | repository.load(imageInfoModel.url) 41 | .thumbnail(repository.load(imageInfoModel.previewUrl)) 42 | .listener(object : RequestListener { 43 | override fun onResourceReady(resource: GifDrawable?, model: Any?, 44 | target: Target?, dataSource: DataSource?, 45 | isFirstResource: Boolean): Boolean { 46 | // Hide progressbar 47 | holder.itemView.gifProgress.visibility = View.GONE 48 | if (Log.isLoggable(TAG, Log.INFO)) Log.i(TAG, "finished loading\t $model") 49 | 50 | return false 51 | } 52 | 53 | override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, 54 | isFirstResource: Boolean): Boolean { 55 | // Hide progressbar 56 | holder.itemView.gifProgress.visibility = View.GONE 57 | if (Log.isLoggable(TAG, Log.INFO)) Log.i(TAG, "finished loading\t $model") 58 | 59 | return false 60 | } 61 | }) 62 | .into(holder.itemView.gifImage) 63 | 64 | holder.itemView.setOnClickListener { onItemClickListener.onClick(imageInfoModel) } 65 | } 66 | 67 | override fun onViewRecycled(holder: ViewHolder) { 68 | super.onViewRecycled(holder) 69 | 70 | // https://github.com/bumptech/glide/issues/624#issuecomment-140134792 71 | // Forget view, try to free resources 72 | Glide.with(holder.itemView.context).clear(holder.itemView.gifImage) 73 | holder.itemView.gifImage.setImageDrawable(null) 74 | // Make sure to show progress when loading new view 75 | holder.itemView.gifProgress.visibility = View.VISIBLE 76 | } 77 | 78 | /** 79 | * Returns the number of elements in the data. 80 | * 81 | * @return the number of elements in the data. 82 | */ 83 | override fun getItemCount(): Int { 84 | return data.size 85 | } 86 | 87 | /** 88 | * Returns the hashCode of the URL of image. 89 | * 90 | * @return the hashCode of the URL of image. 91 | */ 92 | override fun getItemId(position: Int): Long { 93 | return getItem(position).url?.hashCode()?.toLong() ?: 0L 94 | } 95 | 96 | /** 97 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 98 | */ 99 | inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) 100 | 101 | /** 102 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 103 | */ 104 | interface OnItemClickListener { 105 | fun onClick(imageInfoModel: ImageInfoModel) 106 | } 107 | 108 | /** 109 | * Returns the element at the specified location in the data. 110 | * 111 | * @param location the index of the element to return. 112 | * @return the element at the specified location. 113 | */ 114 | fun getItem(location: Int): ImageInfoModel { 115 | return data[location] 116 | } 117 | 118 | /** 119 | * Searches the data for the specified object and returns the index of the 120 | * first occurrence. 121 | * 122 | * @param model the object to search for. 123 | * @return the index of the first occurrence of the object or -1 if the object was not found. 124 | */ 125 | fun getLocation(model: ImageInfoModel): Int { 126 | return data.indexOf(model) 127 | } 128 | 129 | /** 130 | * Clear the entire adapter using [android.support.v7.widget.RecyclerView.Adapter.notifyItemRangeRemoved]. 131 | */ 132 | fun clear() { 133 | val size = data.size 134 | if (size > 0) { 135 | for (i in 0 until size) data.removeAt(0) 136 | 137 | notifyItemRangeRemoved(0, size) 138 | } 139 | } 140 | 141 | /** 142 | * Adds the specified object at the end of the data. 143 | * 144 | * @param model the object to add. 145 | * @return always true. 146 | */ 147 | fun add(model: ImageInfoModel): Boolean { 148 | return data.add(model).apply { 149 | notifyItemInserted(data.size + 1) 150 | } 151 | } 152 | 153 | /** 154 | * Adds the objects in the specified collection to the end of the data. The 155 | * objects are added in the order in which they are returned from the 156 | * collection's iterator. 157 | * 158 | * @param collection the collection of objects. 159 | * @return `true` if the data is modified, `false` otherwise (i.e. if the passed 160 | * collection was empty). 161 | */ 162 | fun addAll(collection: List): Boolean { 163 | return data.addAll(collection).apply { 164 | notifyItemRangeInserted(0, data.size + 1) 165 | } 166 | } 167 | 168 | /** 169 | * Inserts the specified object into the data at the specified location. 170 | * The object is inserted before the current element at the specified 171 | * location. If the location is equal to the size of the data, the object 172 | * is added at the end. If the location is smaller than the size of the 173 | * data, then all elements beyond the specified location are moved by one 174 | * location towards the end of the data. 175 | * 176 | * @param location the index at which to insert. 177 | * @param model the object to add. 178 | */ 179 | fun add(location: Int, model: ImageInfoModel) { 180 | data.add(location, model) 181 | notifyItemInserted(location) 182 | } 183 | 184 | /** 185 | * Removes the first occurrence of the specified object from the data. 186 | * 187 | * @param model the object to remove. 188 | * @return true if the data was modified by this operation, false otherwise. 189 | */ 190 | fun remove(location: Int, model: ImageInfoModel): Boolean { 191 | return data.remove(model).apply { 192 | notifyItemRangeRemoved(location, data.size) 193 | } 194 | } 195 | 196 | /** 197 | * Removes the first occurrence of the specified object from the data. 198 | * 199 | * @param model the object to remove. 200 | * @return true if the data was modified by this operation, false otherwise. 201 | */ 202 | fun remove(model: ImageInfoModel): Boolean { 203 | return getLocation(model).let { location -> 204 | data.remove(model).apply { 205 | notifyItemRemoved(location) 206 | } 207 | } 208 | } 209 | 210 | /** 211 | * Removes the object at the specified location from the data. 212 | * 213 | * @param location the index of the object to remove. 214 | * @return the removed object. 215 | */ 216 | fun remove(location: Int): ImageInfoModel { 217 | return data.removeAt(location).apply { 218 | notifyItemRemoved(location) 219 | notifyItemRangeChanged(location, data.size) 220 | } 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /config/detekt/detekt.yml: -------------------------------------------------------------------------------- 1 | autoCorrect: true 2 | failFast: false 3 | 4 | build: 5 | warningThreshold: 5 6 | failThreshold: 10 7 | weights: 8 | complexity: 2 9 | formatting: 1 10 | LongParameterList: 1 11 | comments: 1 12 | 13 | processors: 14 | active: true 15 | exclude: 16 | # - 'FunctionCountProcessor' 17 | # - 'PropertyCountProcessor' 18 | # - 'ClassCountProcessor' 19 | # - 'PackageCountProcessor' 20 | # - 'KtFileCountProcessor' 21 | 22 | console-reports: 23 | active: true 24 | exclude: 25 | # - 'ProjectStatisticsReport' 26 | # - 'ComplexityReport' 27 | # - 'NotificationReport' 28 | # - 'FindingsReport' 29 | # - 'BuildFailureReport' 30 | 31 | output-reports: 32 | active: true 33 | exclude: 34 | # - 'PlainOutputReport' 35 | # - 'XmlOutputReport' 36 | 37 | potential-bugs: 38 | active: true 39 | DuplicateCaseInWhenExpression: 40 | active: true 41 | EqualsAlwaysReturnsTrueOrFalse: 42 | active: false 43 | EqualsWithHashCodeExist: 44 | active: true 45 | InvalidLoopCondition: 46 | active: false 47 | WrongEqualsTypeParameter: 48 | active: false 49 | IteratorHasNextCallsNextMethod: 50 | active: false 51 | ExplicitGarbageCollectionCall: 52 | active: true 53 | UnconditionalJumpStatementInLoop: 54 | active: false 55 | IteratorNotThrowingNoSuchElementException: 56 | active: false 57 | UnreachableCode: 58 | active: true 59 | LateinitUsage: 60 | active: false 61 | excludeAnnotatedProperties: '' 62 | ignoreOnClassesPattern: '' 63 | UnsafeCallOnNullableType: 64 | active: false 65 | UnsafeCast: 66 | active: false 67 | UselessPostfixExpression: 68 | active: false 69 | 70 | performance: 71 | active: true 72 | ForEachOnRange: 73 | active: true 74 | SpreadOperator: 75 | active: true 76 | UnnecessaryTemporaryInstantiation: 77 | active: true 78 | 79 | exceptions: 80 | active: true 81 | ExceptionRaisedInUnexpectedLocation: 82 | active: false 83 | methodNames: 'toString,hashCode,equals,finalize' 84 | SwallowedException: 85 | active: false 86 | TooGenericExceptionCaught: 87 | active: true 88 | exceptions: 89 | - ArrayIndexOutOfBoundsException 90 | - Error 91 | - Exception 92 | - IllegalMonitorStateException 93 | - IndexOutOfBoundsException 94 | - InterruptedException 95 | - NullPointerException 96 | - RuntimeException 97 | - Throwable 98 | TooGenericExceptionThrown: 99 | active: true 100 | exceptions: 101 | - Error 102 | - Exception 103 | - NullPointerException 104 | - RuntimeException 105 | - Throwable 106 | InstanceOfCheckForException: 107 | active: false 108 | NotImplementedDeclaration: 109 | active: false 110 | ThrowingExceptionsWithoutMessageOrCause: 111 | active: false 112 | exceptions: 'IllegalArgumentException,IllegalStateException,IOException' 113 | PrintStackTrace: 114 | active: false 115 | RethrowCaughtException: 116 | active: false 117 | ReturnFromFinally: 118 | active: false 119 | ThrowingExceptionFromFinally: 120 | active: false 121 | ThrowingExceptionInMain: 122 | active: false 123 | ThrowingNewInstanceOfSameException: 124 | active: false 125 | 126 | empty-blocks: 127 | active: true 128 | EmptyCatchBlock: 129 | active: true 130 | EmptyClassBlock: 131 | active: true 132 | EmptyDefaultConstructor: 133 | active: true 134 | EmptyDoWhileBlock: 135 | active: true 136 | EmptyElseBlock: 137 | active: true 138 | EmptyFinallyBlock: 139 | active: true 140 | EmptyForBlock: 141 | active: true 142 | EmptyFunctionBlock: 143 | active: true 144 | EmptyIfBlock: 145 | active: true 146 | EmptyInitBlock: 147 | active: true 148 | EmptyKtFile: 149 | active: true 150 | EmptySecondaryConstructor: 151 | active: true 152 | EmptyWhenBlock: 153 | active: true 154 | EmptyWhileBlock: 155 | active: true 156 | 157 | complexity: 158 | active: true 159 | LongMethod: 160 | active: false 161 | threshold: 20 162 | NestedBlockDepth: 163 | active: true 164 | threshold: 3 165 | LongParameterList: 166 | active: true 167 | threshold: 5 168 | LargeClass: 169 | active: true 170 | threshold: 150 171 | ComplexInterface: 172 | active: false 173 | threshold: 10 174 | includeStaticDeclarations: false 175 | ComplexMethod: 176 | active: true 177 | threshold: 10 178 | MethodOverloading: 179 | active: false 180 | threshold: 5 181 | TooManyFunctions: 182 | active: false 183 | threshold: 10 184 | ComplexCondition: 185 | active: true 186 | threshold: 3 187 | LabeledExpression: 188 | active: false 189 | StringLiteralDuplication: 190 | active: false 191 | threshold: 2 192 | ignoreAnnotation: true 193 | excludeStringsWithLessThan5Characters: true 194 | ignoreStringsRegex: '$^' 195 | 196 | code-smell: 197 | active: true 198 | FeatureEnvy: 199 | threshold: 0.5 200 | weight: 0.45 201 | base: 0.5 202 | 203 | formatting: 204 | active: true 205 | useTabs: true 206 | Indentation: 207 | active: false 208 | indentSize: 4 209 | ConsecutiveBlankLines: 210 | active: true 211 | autoCorrect: true 212 | MultipleSpaces: 213 | active: true 214 | autoCorrect: true 215 | SpacingAfterComma: 216 | active: true 217 | autoCorrect: true 218 | SpacingAfterKeyword: 219 | active: true 220 | autoCorrect: true 221 | SpacingAroundColon: 222 | active: true 223 | autoCorrect: true 224 | SpacingAroundBraces: 225 | active: true 226 | autoCorrect: true 227 | SpacingAroundOperator: 228 | active: true 229 | autoCorrect: true 230 | TrailingSpaces: 231 | active: true 232 | autoCorrect: true 233 | UnusedImports: 234 | active: true 235 | autoCorrect: true 236 | OptionalSemicolon: 237 | active: true 238 | autoCorrect: true 239 | OptionalUnit: 240 | active: true 241 | autoCorrect: true 242 | ExpressionBodySyntax: 243 | active: false 244 | autoCorrect: false 245 | ExpressionBodySyntaxLineBreaks: 246 | active: false 247 | autoCorrect: false 248 | OptionalReturnKeyword: 249 | active: true 250 | autoCorrect: false 251 | 252 | style: 253 | active: true 254 | ReturnCount: 255 | active: false 256 | max: 2 257 | ThrowsCount: 258 | active: true 259 | max: 2 260 | NewLineAtEndOfFile: 261 | active: true 262 | OptionalAbstractKeyword: 263 | active: true 264 | OptionalWhenBraces: 265 | active: false 266 | EqualsNullCall: 267 | active: false 268 | ForbiddenComment: 269 | active: true 270 | values: 'TODO:,FIXME:,STOPSHIP:' 271 | ForbiddenImport: 272 | active: false 273 | imports: '' 274 | PackageDeclarationStyle: 275 | active: false 276 | LoopWithTooManyJumpStatements: 277 | active: false 278 | maxJumpCount: 1 279 | ModifierOrder: 280 | active: true 281 | MagicNumber: 282 | active: false 283 | ignoreNumbers: '-1,0,1,2' 284 | ignoreHashCodeFunction: false 285 | ignorePropertyDeclaration: false 286 | ignoreAnnotation: false 287 | ignoreNamedArgument: true 288 | WildcardImport: 289 | active: false 290 | SafeCast: 291 | active: true 292 | MaxLineLength: 293 | active: false 294 | maxLineLength: 120 295 | excludePackageStatements: false 296 | excludeImportStatements: false 297 | PackageNaming: 298 | active: true 299 | packagePattern: '^[a-z]+(\.[a-z][a-z0-9]*)*$' 300 | ClassNaming: 301 | active: true 302 | classPattern: '[A-Z$][a-zA-Z$]*' 303 | EnumNaming: 304 | active: true 305 | enumEntryPattern: '^[A-Z$][a-zA-Z_$]*$' 306 | FunctionNaming: 307 | active: true 308 | functionPattern: '^([a-z$][a-zA-Z$0-9]*)|(`.*`)$' 309 | FunctionMaxLength: 310 | active: false 311 | maximumFunctionNameLength: 30 312 | FunctionMinLength: 313 | active: false 314 | minimumFunctionNameLength: 3 315 | VariableNaming: 316 | active: true 317 | variablePattern: '^(_)?[a-z$][a-zA-Z$0-9]*$' 318 | ConstantNaming: 319 | active: true 320 | constantPattern: '^([A-Z_]*|serialVersionUID)$' 321 | VariableMaxLength: 322 | active: false 323 | maximumVariableNameLength: 30 324 | VariableMinLength: 325 | active: false 326 | minimumVariableNameLength: 3 327 | ForbiddenClassName: 328 | active: false 329 | forbiddenName: '' 330 | ProtectedMemberInFinalClass: 331 | active: false 332 | SerialVersionUIDInSerializableClass: 333 | active: false 334 | UnnecessaryParentheses: 335 | active: false 336 | UnnecessaryInheritance: 337 | active: false 338 | UtilityClassWithPublicConstructor: 339 | active: false 340 | DataClassContainsFunctions: 341 | active: false 342 | conversionFunctionPrefix: 'to' 343 | UseDataClass: 344 | active: false 345 | UnnecessaryAbstractClass: 346 | active: false 347 | OptionalUnit: 348 | active: false 349 | OptionalReturnKeyword: 350 | active: false 351 | ExpressionBodySyntax: 352 | active: false 353 | UnusedImports: 354 | active: false 355 | NestedClassesVisibility: 356 | active: false 357 | 358 | comments: 359 | active: true 360 | CommentOverPrivateMethod: 361 | active: true 362 | CommentOverPrivateProperty: 363 | active: true 364 | UndocumentedPublicClass: 365 | active: false 366 | searchInNestedClass: true 367 | searchInInnerClass: true 368 | searchInInnerObject: true 369 | searchInInnerInterface: true 370 | UndocumentedPublicFunction: 371 | active: false 372 | 373 | # *experimental feature* 374 | # Migration rules can be defined in the same config file or a new one 375 | migration: 376 | active: true 377 | imports: 378 | # your.package.Class: new.package.or.Class 379 | # for example: 380 | # io.gitlab.arturbosch.detekt.api.Rule: io.gitlab.arturbosch.detekt.rule.Rule 381 | -------------------------------------------------------------------------------- /src/main/kotlin/burrows/apps/example/gif/presentation/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package burrows.apps.example.gif.presentation.main 2 | 3 | import android.os.Bundle 4 | import android.support.v7.app.AppCompatActivity 5 | import android.support.v7.app.AppCompatDialog 6 | import android.support.v7.widget.GridLayoutManager 7 | import android.support.v7.widget.RecyclerView 8 | import android.support.v7.widget.SearchView 9 | import android.text.TextUtils 10 | import android.util.Log 11 | import android.view.Menu 12 | import android.view.MenuItem 13 | import android.view.View 14 | import android.widget.ImageView 15 | import android.widget.ProgressBar 16 | import android.widget.TextView 17 | import burrows.apps.example.gif.App 18 | import burrows.apps.example.gif.R 19 | import burrows.apps.example.gif.data.rest.model.RiffsyResponseDto 20 | import burrows.apps.example.gif.data.rest.repository.ImageApiRepository 21 | import burrows.apps.example.gif.data.rest.repository.RiffsyApiClient 22 | import burrows.apps.example.gif.presentation.SchedulerProvider 23 | import burrows.apps.example.gif.presentation.adapter.GifAdapter 24 | import burrows.apps.example.gif.presentation.adapter.GifItemDecoration 25 | import burrows.apps.example.gif.presentation.adapter.model.ImageInfoModel 26 | import com.bumptech.glide.Glide 27 | import com.bumptech.glide.load.DataSource 28 | import com.bumptech.glide.load.engine.GlideException 29 | import com.bumptech.glide.load.resource.gif.GifDrawable 30 | import com.bumptech.glide.request.RequestListener 31 | import com.bumptech.glide.request.target.Target 32 | import com.squareup.leakcanary.RefWatcher 33 | import javax.inject.Inject 34 | import kotlinx.android.synthetic.main.activity_main.* 35 | import kotlinx.android.synthetic.main.dialog_preview.view.* 36 | 37 | /** 38 | * Main activity that will load our Fragments via the Support Fragment Manager. 39 | * 40 | * @author [Jared Burrows](mailto:jaredsburrows@gmail.com) 41 | */ 42 | class MainActivity : AppCompatActivity(), IMainView, GifAdapter.OnItemClickListener { 43 | companion object { 44 | private val TAG = MainActivity::class.java.simpleName // Can't be longer than 23 chars 45 | private const val PORTRAIT_COLUMNS = 3 46 | private const val VISIBLE_THRESHOLD = 5 47 | } 48 | private lateinit var layoutManager: GridLayoutManager 49 | private lateinit var itemOffsetDecoration: GifItemDecoration 50 | private lateinit var adapter: GifAdapter 51 | private lateinit var dialog: AppCompatDialog 52 | private lateinit var dialogText: TextView 53 | private lateinit var progressBar: ProgressBar 54 | private lateinit var imageView: ImageView 55 | private lateinit var presenter: IMainPresenter 56 | private var hasSearched = false 57 | private var previousTotal = 0 58 | private var loading = true 59 | private var firstVisibleItem = 0 60 | private var visibleItemCount = 0 61 | private var totalItemCount = 0 62 | private var next: Double? = null 63 | @Inject lateinit var refWatcher: RefWatcher 64 | @Inject lateinit var repository: ImageApiRepository 65 | @Inject lateinit var client: RiffsyApiClient 66 | @Inject lateinit var schedulerProvider: SchedulerProvider 67 | 68 | // 69 | // Contract 70 | // 71 | 72 | override fun setPresenter(presenter: IMainPresenter) { 73 | this.presenter = presenter 74 | } 75 | 76 | override fun clearImages() { 77 | // Clear current data 78 | adapter.clear() 79 | } 80 | 81 | override fun addImages(response: RiffsyResponseDto) { 82 | next = response.page 83 | 84 | response.results?.forEach { 85 | val url = it.media?.first()?.gif?.url 86 | 87 | adapter.add(ImageInfoModel(url, null)) 88 | 89 | if (Log.isLoggable(TAG, Log.INFO)) Log.i(TAG, "ORIGINAL_IMAGE_URL\t $url") 90 | } 91 | } 92 | 93 | override fun showDialog(imageInfoModel: ImageInfoModel) { 94 | showImageDialog(imageInfoModel) 95 | } 96 | 97 | override fun isActive(): Boolean { 98 | return !isFinishing 99 | } 100 | 101 | // 102 | // GifAdapter 103 | // 104 | 105 | override fun onClick(imageInfoModel: ImageInfoModel) { 106 | showDialog(imageInfoModel) 107 | } 108 | 109 | // 110 | // Activity 111 | // 112 | 113 | override fun onCreate(savedInstanceState: Bundle?) { 114 | super.onCreate(savedInstanceState) 115 | setContentView(R.layout.activity_main) 116 | 117 | // Injection dependencies 118 | (application as App).activityComponent.inject(this) 119 | 120 | MainPresenter(this, client, schedulerProvider) 121 | 122 | // Setup Toolbar 123 | toolBar.setNavigationIcon(R.mipmap.ic_launcher) 124 | toolBar.setTitle(R.string.main_screen_title) 125 | setSupportActionBar(toolBar) 126 | 127 | layoutManager = GridLayoutManager(this, PORTRAIT_COLUMNS) 128 | itemOffsetDecoration = GifItemDecoration( 129 | this.resources.getDimensionPixelSize(R.dimen.gif_adapter_item_offset), 130 | layoutManager.spanCount) 131 | adapter = GifAdapter(this, repository) 132 | adapter.setHasStableIds(true) 133 | 134 | // Setup RecyclerView 135 | recyclerView.layoutManager = layoutManager 136 | recyclerView.addItemDecoration(itemOffsetDecoration) 137 | recyclerView.adapter = adapter 138 | recyclerView.setHasFixedSize(true) 139 | recyclerView.setItemViewCacheSize(RiffsyApiClient.DEFAULT_LIMIT_COUNT) 140 | recyclerView.isDrawingCacheEnabled = true 141 | recyclerView.drawingCacheQuality = View.DRAWING_CACHE_QUALITY_HIGH 142 | recyclerView.recycledViewPool.setMaxRecycledViews(0, PORTRAIT_COLUMNS * 2) // default 5 143 | recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { 144 | override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) { 145 | super.onScrolled(recyclerView, dx, dy) 146 | 147 | // Continuous scrolling 148 | visibleItemCount = recyclerView?.childCount ?: 0 149 | totalItemCount = layoutManager.itemCount 150 | firstVisibleItem = layoutManager.findFirstVisibleItemPosition() 151 | 152 | if (loading && totalItemCount > previousTotal) { 153 | loading = false 154 | previousTotal = totalItemCount 155 | } 156 | 157 | if (!loading && totalItemCount - visibleItemCount <= firstVisibleItem + VISIBLE_THRESHOLD) { 158 | presenter.loadTrendingImages(next) 159 | 160 | loading = true 161 | } 162 | } 163 | }) 164 | 165 | // Custom view for Dialog 166 | val dialogView = View.inflate(this, R.layout.dialog_preview, null) 167 | 168 | // Customize Dialog 169 | dialog = AppCompatDialog(this) 170 | dialog.setContentView(dialogView) 171 | dialog.setCancelable(true) 172 | dialog.setCanceledOnTouchOutside(true) 173 | dialog.setOnDismissListener { 174 | // https://github.com/bumptech/glide/issues/624#issuecomment-140134792 175 | Glide.with(imageView.context).clear(imageView) // Forget view, try to free resources 176 | imageView.setImageDrawable(null) 177 | progressBar.visibility = View.VISIBLE // Make sure to show progress when loading new view 178 | } 179 | 180 | // Dialog views 181 | dialogText = dialogView.gifDialogTitle 182 | progressBar = dialogView.gifDialogProgress 183 | imageView = dialogView.gifDialogImage 184 | 185 | // Load initial images 186 | presenter.loadTrendingImages(next) 187 | } 188 | 189 | override fun onCreateOptionsMenu(menu: Menu): Boolean { 190 | super.onCreateOptionsMenu(menu) 191 | menuInflater.inflate(R.menu.menu_fragment_main, menu) 192 | 193 | val menuItem = menu.findItem(R.id.menu_search) 194 | val searchView = menuItem?.actionView as SearchView? 195 | searchView?.queryHint = searchView?.context?.getString(R.string.search_gifs) 196 | 197 | // Set contextual action on search icon click 198 | menuItem?.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { 199 | override fun onMenuItemActionExpand(item: MenuItem): Boolean { 200 | return true 201 | } 202 | 203 | override fun onMenuItemActionCollapse(item: MenuItem): Boolean { 204 | // When search is closed, go back to trending getResults 205 | if (hasSearched) { 206 | // Reset 207 | presenter.clearImages() 208 | presenter.loadTrendingImages(next) 209 | hasSearched = false 210 | } 211 | return true 212 | } 213 | }) 214 | 215 | // Query listener for search bar 216 | searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { 217 | override fun onQueryTextChange(newText: String): Boolean { 218 | // Search on type 219 | if (!TextUtils.isEmpty(newText)) { 220 | // Reset 221 | presenter.clearImages() 222 | presenter.loadSearchImages(newText, next) 223 | hasSearched = true 224 | } 225 | return false 226 | } 227 | 228 | override fun onQueryTextSubmit(query: String): Boolean { 229 | return false 230 | } 231 | }) 232 | 233 | return true 234 | } 235 | 236 | override fun onResume() { 237 | super.onResume() 238 | 239 | presenter.subscribe() 240 | } 241 | 242 | override fun onPause() { 243 | super.onPause() 244 | 245 | presenter.unsubscribe() 246 | } 247 | 248 | override fun onDestroy() { 249 | super.onDestroy() 250 | 251 | refWatcher.watch(this, TAG) 252 | } 253 | 254 | // 255 | // Private 256 | // 257 | 258 | private fun showImageDialog(imageInfoModel: ImageInfoModel) { 259 | dialog.show() 260 | // Remove "white" background for dialog 261 | dialog.window.decorView.setBackgroundResource(android.R.color.transparent) 262 | 263 | // Load associated text 264 | dialogText.text = imageInfoModel.url 265 | dialogText.visibility = View.VISIBLE 266 | 267 | // Load image 268 | repository.load(imageInfoModel.url) 269 | .thumbnail(repository.load(imageInfoModel.previewUrl)) 270 | .listener(object : RequestListener { 271 | override fun onResourceReady(resource: GifDrawable?, model: Any?, 272 | target: Target?, dataSource: DataSource?, 273 | isFirstResource: Boolean): Boolean { 274 | // Hide progressbar 275 | progressBar.visibility = View.GONE 276 | if (Log.isLoggable(TAG, Log.INFO)) Log.i(TAG, "finished loading\t $model") 277 | 278 | return false 279 | } 280 | 281 | override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, 282 | isFirstResource: Boolean): Boolean { 283 | // Hide progressbar 284 | progressBar.visibility = View.GONE 285 | if (Log.isLoggable(TAG, Log.INFO)) Log.i(TAG, "finished loading\t $model") 286 | 287 | return false 288 | } 289 | }) 290 | .into(imageView) 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------