Repository: kaushikgopal/movies-usf
Branch: master
Commit: 196b82606af2
Files: 58
Total size: 121.4 KB
Directory structure:
gitextract_gbcdwt71/
├── .gitignore
├── .idea/
│ ├── .name
│ └── vcs.xml
├── LICENSE
├── README.md
├── app/
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src/
│ ├── debug/
│ │ └── java/
│ │ └── co/
│ │ └── kaush/
│ │ └── msusf/
│ │ └── movies/
│ │ ├── OpenClass.kt
│ │ └── OpenClassOnDebug.kt
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── co/
│ │ │ └── kaush/
│ │ │ └── msusf/
│ │ │ ├── MSApp.kt
│ │ │ ├── di/
│ │ │ │ ├── AppScope.kt
│ │ │ │ └── MSAppDI.kt
│ │ │ └── movies/
│ │ │ ├── MSAnimationExt.kt
│ │ │ ├── MSMovieActivity.kt
│ │ │ ├── MSMovieApi.kt
│ │ │ ├── MSMovieRepository.kt
│ │ │ ├── MSMovieResult.kt
│ │ │ ├── MSMovieSearchHistoryAdapter.kt
│ │ │ ├── MSMovieViewModel.kt
│ │ │ ├── MSMovieViewModelImpl.kt
│ │ │ └── MSMovieViewState.kt
│ │ └── res/
│ │ ├── drawable/
│ │ │ ├── ic_launcher_background.xml
│ │ │ └── ms_list_divider_space.xml
│ │ ├── drawable-v24/
│ │ │ └── ic_launcher_foreground.xml
│ │ ├── layout/
│ │ │ ├── activity_main.xml
│ │ │ └── view_movie.xml
│ │ ├── mipmap-anydpi-v26/
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ ├── values/
│ │ │ ├── colors.xml
│ │ │ ├── dimens.xml
│ │ │ ├── strings.xml
│ │ │ ├── styles.xml
│ │ │ └── tags.xml
│ │ └── xml/
│ │ └── network_security_config.xml
│ └── test/
│ └── java/
│ └── co/
│ └── kaush/
│ └── msusf/
│ └── movies/
│ ├── CoroutineTestRule.kt
│ ├── MSMovieViewModelTest.kt
│ └── di/
│ └── TestMSAppDI.kt
├── build.gradle.kts
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── usf/
├── annotations/
│ ├── build.gradle.kts
│ └── src/
│ └── main/
│ └── java/
│ └── co/
│ └── kaush/
│ └── msusf/
│ └── annotations/
│ └── UsfViewModel.kt
├── annotations-processors/
│ ├── build.gradle.kts
│ └── src/
│ └── main/
│ ├── java/
│ │ └── co/
│ │ └── kaush/
│ │ └── msusf/
│ │ └── processors/
│ │ ├── UsfViewModelClassBuilderDefinition.kt
│ │ ├── UsfViewModelFileBuilder.kt
│ │ ├── UsfViewModelProcessor.kt
│ │ ├── UsfViewModelProcessorProvider.kt
│ │ └── UsfViewModelVisitor.kt
│ └── resources/
│ └── META-INF/
│ └── services/
│ └── com.google.devtools.ksp.processing.SymbolProcessorProvider
└── api/
├── build.gradle.kts
└── src/
└── main/
└── java/
└── co/
└── kaush/
└── usf/
├── UsfViewModelImpl.kt
└── UsfVm.kt
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
##############################################################################
### macOS template
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
##############################################################################
### Windows template
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
##############################################################################
### Linux template
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
##############################################################################
### Visual Studio template
# Visual studio project files
*.vcxproj
*.vcxproj.filters
*.sln
##############################################################################
### JetBrains template
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
*.iml
*.ipr
*.iws
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
/.idea/*
# Exclude non-user-specific stuff
!.idea/.name
!.idea/codeInsightSettings.xml
!.idea/codeStyles/
!.idea/copyright/
!.idea/dataSources.xml
!.idea/detekt.xml
!.idea/encodings.xml
!.idea/externalDependencies.xml
!.idea/file.template.settings.xml
!.idea/fileTemplates/
!.idea/icon.svg
!.idea/inspectionProfiles/
!.idea/runConfigurations/
!.idea/scopes/
!.idea/vcs.xml
##############################################################################
### Kotlin template
# Compiled class file
*.class
# Log file
*.log
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
##############################################################################
### C++ template
# Compiled class file
*.cxx
##############################################################################
### Gradle template
.gradle
.gradle/
# Note that you may need to exclude by hand other folders
# named build if necessary (e.g., in src/)
**/build/
!src/**/build/
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle/wrapper/gradle-wrapper.jar
# Cache of project
.gradletasknamecache
##############################################################################
### Android-specific stuff
# Built application files
*.apk
*.ap_
*.aab
*.apks
# Files for the Dalvik VM
*.dex
# Generated files
bin/
gen/
obj/
# Local configuration file (sdk path, etc)
local.properties
# Google/Firebase secrets
google-services.json
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
output.json
# Keystore files
*.jks
*.keystore
# Android Profiling
*.hprof
##############################################################################
### Project Specific
# !.idea/workspace.xml
================================================
FILE: .idea/.name
================================================
Movies USF Android
================================================
FILE: .idea/vcs.xml
================================================
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: README.md
================================================
# Movie search using a unidirectional state flow pattern
This is an attempt at coming up with a unidirectional state flow pattern that uses the concepts
of patterns like Redux, Cycle.js, MVI etc.
Many of my contemporaries have already done amazing work in this area and I've drawn a lot of lessons and learnings from their work already:
* [The state of managing state with RxJava](https://jakewharton.com/the-state-of-managing-state-with-rxjava/) - [JakeWharton](https://twitter.com/JakeWharton)
* [MVI patterns with Hannes Dorfmann](http://fragmentedpodcast.com/episodes/103/) - [Hannes Dorfmann](https://twitter.com/sockeqwe)
* [LCE: Modeling Data Loading in RxJava](https://tech.instacart.com/lce-modeling-data-loading-in-rxjava-b798ac98d80) - [Laimonas](https://twitter.com/ThatLime)
I wanted to achieve the benefits of this pattern without introducing any new libraries
or a new framework. How would one familiar with an MVVM model today leverage the principles/benefits of a unidirectional state/data flow? I hope to demo those concepts with this app.

The app is a simple movie search app. Clicking the movie result populates a history list. While this is not an extremely complex app, it isn't a silly Hello World one either, so the hope is that it'll cover regular use cases for a basic application.
I've also started meaninful test cases in the repo.
## Setting up your OMDB API KEY
_We use the wonderful [OMDB api](http://www.omdbapi.com) to fetch movie information._
There are quotas on this api, so please don't use mine :)
1. Get an [api key for OMDB here](http://www.omdbapi.com/apikey.aspx)
2. Add it to you local.properties file (which shouldn't be checked in to a VCS) like so:
```
# local.properties
OMDB_API_KEY=""
```
For great movie recommendations, ping me [@kau.sh](https://kau.sh) (seriously, I watch a lot of movies).
I gave a talk at [MBLT}Dev 2018](https://twitter.com/mbltdev) on how I went about building this app. [Slides can be found here](https://speakerdeck.com/kaushikgopal/unidirectional-state-flow-patterns-a-refactoring-story).
## Getting Started
This project now uses [ksp](https://kotlinlang.org/docs/ksp-overview.html) to reduce the boilerplate in wiring up a new feature. [This PR](https://github.com/kaushikgopal/movies-usf-android/pull/32) has the details for how this change was made.
All that's needed is writing the implementation of your ViewModel so `MyFeatureViewModelImpl: UsfViewModelImpl` and adding the `@UsfViewModel` annotation. Your ViewModel boilerplate code will be auto-generated.
Take a look at [MSMovieViewModelImpl](https://github.com/kaushikgopal/movies-usf-android/blob/master/app/src/main/java/co/kaush/msusf/movies/MSMovieViewModelImpl.kt) for the View Model logic and [MSMovieActivity](https://github.com/kaushikgopal/movies-usf-android/blob/master/app/src/main/java/co/kaush/msusf/movies/MSMovieActivity.kt) to see how the `viewModel` is invoked.
# iOS app
I gave another talk at [Mobilization IX](https://twitter.com/mobilizationpl/status/1184008559157219328?s=20) showing how we can use the same concepts on iOS too and wrote [my first iOS app to demonstrate these concepts - You can check that out here](https://github.com/kaushikgopal/movies-usf-ios).
================================================
FILE: app/build.gradle.kts
================================================
@file:Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed (or agp 8.1)
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.allopen)
alias(libs.plugins.google.ksp)
alias(libs.plugins.secrets.gradle.plugin)
alias(libs.plugins.mannodermaus.junit5)
}
allOpen { annotation("co.kaush.msusf.movies.OpenClass") }
android {
namespace = "co.kaush.msusf.movies"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = "co.kaush.msusf"
minSdk = libs.versions.minSdk.get().toInt()
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
}
buildFeatures {
buildConfig = true
viewBinding = true
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
}
}
compileOptions {
// after agp 8.1.0-alpha09, this is no longer needed
// https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
ksp(libs.kotlin.inject.compiler)
implementation(libs.kotlin.inject.runtime)
ksp(project(":usf:annotations-processors")) // todo: put all usf in same module
implementation(project(":usf:annotations"))
implementation(project(":usf:api"))
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.androidx.constraintlayout) // todo: move to compose
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.recyclerview)
implementation(libs.coil)
implementation(libs.flow.binding)
implementation(libs.timber) // todo: remove
implementation(platform(libs.square.okhttp.bom))
implementation(libs.square.okhttp)
implementation(libs.square.okhttp.logging.interceptor)
implementation(libs.square.retrofit.gson)
implementation(libs.square.retrofit)
debugImplementation(libs.square.leakcanary)
releaseImplementation(libs.square.leakcanary.noop)
// (Required) Writing and executing Unit Tests on the JUnit Platform
testImplementation(libs.testing.junit5.api)
testRuntimeOnly(libs.testing.junit5.engine)
testImplementation(libs.testing.turbine)
testImplementation(libs.testing.assertj.core)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.kotlin)
testImplementation(libs.kotlinx.coroutines.test)
// testImplementation(libs.atsl.runner)
androidTestImplementation(libs.androidx.espresso.core)
}
================================================
FILE: app/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
================================================
FILE: app/src/debug/java/co/kaush/msusf/movies/OpenClass.kt
================================================
package co.kaush.msusf.movies
@Target(AnnotationTarget.ANNOTATION_CLASS) annotation class OpenClass
================================================
FILE: app/src/debug/java/co/kaush/msusf/movies/OpenClassOnDebug.kt
================================================
package co.kaush.msusf.movies
@OpenClass @Target(AnnotationTarget.CLASS) annotation class OpenClassOnDebug
================================================
FILE: app/src/main/AndroidManifest.xml
================================================
================================================
FILE: app/src/main/java/co/kaush/msusf/MSApp.kt
================================================
package co.kaush.msusf
import android.app.Application
import co.kaush.msusf.di.AppComponent
import co.kaush.msusf.movies.OpenClassOnDebug
import timber.log.Timber
@OpenClassOnDebug
class MSApp : Application() {
private val appComponent by lazy(LazyThreadSafetyMode.NONE) { AppComponent.from(this) }
override fun onCreate() {
super.onCreate()
Timber.plant(Timber.DebugTree())
}
}
================================================
FILE: app/src/main/java/co/kaush/msusf/di/AppScope.kt
================================================
package co.kaush.msusf.di
import me.tatarka.inject.annotations.Scope
/** The application-level scope. There will only be one instance of anything annotated with this. */
@Scope annotation class AppScope
================================================
FILE: app/src/main/java/co/kaush/msusf/di/MSAppDI.kt
================================================
package co.kaush.msusf.di
import android.content.Context
import co.kaush.msusf.MSApp
import co.kaush.msusf.movies.MSMovieApi
import co.kaush.msusf.movies.MSMovieRepository
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
/** These are components that you'll need in both Test and Prod */
abstract class CommonAppComponent {
abstract val movieRepository: MSMovieRepository
}
@AppScope
@Component
abstract class AppComponent(
@get:Provides val app: MSApp,
) : CommonAppComponent() {
@AppScope
@Provides
protected fun provideRetrofit(): Retrofit {
val interceptor = (HttpLoggingInterceptor()).apply { level = HttpLoggingInterceptor.Level.BODY }
val okHttpClient: OkHttpClient =
OkHttpClient.Builder().addNetworkInterceptor(interceptor).build()
return Retrofit.Builder()
.baseUrl("http://www.omdbapi.com")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@AppScope
@Provides
fun provideMovieApi(retrofit: Retrofit): MSMovieApi {
return retrofit.create(MSMovieApi::class.java)
}
companion object {
private var instance: AppComponent? = null
fun from(context: Context): AppComponent =
instance
?: AppComponent::class.create(context.applicationContext as MSApp).also {
instance = it
}
}
}
================================================
FILE: app/src/main/java/co/kaush/msusf/movies/MSAnimationExt.kt
================================================
package co.kaush.msusf.movies
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.view.View
import android.view.animation.OvershootInterpolator
import android.widget.ImageView
fun ImageView.growShrink() {
val expansionFactor: Float = 0.2F
val growX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f + expansionFactor)
val growY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f + expansionFactor)
val growAnimation = ObjectAnimator.ofPropertyValuesHolder(this, growX, growY)
growAnimation.interpolator = OvershootInterpolator()
val shrinkX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f)
val shrinkY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f)
val shrinkAnimation = ObjectAnimator.ofPropertyValuesHolder(this, shrinkX, shrinkY)
shrinkAnimation.interpolator = OvershootInterpolator()
val animSetXY = AnimatorSet()
animSetXY.playSequentially(growAnimation, shrinkAnimation)
animSetXY.start()
}
================================================
FILE: app/src/main/java/co/kaush/msusf/movies/MSMovieActivity.kt
================================================
package co.kaush.msusf.movies
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import co.kaush.msusf.di.AppComponent
import co.kaush.msusf.movies.MSMovieEvent.AddToHistoryEvent
import co.kaush.msusf.movies.MSMovieEvent.RestoreFromHistoryEvent
import co.kaush.msusf.movies.MSMovieEvent.ScreenLoadEvent
import co.kaush.msusf.movies.MSMovieEvent.SearchMovieEvent
import co.kaush.msusf.movies.databinding.ActivityMainBinding
import coil.load
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import reactivecircus.flowbinding.android.view.clicks
import timber.log.Timber
class MSMovieActivity : ComponentActivity() {
private lateinit var movieRepo: MSMovieRepository
private val viewModel: MSMovieViewModel by viewModels {
MSMovieViewModel.MSMovieViewModelFactory(movieRepo)
}
private lateinit var listAdapter: MSMovieSearchHistoryAdapter
private lateinit var binding: ActivityMainBinding
private val historyItemClick = MutableSharedFlow()
private val spinner: CircularProgressDrawable by lazy {
val circularProgressDrawable = CircularProgressDrawable(this)
circularProgressDrawable.strokeWidth = 5f
circularProgressDrawable.centerRadius = 30f
circularProgressDrawable.start()
circularProgressDrawable
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val appComponent = AppComponent.from(this)
movieRepo = appComponent.movieRepository
setupListView()
binding.longRunningTask.setOnClickListener {
GlobalScope.launch(Dispatchers.Default) {
viewModel.processInput(MSMovieEvent.LongRunningEvent)
}
}
viewModel.viewState
.onEach { render(it) }
.catch { Timber.e(it, "error rendering view state") }
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.launchIn(lifecycleScope)
viewModel.effects
.onEach { trigger(it) }
.catch { Timber.e(it, "error triggering side effect") }
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.launchIn(lifecycleScope)
}
override fun onResume() {
super.onResume()
val screenLoadEvents = flowOf(ScreenLoadEvent)
val searchMovieEvents =
binding.msMainScreenSearchBtn.clicks().map {
SearchMovieEvent(binding.msMainScreenSearchText.text.toString())
}
val addToHistoryEvents =
binding.msMainScreenPoster.clicks().map {
binding.msMainScreenPoster.growShrink()
AddToHistoryEvent(binding.msMainScreenPoster.getTag(R.id.TAG_MOVIE_DATA) as MSMovie)
}
val restoreFromHistoryEvents = historyItemClick.map { RestoreFromHistoryEvent(it) }
merge(
screenLoadEvents,
searchMovieEvents,
addToHistoryEvents,
restoreFromHistoryEvents,
)
.onEach { viewModel.processInput(it) }
.catch { Timber.e(it, "error processing input ") }
.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
.launchIn(lifecycleScope)
}
private fun trigger(effect: MSMovieEffect) {
Timber.d("----- [trigger] ${Thread.currentThread().name}")
when (effect) {
is MSMovieEffect.AddedToHistoryToastEffect -> {
Toast.makeText(this, "added to history", Toast.LENGTH_SHORT).show()
}
}
}
private fun render(vs: MSMovieViewState) {
Timber.d("----- [render] ${Thread.currentThread().name}")
vs.searchBoxText.let { binding.msMainScreenSearchText.setText(it) }
binding.msMainScreenTitle.text = vs.searchedMovieTitle
binding.msMainScreenRating.text = vs.searchedMovieRating
vs.searchedMoviePoster
.takeIf { it.isNotBlank() }
?.let {
binding.msMainScreenPoster.load(vs.searchedMoviePoster) { placeholder(spinner) }
binding.msMainScreenPoster.setTag(R.id.TAG_MOVIE_DATA, vs.searchedMovieReference)
}
?: run { binding.msMainScreenPoster.setImageResource(0) }
listAdapter.submitList(vs.adapterList)
}
private fun setupListView() {
val layoutManager = LinearLayoutManager(this, HORIZONTAL, false)
binding.msMainScreenSearchHistory.layoutManager = layoutManager
val dividerItemDecoration = DividerItemDecoration(this, HORIZONTAL)
dividerItemDecoration.setDrawable(
ContextCompat.getDrawable(this, R.drawable.ms_list_divider_space)!!,
)
binding.msMainScreenSearchHistory.addItemDecoration(dividerItemDecoration)
listAdapter = MSMovieSearchHistoryAdapter {
lifecycleScope.launch { historyItemClick.emit(it) }
}
binding.msMainScreenSearchHistory.adapter = listAdapter
}
}
================================================
FILE: app/src/main/java/co/kaush/msusf/movies/MSMovieApi.kt
================================================
package co.kaush.msusf.movies
import com.google.gson.annotations.SerializedName
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
interface MSMovieApi {
@GET("/")
suspend fun searchMovie(
@Query("t") movieName: String,
@Query("apiKey") apiKey: String = BuildConfig.OMDB_API_KEY
): Response
}
data class MSMovie(
@SerializedName("Result") val result: Boolean,
@SerializedName("Error") val errorMessage: String? = null,
@SerializedName("Title") val title: String = "",
@SerializedName("Poster") val posterUrl: String = "",
@SerializedName("Ratings") val ratings: List = emptyList()
) {
val ratingSummary: String
get() {
return ratings.fold("") { summary, msRating -> "$summary\n${msRating.summary}" }
}
}
data class MSRating(
@SerializedName("Source") val source: String,
@SerializedName("Value") val rating: String
) {
val summary: String
get() = "$rating (${sourceShortName(source)})"
private fun sourceShortName(ratingSource: String): String {
return when {
ratingSource.contains("Internet Movie Database") -> "IMDB"
ratingSource.contains("Rotten Tomatoes") -> "RT"
ratingSource.contains("Metacritic") -> "Metac"
else -> ratingSource
}
}
}
================================================
FILE: app/src/main/java/co/kaush/msusf/movies/MSMovieRepository.kt
================================================
package co.kaush.msusf.movies
import co.kaush.msusf.di.AppScope
import com.google.gson.Gson
import me.tatarka.inject.annotations.Inject
class MSMovieRepository @AppScope @Inject constructor(val movieApi: MSMovieApi) {
suspend fun searchMovie(movieName: String): MSMovie? {
val response = movieApi.searchMovie(movieName)
response.body()?.let {
return it
}
return response.errorBody()?.let { body ->
Gson()
.fromJson(
body.string(),
MSMovie::class.java,
)
}
}
}
================================================
FILE: app/src/main/java/co/kaush/msusf/movies/MSMovieResult.kt
================================================
package co.kaush.msusf.movies
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
sealed class MSMovieResult {
abstract val loading: Boolean
abstract val errorMessage: String
abstract fun toViewState(currentViewState: MSMovieViewState): MSMovieViewState
abstract fun toEffects(): Flow
data class ScreenLoadResult(
override val loading: Boolean = false,
override val errorMessage: String = "",
) : MSMovieResult() {
override fun toViewState(currentViewState: MSMovieViewState): MSMovieViewState =
currentViewState.copy(searchBoxText = "")
override fun toEffects(): Flow = emptyFlow()
}
data class SearchMovieResult(
override val loading: Boolean = false,
override val errorMessage: String = "",
val movie: MSMovie? = null,
) : MSMovieResult() {
override fun toViewState(currentViewState: MSMovieViewState): MSMovieViewState {
val movie: MSMovie = movie!!
return currentViewState.copy(
searchBoxText = movie.title,
searchedMovieTitle = movie.title,
searchedMovieRating = movie.ratingSummary,
searchedMoviePoster = movie.posterUrl,
searchedMovieReference = movie,
)
}
override fun toEffects(): Flow = emptyFlow()
}
data class AddToHistoryResult(
override val loading: Boolean = false,
override val errorMessage: String = "",
val movie: MSMovie? = null,
) : MSMovieResult() {
override fun toViewState(currentViewState: MSMovieViewState): MSMovieViewState {
val movie: MSMovie = movie!!
return if (!currentViewState.adapterList.contains(movie)) {
currentViewState.copy(adapterList = currentViewState.adapterList.plus(movie))
} else currentViewState.copy()
}
override fun toEffects(): Flow {
return flow { emit(MSMovieEffect.AddedToHistoryToastEffect) }
}
}
}
================================================
FILE: app/src/main/java/co/kaush/msusf/movies/MSMovieSearchHistoryAdapter.kt
================================================
package co.kaush.msusf.movies
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import coil.load
class MSMovieSearchHistoryAdapter(private val historyClickListener: (MSMovie) -> Unit) :
ListAdapter(MSMovieSearchDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MSMovieSearchVH {
return MSMovieSearchVH(parent.inflate(R.layout.view_movie))
}
override fun onBindViewHolder(holder: MSMovieSearchVH, position: Int) {
holder.bind(getItem(position), historyClickListener)
}
}
class MSMovieSearchDiffCallback : DiffUtil.ItemCallback() {
// only one kind of item
override fun areItemsTheSame(oldItem: MSMovie, newItem: MSMovie): Boolean = true
override fun areContentsTheSame(oldItem: MSMovie, newItem: MSMovie): Boolean {
// this is just lazy!
return oldItem.posterUrl.equals(newItem.posterUrl, ignoreCase = true)
}
}
class MSMovieSearchVH(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val posterView: ImageView = itemView.findViewById(R.id.ms_result_poster)
private val ratingView: TextView = itemView.findViewById(R.id.ms_result_rating)
private val spinner: CircularProgressDrawable by lazy {
val circularProgressDrawable = CircularProgressDrawable(itemView.context)
circularProgressDrawable.strokeWidth = 5f
circularProgressDrawable.centerRadius = 30f
circularProgressDrawable.start()
circularProgressDrawable
}
@SuppressLint("SetTextI18n")
fun bind(item: MSMovie, historyClickListener: (MSMovie) -> Unit) {
(item.ratings.first()).let { ratingView.text = it.summary }
item.posterUrl.takeIf { it.isNotBlank() }?.let { posterView.load(it) { placeholder(spinner) } }
?: run { posterView.setImageResource(0) }
itemView.setOnClickListener {
historyClickListener.invoke(item)
posterView.growShrink()
}
}
}
// -----------------------------------------------------------------------------------
// helpers
/**
* Usage: `val view = container?.inflate(R.layout.activity)`
* `inflater?.inflate(R.layout.fragment_dialog_standard, c)!!`
*/
private fun ViewGroup.inflate(@LayoutRes layoutRes: Int, attachToRoot: Boolean = false): View =
LayoutInflater.from(context).inflate(layoutRes, this, attachToRoot)
================================================
FILE: app/src/main/java/co/kaush/msusf/movies/MSMovieViewModel.kt
================================================
package co.kaush.msusf.movies
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.Factory
import androidx.lifecycle.viewModelScope
import java.lang.Class
import kotlin.Suppress
import kotlin.Unit
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
public class MSMovieViewModel(
movieRepo: MSMovieRepository,
) : ViewModel() {
private val MSMovieViewModelImpl: MSMovieViewModelImpl =
MSMovieViewModelImpl(movieRepo = movieRepo, coroutineScope = viewModelScope)
public val effects: SharedFlow
get() = MSMovieViewModelImpl.effects
public val viewState: StateFlow
get() = MSMovieViewModelImpl.viewState
public fun processInput(event: MSMovieEvent): Unit =
MSMovieViewModelImpl.processInput(event = event)
public class MSMovieViewModelFactory(
private val movieRepo: MSMovieRepository,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
public override fun create(modelClass: Class): T =
MSMovieViewModel(movieRepo = movieRepo) as T
}
}
================================================
FILE: app/src/main/java/co/kaush/msusf/movies/MSMovieViewModelImpl.kt
================================================
package co.kaush.msusf.movies
import co.kaush.msusf.movies.MSMovieEvent.LongRunningEvent
import co.kaush.usf.UsfViewModelImpl
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
// @UsfViewModel
class MSMovieViewModelImpl(
private val movieRepo: MSMovieRepository,
coroutineScope: CoroutineScope,
dispatcher: CoroutineDispatcher = Dispatchers.IO,
) :
UsfViewModelImpl(
MSMovieViewState(),
coroutineScope,
dispatcher,
) {
// -----------------------------------------------------------------------------------
// Event -> Results
override fun eventToResultFlow(event: MSMovieEvent): Flow {
return when (event) {
is MSMovieEvent.ScreenLoadEvent -> onScreenLoad()
is MSMovieEvent.SearchMovieEvent -> onSearchMovie(event)
is MSMovieEvent.AddToHistoryEvent -> onAddToHistory(event)
is MSMovieEvent.RestoreFromHistoryEvent -> onRestoreFromHistory(event)
is LongRunningEvent -> onLongRunningTask(event)
}
}
private fun onScreenLoad(): Flow = flow { emit(MSMovieResult.ScreenLoadResult()) }
private fun onLongRunningTask(event: LongRunningEvent): Flow {
return flow {
delay(2000)
emit(
MSMovieResult.SearchMovieResult(
movie =
MSMovie(
true,
title = "Batman (1989)",
posterUrl =
"data:image/jpeg;base64,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",
),
),
)
}
}
private fun onSearchMovie(event: MSMovieEvent.SearchMovieEvent): Flow {
return flow {
emit(MSMovieResult.SearchMovieResult(loading = true))
try {
val movie = movieRepo.searchMovie(event.searchedMovieTitle)
emit(
MSMovieResult.SearchMovieResult(
movie = movie,
errorMessage = movie?.errorMessage ?: "",
),
)
} catch (e: Exception) {
emit(
MSMovieResult.SearchMovieResult(
movie = MSMovie(result = false, errorMessage = e.localizedMessage),
errorMessage = e.localizedMessage ?: "",
),
)
}
}
}
private fun onAddToHistory(event: MSMovieEvent.AddToHistoryEvent): Flow {
return flow { emit(MSMovieResult.AddToHistoryResult(movie = event.searchedMovie)) }
}
private fun onRestoreFromHistory(
event: MSMovieEvent.RestoreFromHistoryEvent
): Flow {
return flow { emit(MSMovieResult.SearchMovieResult(movie = event.movieFromHistory)) }
}
// -----------------------------------------------------------------------------------
// Results -> ViewState
override fun resultToViewState(
currentViewState: MSMovieViewState,
result: MSMovieResult
): MSMovieViewState {
return when {
result.loading -> {
currentViewState.copy(
searchBoxText = "",
searchedMovieTitle = "Searching Movie...",
searchedMovieRating = "",
searchedMoviePoster = "",
searchedMovieReference = null,
)
}
result.errorMessage.isNotBlank() -> {
currentViewState.copy(searchedMovieTitle = result.errorMessage)
}
else -> result.toViewState(currentViewState)
}
}
// -----------------------------------------------------------------------------------
// Results -> Effect
override fun resultToEffects(result: MSMovieResult): Flow = result.toEffects()
}
================================================
FILE: app/src/main/java/co/kaush/msusf/movies/MSMovieViewState.kt
================================================
package co.kaush.msusf.movies
data class MSMovieViewState(
val searchBoxText: String = "Blade",
val searchedMovieTitle: String = "",
val searchedMovieRating: String = "",
val searchedMoviePoster: String = "",
val searchedMovieReference: MSMovie? = null,
val adapterList: List = emptyList()
)
sealed class MSMovieEffect {
object AddedToHistoryToastEffect : MSMovieEffect()
}
sealed class MSMovieEvent {
object ScreenLoadEvent : MSMovieEvent()
object LongRunningEvent : MSMovieEvent()
data class SearchMovieEvent(val searchedMovieTitle: String = "") : MSMovieEvent()
data class AddToHistoryEvent(val searchedMovie: MSMovie) : MSMovieEvent()
data class RestoreFromHistoryEvent(val movieFromHistory: MSMovie) : MSMovieEvent()
}
================================================
FILE: app/src/main/res/drawable/ic_launcher_background.xml
================================================
================================================
FILE: app/src/main/res/drawable/ms_list_divider_space.xml
================================================
================================================
FILE: app/src/main/res/drawable-v24/ic_launcher_foreground.xml
================================================
================================================
FILE: app/src/main/res/layout/activity_main.xml
================================================
================================================
FILE: app/src/main/res/layout/view_movie.xml
================================================
================================================
FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
================================================
FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
================================================
================================================
FILE: app/src/main/res/values/colors.xml
================================================
#3F51B5#303F9F#FF4081#fff
================================================
FILE: app/src/main/res/values/dimens.xml
================================================
12dp10dp0dp20sp30sp
================================================
FILE: app/src/main/res/values/strings.xml
================================================
Movie Searching through USFSearch
================================================
FILE: app/src/main/res/values/styles.xml
================================================
================================================
FILE: app/src/main/res/values/tags.xml
================================================
================================================
FILE: app/src/main/res/xml/network_security_config.xml
================================================
omdbapi.com
================================================
FILE: app/src/test/java/co/kaush/msusf/movies/CoroutineTestRule.kt
================================================
package co.kaush.msusf.movies
/*
* Copyright 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext
/**
* Primary rule for Coroutine Tests.
*
* @constructor Create empty Test coroutine rule
*
* Use it as follows
*
* ```kotlin
* class MyViewModelTest {
* @RegisterExtension val testRule = CoroutineTestRule()
* }
* ```
*/
@ExperimentalCoroutinesApi
class CoroutineTestRule(
private val scheduler: TestCoroutineScheduler? =
null, // if you want multiple types of dispatchers
private val dispatcher: TestDispatcher? = null,
) : BeforeEachCallback, AfterEachCallback {
val testDispatcher by lazy {
when {
dispatcher != null -> dispatcher
scheduler != null -> StandardTestDispatcher(scheduler)
else -> StandardTestDispatcher()
}
}
override fun beforeEach(p0: ExtensionContext?) {
// ⚠️ Calling this with a TestDispatcher has special behavior:
// subsequently-called runTest, as well as TestScope and test dispatcher constructors,
// will use the TestCoroutineScheduler of the provided dispatcher.
// This means in runTest you don't have to
Dispatchers.setMain(testDispatcher)
}
override fun afterEach(p0: ExtensionContext?) {
Dispatchers.resetMain()
}
fun currentTestTime(): Long {
return testDispatcher.scheduler.currentTime
}
}
================================================
FILE: app/src/test/java/co/kaush/msusf/movies/MSMovieViewModelTest.kt
================================================
package co.kaush.msusf.movies
import app.cash.turbine.test
import co.kaush.msusf.movies.MSMovieEffect.AddedToHistoryToastEffect
import co.kaush.msusf.movies.MSMovieEvent.AddToHistoryEvent
import co.kaush.msusf.movies.MSMovieEvent.RestoreFromHistoryEvent
import co.kaush.msusf.movies.MSMovieEvent.ScreenLoadEvent
import co.kaush.msusf.movies.MSMovieEvent.SearchMovieEvent
import co.kaush.msusf.movies.di.TestAppComponent
import co.kaush.msusf.movies.di.blade
import co.kaush.msusf.movies.di.bladeRunner2049
import co.kaush.msusf.movies.di.create
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
@OptIn(ExperimentalCoroutinesApi::class)
class MSMovieViewModelTest {
// Set the main coroutines dispatcher for unit testing.
@OptIn(ExperimentalCoroutinesApi::class)
@JvmField
@RegisterExtension
val testRule = CoroutineTestRule()
// Subject under test
private lateinit var viewModel: MSMovieViewModelImpl
// Use a fake repository to be injected into the viewModel
private val testAppComponent = TestAppComponent::class.create()
private var fakeMovieAppRepository: MSMovieRepository = testAppComponent.movieRepository
@Test
@DisplayName("on init, initial view state should have Blade pre-populated")
fun onSubscription_InitialStateIsEmitted() = runTest {
viewModel = createTestViewModel()
val vs = viewModel.viewState.first()
assertThat(vs.searchBoxText).isEqualTo("Blade")
}
@Test
@DisplayName("on screen load, search box test should be cleared")
fun onScreenLoad_searchBoxText_shouldBeCleared() = runTest {
val vm = createTestViewModel()
assertThat(vm.viewState.first().searchBoxText).isEqualTo("Blade") // starts off with blade
vm.processInput(ScreenLoadEvent)
runCurrent()
assertThat(vm.viewState.first().searchBoxText).isEmpty()
}
@Test
@DisplayName("on screen load, search box test should be cleared - using turbine")
fun onScreenLoad_searchBoxText_shouldBeCleared_2() =
// Functionally the exact same test as the previous one
// we use turbine here as a demonstration
runTest {
viewModel = createTestViewModel()
viewModel.viewState.test() {
assertThat(awaitItem().searchBoxText).isEqualTo("Blade") // starts off with blade
viewModel.processInput(ScreenLoadEvent)
// notice that we don't need to runCurrent()
// runCurrent()
// this is because Turbine uses an UnconfinedTestDispatcher internally
// https://github.com/cashapp/turbine/blob/trunk/src/commonMain/kotlin/app/cash/turbine/flow.kt#L199-L201
assertThat(awaitItem().searchBoxText).isEmpty()
}
}
@Test
@DisplayName("on search movie, show loading indicator")
fun onSearchingMovie_showLoadingIndicator_ThenResult() = runTest {
viewModel = createTestViewModel()
viewModel.viewState.test {
skipItems(1) // starting state
viewModel.processInput(SearchMovieEvent("blade runner 2049"))
assertThat(awaitItem().searchedMovieTitle).isEqualTo("Searching Movie...")
with(awaitItem()) {
assertThat(searchedMovieTitle).isEqualTo("Blade Runner 2049")
assertThat(searchedMoviePoster)
.isEqualTo(
"https://m.media-amazon.com/images/M/MV5BNzA1Njg4NzYxOV5BMl5BanBnXkFtZTgwODk5NjU3MzI@._V1_SX300.jpg",
)
assertThat(searchedMovieRating).isEqualTo("\n8.1/10 (IMDB)\n87% (RT)")
}
expectNoEvents()
}
}
@Test
@DisplayName("click search result, should show it in history")
fun onClickingMovieSearchResult_shouldPopulateHistoryList() = runTest {
viewModel = createTestViewModel()
val vs = mutableListOf()
val ve = mutableListOf()
backgroundScope.launch { viewModel.viewState.toList(vs) }
backgroundScope.launch { viewModel.effects.toList(ve) }
viewModel.processInput(SearchMovieEvent("blade runner 2049"))
viewModel.processInput(AddToHistoryEvent(bladeRunner2049))
runCurrent()
vs[1].let {
assertThat(it.adapterList).hasSize(1)
assertThat(it.adapterList[0]).isEqualTo(bladeRunner2049)
}
assertThat(ve[0]).isEqualTo(AddedToHistoryToastEffect)
}
@Test
@DisplayName("adding to history twice, should show two toasts")
fun onClickingMovieSearchResultTwice_shouldShowToastEachTime() = runTest {
viewModel = createTestViewModel()
val ve = mutableListOf()
backgroundScope.launch { viewModel.effects.toList(ve) }
viewModel.processInput(SearchMovieEvent("blade runner 2049"))
assertThat(ve.size).isEqualTo(0)
viewModel.processInput(AddToHistoryEvent(bladeRunner2049))
runCurrent()
assertThat(ve[0]).isEqualTo(AddedToHistoryToastEffect)
viewModel.processInput(AddToHistoryEvent(bladeRunner2049))
runCurrent()
assertThat(ve[1]).isEqualTo(AddedToHistoryToastEffect)
}
@Test
fun onClickingMovieHistoryResult_ResultViewIsRepopulatedWithInfo() = runTest {
viewModel = createTestViewModel()
viewModel.viewState.test {
skipItems(1) // starting state
// populate history
viewModel.processInput(SearchMovieEvent("blade runner 2049"))
skipItems(2)
viewModel.processInput(SearchMovieEvent("blade"))
skipItems(2)
// click blade runner 2049 from history
viewModel.processInput(RestoreFromHistoryEvent(bladeRunner2049))
with(awaitItem()) {
assertThat(searchedMovieTitle).isEqualTo("Blade Runner 2049")
assertThat(searchedMovieRating).isEqualTo(bladeRunner2049.ratingSummary)
}
// click blade again
viewModel.processInput(RestoreFromHistoryEvent(blade))
with(awaitItem()) {
assertThat(searchedMovieTitle).isEqualTo("Blade")
assertThat(searchedMovieRating).isEqualTo(blade.ratingSummary)
}
}
}
private fun TestScope.createTestViewModel() =
MSMovieViewModelImpl(fakeMovieAppRepository, backgroundScope, testRule.testDispatcher)
}
================================================
FILE: app/src/test/java/co/kaush/msusf/movies/di/TestMSAppDI.kt
================================================
package co.kaush.msusf.movies.di
import co.kaush.msusf.di.AppScope
import co.kaush.msusf.di.CommonAppComponent
import co.kaush.msusf.movies.MSMovie
import co.kaush.msusf.movies.MSMovieRepository
import co.kaush.msusf.movies.MSRating
import kotlinx.coroutines.runBlocking
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
internal val bladeRunner2049 by lazy {
val ratingImdb =
MSRating(
source = "Internet Movie Database",
rating = "8.1/10",
)
val ratingRottenTomatoes =
MSRating(
source = "Rotten Tomatoes",
rating = "87%",
)
MSMovie(
result = true,
errorMessage = null,
title = "Blade Runner 2049",
ratings = listOf(ratingImdb, ratingRottenTomatoes),
posterUrl =
"https://m.media-amazon.com/images/M/MV5BNzA1Njg4NzYxOV5BMl5BanBnXkFtZTgwODk5NjU3MzI@._V1_SX300.jpg",
)
}
internal val blade by lazy {
val ratingImdb =
MSRating(
source = "Internet Movie Database",
rating = "7.1/10",
)
val ratingRottenTomatoes =
MSRating(
source = "Rotten Tomatoes",
rating = "54%",
)
MSMovie(
result = true,
errorMessage = null,
title = "Blade",
ratings = listOf(ratingImdb, ratingRottenTomatoes),
posterUrl =
"https://m.media-amazon.com/images/M/MV5BMTQ4MzkzNjcxNV5BMl5BanBnXkFtZTcwNzk4NTU0Mg@@._V1_SX300.jpg",
)
}
internal val movieNotFound by lazy {
val ratingImdb =
MSRating(
source = "Internet Movie Database",
rating = "8.1/10",
)
val ratingRottenTomatoes =
MSRating(
source = "Rotten Tomatoes",
rating = "87%",
)
MSMovie(
result = true,
errorMessage = null,
title = "Blade Runner 2049",
ratings = listOf(ratingImdb, ratingRottenTomatoes),
posterUrl =
"https://m.media-amazon.com/images/M/MV5BNzA1Njg4NzYxOV5BMl5BanBnXkFtZTgwODk5NjU3MzI@._V1_SX300.jpg",
)
}
class TestFakes {
@Provides
fun provideFakeMovieRepository(): MSMovieRepository {
return mock {
on { runBlocking { searchMovie("blade runner 2049") } } doReturn bladeRunner2049
on { runBlocking { searchMovie("blade") } } doReturn blade
}
}
}
@AppScope
@Component
abstract class TestAppComponent(@Component val fakes: TestFakes = TestFakes()) :
CommonAppComponent()
================================================
FILE: build.gradle.kts
================================================
@file:Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed (or agp 8.1)
plugins {
alias(libs.plugins.spotless)
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.android) apply false
// https://youtrack.jetbrains.com/issue/KT-46200
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.secrets.gradle.plugin) apply false
}
spotless {
kotlin {
target("**/*.kt")
targetExclude("**/build/**/*.kt")
ktfmt("0.43")
}
format("kts") {
target("**/*.gradle.kts")
targetExclude("**/build/**/*.kts")
}
}
================================================
FILE: gradle/libs.versions.toml
================================================
[versions]
#region ------------------ VERSIONS --------------------------
compileSdk = "34"
minSdk = "21"
appVersionCode = "2"
appVersionName = "23.063.000"
gradle = "8.7" # Gradle
android-gradle-plugin = "8.3.2" # AGP
kotlin = "1.9.23"
kotlin-coroutines = "1.8.0"
kotlin-serialization = "1.6.3"
kotlin-compiler = "1.5.11"
kotlin-bcv = "0.13.2"
androidx-annotation = "1.7.1"
androidx-compose-bom = "2024.04.01" # https://developer.android.com/jetpack/compose/bom/bom-mapping
androidx-compose-compiler = "1.4.2" # https://developer.android.com/jetpack/androidx/releases/compose-kotlin#kts
androidx-compose = "1.6.5"
androidx-constraintlayout = "2.1.4"
androidx-datastore = "1.1.0"
androidx-lifecycle = "2.7.0"
androidx-navigation = "2.7.7"
androidx-work = "2.9.0"
androidx-room = "2.6.1"
androidx-appcompat = "1.1.0"
androidx-test = "1.5.2"
androidx-activity = "1.7.2"
firebase-bom = "32.8.1"
firebase-crashlytics = "18.6.4"
firebase-crashlytics-plugin = "2.9.9"
google-ksp = "1.9.23-1.0.19" # first half is kotlin version
google-protobuf = "3.25.3"
google-gms = "4.4.1"
google-dagger = "2.51.1"
assertj = "3.25.3"
anvil = "2.4.9"
coil = "2.6.0"
datadog = "2.8.0"
android-desugar-jdk = "2.0.4"
espresso = "3.3.0"
flowbinding = "1.2.0"
grcp = "1.63.0"
jackson = "2.17.0"
jacoco = "0.8.12"
junit = "4.12"
junit-platform = "1.10.0.0"
junit5 = "5.10.2"
kotlinInject = "0.6.1"
kotlinpoet = "1.12.0"
leakCanary = "2.11"
lottie = "6.4.0"
mockito = "5.11.0"
mockitoKotlin = "5.0.0"
okhttp = "4.12.0"
paparazzi = "1.3.3"
picasso = "2.71828"
retrofit = "2.11.0"
secretsGradlePlugin = "2.0.1"
spotless = "6.15.0"
timber = "4.7.1"
trueTime = "4.0.0.alpha"
truth = "0.42"
turbine = "1.0.0"
#endregion
[plugins]
#region ------------------ PLUGINS --------------------------
android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" }
android-test = { id = "com.android.test", version.ref = "android-gradle-plugin" }
android-baseline = { id = "androidx.baselineprofile", version = "1.2.4" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-allopen = { id = "org.jetbrains.kotlin.plugin.allopen", version.ref = "kotlin" } #
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
kotlin-kovert = { id = "org.jetbrains.kotlinx.kovert", version = "0.8.0-Beta2" }
kotlin-lombok = { id = "org.jetbrains.kotlin.plugin.lombok", version.ref = "kotlin" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
kotlin-samreceiver = { id = "org.jetbrains.kotlin.plugin.sam.with.receiver", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-compatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "kotlin-bcv" }
google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" }
google-firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebase-crashlytics-plugin" }
google-protobuf = { id = "com.google.protobuf", version = "google-protobuf" }
google-services = { id = "com.google.gms.google-services", version = "google-gms" }
mannodermaus-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "junit-platform" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
secrets-gradle-plugin = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsGradlePlugin" }
#endregion
[libraries]
#region ------------------ SINGLE ARTIFACT DEPENDENCIES --------------------------
coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
android-desugar-jdk = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "android-desugar-jdk" }
flow-binding = { group = "io.github.reactivecircus.flowbinding", name = "flowbinding-android", version.ref = "flowbinding" }
instacart-truetime = { group = "com.github.instacart", name = "truetime-android", version.ref = "trueTime" }
testing-assertj-core = { group = "org.assertj", name = "assertj-core", version.ref = "assertj" }
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
testing-turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
#endregion
#region ------------------ FAMILY ARTIFACT DEPENDENCIES --------------------------
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
androidx-atsl-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-ui-ui = { module = "androidx.compose.ui:ui" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
androidx-lifecycle-compiler = { group = "androidx.lifecycle", name = "lifecycle-compiler", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-savedstate = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "androidx-appcompat" }
androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "androidx-appcompat" }
google-dagger = { group = "com.google.dagger", name = "dagger", version.ref = "google-dagger" }
google-dagger-compiler = { group = "com.google.dagger", name = "dagger-compiler", version.ref = "google-dagger" }
google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
google-ksp = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref = "google-ksp" }
google-firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" }
google-firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebase-crashlytics" }
google-firebase-ndk = { group = "com.google.firebase", name = "firebase-crashlytics-ndk", version.ref = "firebase-crashlytics" }
testing-junit5-bom = { group = "org.junit", name = "junit-bom", version.ref = "junit5" }
testing-junit5 = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit5" }
testing-junit5-accesor = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit5" }
testing-junit5-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit5" }
testing-junit5-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit5" }
testing-junit5-migrationsupport = { group = "org.junit.jupiter", name = "junit-jupiter-migrationsupport", version.ref = "junit5" }
testing-junit5-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junit5" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk8", version.ref = "kotlin" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlin-coroutines" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlin-coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlin-coroutines" }
square-kotlinpoet-core = { group = "com.squareup", name = "kotlinpoet", version.ref = "kotlinpoet" }
square-kotlinpoet-ksp = { group = "com.squareup", name = "kotlinpoet-ksp", version.ref = "kotlinpoet" }
square-leakcanary = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "leakCanary" }
square-leakcanary-noop = { group = "com.squareup.leakcanary", name = "leakcanary-android-no-op", version.ref = "leakCanary" }
square-okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" }
square-okhttp = { group = "com.squareup.okhttp3", name = "okhttp" }
square-okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor" }
square-picasso = { group = "com.squareup.picasso", name = "picasso", version.ref = "picasso" }
square-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
square-retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
square-retrofit-mock = { group = "com.squareup.retrofit2", name = "retrofit-mock", version.ref = "retrofit" }
kotlin-inject-compiler = { group = "me.tatarka.inject", name = "kotlin-inject-compiler-ksp", version.ref = "kotlinInject" }
kotlin-inject-runtime = { group = "me.tatarka.inject", name = "kotlin-inject-runtime", version.ref = "kotlinInject" }
mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" }
mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockitoKotlin" }
#endregion
#TODO: arrange all this and rename it better
================================================
FILE: gradle/wrapper/gradle-wrapper.properties
================================================
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
================================================
FILE: gradle.properties
================================================
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
android.useAndroidX=true
android.enableJetifier=true
================================================
FILE: gradlew
================================================
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
================================================
FILE: gradlew.bat
================================================
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
================================================
FILE: settings.gradle.kts
================================================
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
gradlePluginPortal()
google()
mavenCentral()
maven("https://jitpack.io")
maven("https://oss.sonatype.org/content/repositories/snapshots")
mavenLocal()
}
}
rootProject.name = "Movies USF Android"
include(":app")
include(
":usf:annotations",
":usf:annotations-processors",
":usf:api",
)
================================================
FILE: usf/annotations/build.gradle.kts
================================================
@file:Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed (or agp 8.1)
plugins { alias(libs.plugins.kotlin.jvm) }
================================================
FILE: usf/annotations/src/main/java/co/kaush/msusf/annotations/UsfViewModel.kt
================================================
package co.kaush.msusf.annotations
@Target(AnnotationTarget.CLASS) // targeting classes
@Retention(AnnotationRetention.SOURCE) // valid in compile time and removed in binary output
@MustBeDocumented // allows annotation to be included in generated docs
annotation class UsfViewModel
================================================
FILE: usf/annotations-processors/build.gradle.kts
================================================
@file:Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed (or agp 8.1)
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.google.ksp)
}
dependencies {
implementation(project(":usf:annotations"))
implementation(libs.google.ksp)
implementation(libs.square.kotlinpoet.core)
implementation(libs.square.kotlinpoet.ksp)
}
================================================
FILE: usf/annotations-processors/src/main/java/co/kaush/msusf/processors/UsfViewModelClassBuilderDefinition.kt
================================================
package co.kaush.msusf.processors
import com.squareup.kotlinpoet.TypeName
data class UsfViewModelClassBuilderDefinition(
val parameters: List,
val functions: List,
val properties: List,
val viewModel: ParametersDefinition
) {
val simplifiedClassName: String
get() {
val vmConstant = "ViewModel"
val className = this.viewModel.paramName
val indexOfViewModel = className.indexOf(vmConstant)
return if (indexOfViewModel != -1) {
className.substring(0, indexOfViewModel + vmConstant.length)
} else {
className + "GenViewModel"
}
}
}
data class FunctionsDefinition(
val name: String,
val parameters: List,
val returnType: TypeName?
)
data class ParametersDefinition(
val paramName: String,
val paramType: TypeName,
)
================================================
FILE: usf/annotations-processors/src/main/java/co/kaush/msusf/processors/UsfViewModelFileBuilder.kt
================================================
package co.kaush.msusf.processors
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.asClassName
/** This is the primary class that builds out the structure of the ViewModel class */
object UsfViewModelFileBuilder {
private const val androidxPackage = "androidx.lifecycle"
private val androidViewModelTypeClassName = ClassName(androidxPackage, "ViewModel")
private val viewModelProviderTypeClassName =
ClassName(androidxPackage, "ViewModelProvider.Factory")
private const val kotlinxPackage = "kotlinx.coroutines"
private val coroutineScopeClassName = ClassName(kotlinxPackage, "CoroutineScope")
private const val viewModelScopeReservedWord = "viewModelScope"
/** File level structure */
fun buildFileSpec(
viewModelClassBuilderDefinition: UsfViewModelClassBuilderDefinition,
packageName: String,
): FileSpec {
return FileSpec.builder(packageName, viewModelClassBuilderDefinition.simplifiedClassName)
.addType(buildAndroidViewModelClassSpec(viewModelClassBuilderDefinition))
.addImport(androidxPackage, "viewModelScope", "ViewModelProvider")
.build()
}
/** Class level structure */
private fun buildAndroidViewModelClassSpec(
viewModelClassBuilderDefinition: UsfViewModelClassBuilderDefinition,
): TypeSpec {
val classBuilder =
TypeSpec.classBuilder(viewModelClassBuilderDefinition.simplifiedClassName)
.addModifiers(KModifier.PUBLIC)
.superclass(androidViewModelTypeClassName)
val constructorBuilder = FunSpec.constructorBuilder()
val paramsForConstructor =
buildParamForwardingConstructorBody(
viewModelClassBuilderDefinition.parameters, setOf(), setOf(coroutineScopeClassName)) {
sb,
param ->
when (param.paramType) {
coroutineScopeClassName -> {
sb.append("\t${param.paramName} = $viewModelScopeReservedWord,\n")
}
else -> {
// no-op
}
}
}
addParamsToClassConstructor(
viewModelClassBuilderDefinition, constructorBuilder, classBuilder, false)
classBuilder.addProperty(
PropertySpec.builder(
viewModelClassBuilderDefinition.viewModel.paramName,
viewModelClassBuilderDefinition.viewModel.paramType,
KModifier.PRIVATE)
.initializer(
"${(viewModelClassBuilderDefinition.viewModel.paramType as? ClassName)?.simpleName}$paramsForConstructor")
.build())
classBuilder.primaryConstructor(constructorBuilder.build())
viewModelClassBuilderDefinition.functions.forEach { functionMetaData ->
val functionBuilder = FunSpec.builder(functionMetaData.name).addModifiers(KModifier.PUBLIC)
val params = functionMetaData.parameters.map { ParameterSpec(it.paramName, it.paramType) }
functionBuilder.addParameters(params)
val funcCallString =
"${viewModelClassBuilderDefinition.viewModel.paramName}.${functionMetaData.name}${buildParamForwardingConstructorBody(functionMetaData.parameters)}"
if (functionMetaData.returnType != null) {
functionBuilder.returns(functionMetaData.returnType)
}
val codeBody =
if (functionMetaData.returnType != null) {
"return $funcCallString"
} else {
funcCallString
}
functionBuilder.addCode(codeBody)
classBuilder.addFunction(functionBuilder.build())
}
viewModelClassBuilderDefinition.properties.forEach { propertyMetaData ->
val property =
PropertySpec.builder(propertyMetaData.paramName, propertyMetaData.paramType)
.getter(
FunSpec.getterBuilder()
.addStatement(
"return ${viewModelClassBuilderDefinition.viewModel.paramName}.${propertyMetaData.paramName}")
.build())
classBuilder.addProperty(property.build())
}
classBuilder.addType(buildFactory(viewModelClassBuilderDefinition))
return classBuilder.build()
}
private fun buildFactory(
ViewModelClassBuilderDefinition: UsfViewModelClassBuilderDefinition
): TypeSpec {
val classBuilder =
TypeSpec.classBuilder("${ViewModelClassBuilderDefinition.simplifiedClassName}Factory")
.addSuperinterface(viewModelProviderTypeClassName)
val constructorBuilder = FunSpec.constructorBuilder()
val paramsForConstructor =
buildParamForwardingConstructorBody(
ViewModelClassBuilderDefinition.parameters, setOf(coroutineScopeClassName), emptySet())
addParamsToClassConstructor(
ViewModelClassBuilderDefinition, constructorBuilder, classBuilder, true)
val genericViewModel =
com.squareup.kotlinpoet.TypeVariableName("T", androidViewModelTypeClassName)
val suppressAnnotation =
AnnotationSpec.Companion.builder(Suppress::class).addMember("%S", "UNCHECKED_CAST").build()
val factoryCreateFunctionSpec =
FunSpec.builder("create")
.addTypeVariable(genericViewModel)
.addAnnotation(suppressAnnotation)
.addParameter(
"modelClass", Class::class.asClassName().parameterizedBy(genericViewModel))
.addCode(
"return ${ViewModelClassBuilderDefinition.simplifiedClassName}$paramsForConstructor as T")
.returns(genericViewModel)
.addModifiers(KModifier.OVERRIDE)
classBuilder.addFunction(factoryCreateFunctionSpec.build())
classBuilder.primaryConstructor(constructorBuilder.build())
return classBuilder.build()
}
private fun addParamsToClassConstructor(
ViewModelClassBuilderDefinition: UsfViewModelClassBuilderDefinition,
constructorBuilder: FunSpec.Builder,
classBuilder: TypeSpec.Builder,
addAsProperty: Boolean
) {
for (paramMetaData in ViewModelClassBuilderDefinition.parameters) {
// TODO: better way to do this
if (paramMetaData.paramType == coroutineScopeClassName) {
continue
}
constructorBuilder.addParameter(paramMetaData.paramName, paramMetaData.paramType)
if (addAsProperty) {
classBuilder.addProperty(
PropertySpec.builder(
paramMetaData.paramName, paramMetaData.paramType, KModifier.PRIVATE)
.initializer(paramMetaData.paramName)
.build())
}
}
}
private fun buildParamForwardingConstructorBody(
params: List,
ignoreList: Set = emptySet(),
overrides: Set = emptySet(),
overrideAction: ((StringBuilder, ParametersDefinition) -> Unit)? = null
): String {
return buildString {
append("(\n")
for (param in params) {
if (ignoreList.contains(param.paramType)) {
continue
}
if (overrides.contains(param.paramType)) {
overrideAction?.let { it(this, param) }
continue
}
append("\t${param.paramName} = ${param.paramName},\n")
}
if (params.isNotEmpty()) {
delete(length - 2, length)
}
append("\n)")
}
}
}
================================================
FILE: usf/annotations-processors/src/main/java/co/kaush/msusf/processors/UsfViewModelProcessor.kt
================================================
package co.kaush.msusf.processor
import co.kaush.msusf.annotations.UsfViewModel
import co.kaush.msusf.processors.UsfViewModelVisitor
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.validate
import com.squareup.kotlinpoet.asClassName
/**
* This processor handles all classes annotated with @[UsfViewModel].
*
* You typically annotate a ViewModel implementation class (*VMImpl) with @[UsfViewModel]. The
* processor then goes on to generate the corresponding *VM boilerplate code necessary to access the
* ViewModel from an Activity/Fragment.
*
* @param codeGenerator [CodeGenerator] responsible for creating the files that will be generated
* using the code from the processor
* @param logger [KSPLogger] responsible for logging messages to the console (errors/warnings)
*/
class UsfViewModelProcessor(
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger,
private val options: Map,
) : SymbolProcessor {
/**
* This function contains the main logic to process the [ViewModel] annotation.
*
* @param resolver [Resolver] helps you grab a bunch of info on class annotated with
* [UsfViewModel] including classes, properties, functions (each having their own
* [KDeclaration])
*/
override fun process(resolver: Resolver): List {
val viewModelClassName = UsfViewModel::class.asClassName()
// find all classes that have been annotated with @UsfViewModel
val symbols =
resolver
.getSymbolsWithAnnotation(viewModelClassName.toString())
.filterIsInstance()
// symbols is a [Sequence] not a [List], so we don't know its size in advance
// hence the use of an iterator to check if it's empty
if (!symbols.iterator().hasNext()) return emptyList()
symbols.forEach { annotatedClass ->
// Visit different parts of the class and perform actions
annotatedClass.accept(UsfViewModelVisitor(codeGenerator, logger, options), Unit)
}
/*val sourceFiles = symbols.mapNotNull { it.containingFile }
val fileText = buildString {
sourceFiles.forEach {
append("// ")
append(it.fileName)
append("\n")
}
}
val file =
codeGenerator.createNewFile(
Dependencies(
false,
*sourceFiles.toList().toTypedArray(),
),
"your.generated.file.package",
"GeneratedLists",
)
file.write(fileText.toByteArray())*/
return (symbols).filterNot { it.validate() }.toList()
}
}
================================================
FILE: usf/annotations-processors/src/main/java/co/kaush/msusf/processors/UsfViewModelProcessorProvider.kt
================================================
package co.kaush.msusf.processors
import co.kaush.msusf.processor.UsfViewModelProcessor
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.processing.SymbolProcessorProvider
/**
* this class is responsible for instantiating the [UsfViewModelProcessor] processor and introducing
* it to the KSP
*/
class UsfViewModelProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return UsfViewModelProcessor(
environment.codeGenerator,
environment.logger,
environment.options,
)
}
}
================================================
FILE: usf/annotations-processors/src/main/java/co/kaush/msusf/processors/UsfViewModelVisitor.kt
================================================
package co.kaush.msusf.processors
import com.google.devtools.ksp.isPublic
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.Dependencies
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.symbol.ClassKind
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
import com.google.devtools.ksp.symbol.KSVisitorVoid
import com.squareup.kotlinpoet.ksp.toClassName
import com.squareup.kotlinpoet.ksp.toTypeName
class UsfViewModelVisitor(
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger,
private val options: Map,
) : KSVisitorVoid() {
private val reservedFunctionsNames =
setOf("equals", "hashCode", "toString", "getApplication", "", "addCloseable")
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
if (classDeclaration.classKind != ClassKind.CLASS) {
logger.error("Only a class can be annotated with this annotation", classDeclaration)
throw IllegalArgumentException()
}
if (classDeclaration.containingFile == null) {
logger.error(
"Attempted to add annotation to file that does not exist",
classDeclaration.containingFile)
throw IllegalArgumentException()
}
val packageName = classDeclaration.toClassName().packageName
val definition = generateGenericClassBuilderDefinition(classDeclaration)
val fileSpec = UsfViewModelFileBuilder.buildFileSpec(definition, packageName)
codeGenerator
.createNewFile(
dependencies = Dependencies(false, classDeclaration.containingFile!!),
packageName = classDeclaration.toClassName().packageName,
fileName = fileSpec.name)
.use { it.write(fileSpec.toString().toByteArray()) }
}
private fun generateGenericClassBuilderDefinition(
classDeclaration: KSClassDeclaration
): UsfViewModelClassBuilderDefinition {
val constructorParams = mutableListOf()
classDeclaration.primaryConstructor?.parameters?.forEach { ksValueParameter ->
val paramType = ksValueParameter.type.resolve().toTypeName()
val name = ksValueParameter.name?.getShortName()
if (name != null) {
constructorParams.add(ParametersDefinition(name, paramType))
}
}
val functions = mutableListOf()
classDeclaration.getAllFunctions().forEach { ksFunctionDeclaration ->
val functionName = ksFunctionDeclaration.simpleName.getShortName()
if (ksFunctionDeclaration.isPublic() && !reservedFunctionsNames.contains(functionName)) {
val funParams = mutableListOf()
ksFunctionDeclaration.parameters.map {
val varName = it.name?.getShortName()
if (varName != null) {
funParams.add(ParametersDefinition(varName, it.type.resolve().toTypeName()))
}
}
val returnType =
try {
ksFunctionDeclaration.returnType?.resolve()?.toTypeName()
} catch (ex: Exception) {
throw ex
}
functions.add(FunctionsDefinition(functionName, funParams, returnType))
}
}
val properties = mutableListOf()
classDeclaration.getAllProperties().forEach { ksPropertyDecleration ->
if (ksPropertyDecleration.isPublic()) {
val returnType = ksPropertyDecleration.type.resolve().toTypeName()
properties.add(
ParametersDefinition(ksPropertyDecleration.simpleName.getShortName(), returnType))
}
}
return UsfViewModelClassBuilderDefinition(
constructorParams,
functions,
properties,
ParametersDefinition(
classDeclaration.simpleName.getShortName(), classDeclaration.toClassName()))
}
override fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: Unit) {
function.returnType!!.accept(this, Unit)
}
}
================================================
FILE: usf/annotations-processors/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider
================================================
co.kaush.msusf.processors.UsfViewModelProcessorProvider
================================================
FILE: usf/api/build.gradle.kts
================================================
@file:Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed (or agp 8.1)
plugins { alias(libs.plugins.kotlin.jvm) }
dependencies { implementation(libs.kotlinx.coroutines.core) }
================================================
FILE: usf/api/src/main/java/co/kaush/usf/UsfViewModelImpl.kt
================================================
package co.kaush.usf
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
/**
* When building features, we create ViewModelImpl.kt classes that extend this class. and
* only implement the relevant parts of the feature. All boilerplate code is handled by
* [UsfViewModelImpl] & the corresponding UsfViewModel] class generated by the annotation
* processor.
*/
@OptIn(ExperimentalCoroutinesApi::class)
abstract class UsfViewModelImpl(
initialState: VS,
private val coroutineScope: CoroutineScope,
private val processingDispatcher: CoroutineDispatcher = Dispatchers.IO,
logger: UsfVmLogger =
object : UsfVmLogger {
val tag = Thread.currentThread().stackTrace[4].className.split(".").last()
override fun debug(message: String) = run { /*do something*/}
override fun warning(message: String) = run { /*do something*/}
override fun error(error: Throwable, message: String) = run { /*do something*/}
}
) : UsfVm {
/**
* @param event every input is processed into an [E]vent
* @return [Flow]<[R]> a single [E]vent can result in multiple [R]esults for e.g. emit a R for
* loading and another for the actual result
*/
protected abstract fun eventToResultFlow(event: E): Flow
/**
* @param currentViewState the current [VS]tate of the view (.copy it for the returned [VS]tate)
* @return [VS]tate Curiously, we don't return a [Flow]<[VS]> here every [R]esult will only ever
* be transformed into a single [VS]tate if you want multiple [VS]tates emit multiple [R]esults
* transforming each [R]esult to the respective [VS]tate
*/
protected abstract fun resultToViewState(currentViewState: VS, result: R): VS
/**
* @param result a single [R]esult can result in multiple [Effect]s for e.g. emit a VE for
* navigation and another for an analytics call hence a return type of [Flow]<[Effect]>
* @return [Flow] of [Effect]s where null emissions will be ignored automatically
*/
protected abstract fun resultToEffects(result: R): Flow
/**
* we use a "shared" flow vs state flow here to avoid conflation of state flows.
*
* every single event needs to reach the view model as they could have important implications to
* the VM logic state flow might conflate multiple events if they happen simultaneously.
*/
private val _events =
MutableSharedFlow(
/**
* we do this to prevent a race condition misfire for events sent in very early. for e.g.
* OnScreenLoad events that are sent via [processInput()] right after the VM is created
* can get ignored as .collect(ion) has started yet.
*
* inside the init block, we "launch" (which is fire and forget with coroutines) so the VM
* proceeds to "finish" initialization and allows the Screen/Fragment/Activity to send
* inputs to the "hot" _events flow which will drop it, since noone is listening.
*
* Adding a replay ensures that the first event is always sent to "new" subscribers.
*/
replay = 1)
private val _viewState = MutableStateFlow(initialState)
/**
* we use a "shared" flow vs state flow here to avoid conflation of state flows.
*
* every effect must be sent out and cannot be ignored even if there are multiple side effects
* emitted quickly/simultaneously as that could have implications to the Screen logic
*
* there are times where we _want_ to ignore certain effects (like multiple loading spinner calls)
* these can be handled in the Results emission layer.
*/
private val _effects = MutableSharedFlow()
override val viewState = _viewState.asStateFlow()
override val effects = _effects.asSharedFlow()
init {
logger.debug("------ [init] ${Thread.currentThread().name}")
coroutineScope.launch(processingDispatcher) {
_events
.flatMapMerge { event ->
logger.debugEvents(event)
eventToResultFlow(event)
}
.collect { result ->
logger.debugResults(result)
// StateFlow already behaves as if distinctUntilChanged operator is applied to it
resultToViewState(_viewState.value, result).let { vs ->
logger.debugViewState(vs)
_viewState.emit(vs)
}
// effects are emitted after a view state by virtue of this collect call
// (rarely) would we want VS & VE to be emitted at the exact same instant
_effects.emitAll(
resultToEffects(result).filterNotNull().onEach { logger.debugSideEffects(it) },
)
}
}
}
override fun processInput(event: E) {
coroutineScope.launch(processingDispatcher) { _events.emit(event) }
}
interface UsfVmLogger {
fun debug(message: String)
fun debugEvents(event: Any, message: String? = null) =
debug(message ?: "----- [event] ${Thread.currentThread().name} $event")
fun debugResults(result: Any, message: String? = null) =
debug(message ?: "----- [result] ${Thread.currentThread().name} $result")
fun debugViewState(viewState: Any, message: String? = null) =
debug(message ?: "----- [view-state] ${Thread.currentThread().name} $viewState")
fun debugSideEffects(effect: Any, message: String? = null) =
debug(message ?: "----- [effect] ${Thread.currentThread().name} $effect")
fun warning(message: String)
fun error(error: Throwable, message: String)
}
}
================================================
FILE: usf/api/src/main/java/co/kaush/usf/UsfVm.kt
================================================
package co.kaush.usf
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
/**
* We intentionally name this interface [UsfVm] instead of `UsfViewModel` because we want to avoid
* naming conflict with the annotation @[UsfViewModel]
*
* This interface provides the contract for the ViewModel to follow USF/UDF architecture. Having
* this as an interface allows us to possibly avoid using of [AndroidViewModel] or [ViewModel] if we
* don't need it.
*
* [Any] type used because of a Kotlin Compiler bug that won't take in @NotNull event: E
* https://youtrack.jetbrains.com/issue/KT-36770
*
* @param Event
*/
interface UsfVm {
fun processInput(event: Event)
val viewState: StateFlow
val effects: SharedFlow
}