├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── build.gradle.kts ├── shrinker-rules.pro └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ ├── NavigationTest.kt │ │ └── robot │ │ └── AppRobot.kt │ ├── debug │ ├── AndroidManifest.xml │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ ├── DebugStreamlinedApp.kt │ │ └── DebugTree.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── io │ │ │ └── github │ │ │ └── reactivecircus │ │ │ └── streamlined │ │ │ ├── BugsnagInitializer.kt │ │ │ ├── MainFragment.kt │ │ │ ├── ScreenNameNotifier.kt │ │ │ ├── StreamlinedActivity.kt │ │ │ ├── StreamlinedApp.kt │ │ │ └── di │ │ │ └── AppModule.kt │ └── res │ │ ├── drawable-night │ │ └── background_splash.xml │ │ ├── drawable-notnight-v27 │ │ ├── background_splash.xml │ │ └── ic_streamlined_logo.xml │ │ ├── drawable │ │ ├── background_splash.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ └── ic_streamlined_logo.xml │ │ ├── layout │ │ ├── activity_streamlined.xml │ │ └── fragment_main.xml │ │ ├── menu │ │ └── bottom_navigation.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── navigation │ │ ├── navigation_main.xml │ │ └── navigation_root.xml │ │ ├── values-night-v27 │ │ └── themes.xml │ │ ├── values-night │ │ └── themes.xml │ │ ├── values-v27 │ │ └── themes.xml │ │ └── values │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ ├── mock │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── di │ │ └── SdkModule.kt │ └── online │ └── java │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ └── di │ ├── EnvironmentModule.kt │ └── SdkModule.kt ├── build.gradle.kts ├── buildSrc ├── .editorconfig ├── build.gradle.kts ├── buildSrc │ ├── build.gradle.kts │ └── settings.gradle.kts ├── settings.gradle.kts └── src │ └── main │ └── kotlin │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ ├── AdditionalCompilerArgs.kt │ ├── AndroidSdk.kt │ ├── BaselineProjectConfigs.kt │ ├── CoreLibraryDesugaringPlugin.kt │ ├── Dependencies.kt │ ├── DependencyGraphGenerator.kt │ ├── DetektConfigs.kt │ ├── Environment.kt │ ├── KaptConfigs.kt │ ├── PluginUtils.kt │ ├── ProductFlavors.kt │ ├── SlimTests.kt │ ├── StreamlinedPlugin.kt │ ├── VariantExt.kt │ └── dsl │ └── ProductFlavorConfigurationAccessors.kt ├── data ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── data │ │ ├── Stores.kt │ │ ├── TimeBasedRefreshPolicy.kt │ │ ├── di │ │ └── DataModule.kt │ │ ├── mapper │ │ ├── DateParser.kt │ │ └── StoryMappers.kt │ │ └── repository │ │ ├── BookmarkRepositoryImpl.kt │ │ └── StoryRepositoryImpl.kt │ └── test │ └── java │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ └── data │ ├── TimeBasedRefreshPolicyTest.kt │ ├── mapper │ ├── DateParserTest.kt │ └── StoryMappersTest.kt │ ├── repository │ └── StoryRepositoryImplTest.kt │ └── testutil │ └── TestStoryDao.kt ├── design-themes ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── design │ │ ├── ActivityExt.kt │ │ └── SnackbarExt.kt │ └── res │ ├── anim │ └── slide_in_and_fade_in.xml │ ├── color │ └── bottom_nav_color.xml │ ├── drawable │ ├── ic_twotone_bookmark_24.xml │ ├── ic_twotone_bookmark_border_24.xml │ ├── ic_twotone_home_24.xml │ ├── ic_twotone_language_24.xml │ ├── ic_twotone_more_horiz_24.xml │ └── ic_twotone_settings_24.xml │ ├── font │ ├── fira_sans_condensed.xml │ ├── fira_sans_condensed_bold.ttf │ ├── fira_sans_condensed_medium.ttf │ └── fjalla_one.ttf │ ├── values-night-v27 │ └── themes.xml │ ├── values-night │ ├── colors.xml │ ├── styles.xml │ └── themes.xml │ ├── values-v27 │ └── themes.xml │ └── values │ ├── attr.xml │ ├── colors.xml │ ├── dimens.xml │ ├── shape.xml │ ├── styles.xml │ └── themes.xml ├── detekt.yml ├── domain-api ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── java │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ └── domain │ ├── model │ └── Story.kt │ └── repository │ ├── BookmarkRepository.kt │ └── StoryRepository.kt ├── domain-runtime ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── domain │ │ └── interactor │ │ ├── FetchHeadlineStories.kt │ │ ├── FetchPersonalizedStories.kt │ │ ├── GetStoryById.kt │ │ ├── StreamHeadlineStories.kt │ │ ├── StreamPersonalizedStories.kt │ │ └── SyncStories.kt │ └── test │ └── java │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ └── domain │ ├── TestCoroutineDispatcherProvider.kt │ └── interactor │ ├── FetchHeadlineStoriesTest.kt │ ├── FetchPersonalizedStoriesTest.kt │ ├── GetStoryByIdTest.kt │ ├── StreamHeadlineStoriesTest.kt │ └── StreamPersonalizedStoriesTest.kt ├── domain-testing ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── domain │ │ └── repository │ │ ├── FakeResponse.kt │ │ └── FakeStoryRepository.kt │ └── test │ └── java │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ └── domain │ └── repository │ └── FakeStoryRepositoryTest.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── libraries ├── analytics │ ├── analytics-api-base │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── io │ │ │ └── github │ │ │ └── reactivecircus │ │ │ └── analytics │ │ │ └── AnalyticsApi.kt │ ├── analytics-api-firebase │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src │ │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ └── java │ │ │ └── io │ │ │ └── github │ │ │ └── reactivecircus │ │ │ └── analytics │ │ │ └── firebase │ │ │ └── FirebaseAnalyticsApi.kt │ └── analytics-api-no-op │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src │ │ └── main │ │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── analytics │ │ └── noop │ │ └── NoOpAnalyticsApi.kt ├── bugsnag-tree │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── bugsnag │ │ └── BugsnagTree.kt ├── coroutines-test-ext │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ ├── main │ │ └── java │ │ │ └── io │ │ │ └── github │ │ │ └── reactivecircus │ │ │ └── coroutines │ │ │ └── test │ │ │ └── ext │ │ │ ├── AssertThrows.kt │ │ │ ├── CoroutinesTestRule.kt │ │ │ ├── FlowAssertions.kt │ │ │ └── FlowRecorder.kt │ │ └── test │ │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── coroutines │ │ └── test │ │ └── ext │ │ ├── FlowAssertionsTest.kt │ │ └── FlowRecorderTest.kt └── store-ext │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ ├── main │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── store │ │ └── ext │ │ ├── RefreshPolicy.kt │ │ └── StoreExt.kt │ └── test │ └── java │ └── io │ └── github │ └── reactivecircus │ └── store │ └── ext │ ├── RefreshScopeGeneratorTest.kt │ ├── StreamWithRefreshPolicyTest.kt │ └── testutil │ ├── FlowingTestPersister.kt │ ├── NonFlowingTestPersister.kt │ └── TestFetcher.kt ├── navigator ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── navigator │ │ ├── NavControllerType.kt │ │ ├── NavigationExt.kt │ │ └── input │ │ └── StoryDetailsInput.kt │ └── res │ └── values │ └── navigation_ids.xml ├── persistence ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── github │ │ │ └── reactivecircus │ │ │ └── streamlined │ │ │ └── persistence │ │ │ ├── DatabaseConfigs.kt │ │ │ ├── SQLiteConstants.kt │ │ │ ├── StoryDao.kt │ │ │ ├── StoryDaoImpl.kt │ │ │ └── di │ │ │ └── PersistenceModule.kt │ └── sqldelight │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── persistence │ │ └── StoryEntity.sq │ └── test │ └── java │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ └── persistence │ └── StoryDaoTest.kt ├── remote-base ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── java │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ └── remote │ ├── api │ └── NewsApiService.kt │ └── dto │ ├── StoryDTO.kt │ └── StoryListResponse.kt ├── remote-mock ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── java │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ └── remote │ ├── MockData.kt │ ├── api │ └── MockNewsApiService.kt │ └── di │ └── MockRemoteModule.kt ├── remote-real ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── remote │ │ ├── ApiConfigs.kt │ │ ├── AuthInterceptor.kt │ │ └── di │ │ └── RealRemoteModule.kt │ └── test │ └── java │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ └── remote │ └── AuthInterceptorTest.kt ├── scheduled-tasks ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ └── work │ ├── di │ └── ScheduledTasksModule.kt │ ├── scheduler │ ├── DefaultTaskScheduler.kt │ └── TaskScheduler.kt │ └── worker │ └── StorySyncWorker.kt ├── secrets ├── debug.keystore ├── google-services-dev.aes ├── google-services-prod.aes ├── play-api.aes └── streamlined.aes ├── settings.gradle.kts ├── ui-common ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── github │ │ │ └── reactivecircus │ │ │ └── streamlined │ │ │ └── ui │ │ │ ├── ScreenForAnalytics.kt │ │ │ ├── configs │ │ │ └── AnimationConfigs.kt │ │ │ ├── util │ │ │ ├── FragmentExt.kt │ │ │ ├── ItemActionListener.kt │ │ │ ├── PrettyTime.kt │ │ │ └── RecyclerViewIExt.kt │ │ │ └── viewmodel │ │ │ └── LazyViewModelProvider.kt │ └── res │ │ └── values │ │ └── strings.xml │ └── test │ └── java │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ └── ui │ └── util │ └── PrettyTimeTest.kt ├── ui-headlines ├── .gitignore ├── build.gradle.kts └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── headlines │ │ └── HeadlinesScreenTest.kt │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── headlines │ │ └── HeadlinesFragment.kt │ └── res │ ├── layout │ └── fragment_headlines.xml │ └── values │ └── strings.xml ├── ui-home ├── .gitignore ├── build.gradle.kts └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── home │ │ ├── HomeRobot.kt │ │ ├── HomeScreenTest.kt │ │ ├── TestHomeUiConfigs.kt │ │ └── di │ │ └── TestHomeModule.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── github │ │ │ └── reactivecircus │ │ │ └── streamlined │ │ │ └── home │ │ │ ├── FeedItemsGenerator.kt │ │ │ ├── FeedViewHolder.kt │ │ │ ├── FeedsListAdapter.kt │ │ │ ├── HomeFragment.kt │ │ │ ├── HomeUiConfigs.kt │ │ │ ├── HomeUiModels.kt │ │ │ ├── HomeViewModel.kt │ │ │ ├── HomeWorkflow.kt │ │ │ └── di │ │ │ └── HomeModule.kt │ └── res │ │ ├── layout │ │ ├── fragment_home.xml │ │ ├── item_empty_placeholder.xml │ │ ├── item_main_story.xml │ │ ├── item_read_more_headlines.xml │ │ ├── item_section_header.xml │ │ └── item_story.xml │ │ └── values │ │ └── strings.xml │ └── test │ └── java │ └── io │ └── github │ └── reactivecircus │ └── streamlined │ └── home │ ├── FeedItemsGeneratorTest.kt │ └── HomeWorkflowTest.kt ├── ui-reading-list ├── .gitignore ├── build.gradle.kts └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── readinglist │ │ └── ReadingListScreenTest.kt │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── readinglist │ │ └── ReadingListFragment.kt │ └── res │ ├── layout │ └── fragment_reading_list.xml │ └── values │ └── strings.xml ├── ui-settings ├── .gitignore ├── build.gradle.kts └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── settings │ │ └── SettingsScreenTest.kt │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── settings │ │ └── SettingsFragment.kt │ └── res │ ├── layout │ └── fragment_settings.xml │ └── values │ └── strings.xml ├── ui-story-details ├── .gitignore ├── build.gradle.kts └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── storydetails │ │ └── StoryDetailsScreenTest.kt │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── io │ │ └── github │ │ └── reactivecircus │ │ └── streamlined │ │ └── storydetails │ │ ├── StoryDetailsFragment.kt │ │ ├── StoryDetailsUiModels.kt │ │ ├── StoryDetailsViewModel.kt │ │ └── StoryDetailsWorkflow.kt │ └── res │ ├── layout │ └── fragment_story_details.xml │ └── values │ └── strings.xml └── ui-testing-framework ├── .gitignore ├── build.gradle.kts └── src └── main ├── AndroidManifest.xml └── java └── io └── github └── reactivecircus └── streamlined └── testing ├── BaseScreenTest.kt ├── BaseScreenTestApp.kt ├── HiltTestActivity.kt ├── ScreenTestApp.kt ├── ScreenTestRunner.kt ├── TestAnimationConfigs.kt ├── TestData.kt ├── TestDebugTree.kt ├── TestRefreshPolicy.kt ├── assumption ├── DataAssumptions.kt └── NetworkAssumptions.kt └── di ├── TestAppModule.kt └── TestRemoteModule.kt /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | 6 | [*.kts] 7 | max_line_length = 200 8 | 9 | [*.kt] 10 | ij_kotlin_name_count_to_use_star_import = 2147483647 11 | ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # Files for the ART/Dalvik VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # Generated files 12 | bin/ 13 | gen/ 14 | out/ 15 | 16 | # Gradle files 17 | .gradle/ 18 | build/ 19 | reports/ 20 | 21 | # Local configuration file (sdk path, etc) 22 | local.properties 23 | 24 | # Log Files 25 | *.log 26 | 27 | # Android Studio Navigation editor temp files 28 | .navigation/ 29 | 30 | # Android Studio captures folder 31 | captures/ 32 | 33 | # Intellij 34 | *.iml 35 | .idea/ 36 | 37 | # External native build folder generated in Android Studio 2.2 and later 38 | .externalNativeBuild 39 | 40 | # Ignore plain keystore files 41 | secrets/streamlined.jks 42 | 43 | # Ignore plain API keys 44 | google-services.json 45 | secrets/play-api.json 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # streamlined. 2 | 3 | ![CI](https://github.com/ReactiveCircus/streamlined/workflows/CI/badge.svg) 4 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 5 | -------------------------------------------------------------------------------- /app/shrinker-rules.pro: -------------------------------------------------------------------------------- 1 | -verbose 2 | 3 | # Keep annotations with RUNTIME retention and their defaults. 4 | -keepattributes RuntimeVisible*Annotations, AnnotationDefault 5 | 6 | # For crash reporting 7 | -keepattributes LineNumberTable, SourceFile 8 | 9 | # For native methods, see http://proguard.sourceforge.net/manual/examples.html#native 10 | -keepclasseswithmembernames class * { 11 | native ; 12 | } 13 | 14 | # Enum.valueOf(Class, String) and others invoke this method reflectively. 15 | -keepclassmembers,allowoptimization enum * { 16 | public static **[] values(); 17 | } 18 | 19 | # Parcelable CREATOR fields are looked up reflectively when de-parceling. 20 | -keepclassmembers class * implements android.os.Parcelable { 21 | public static final android.os.Parcelable$Creator CREATOR; 22 | } 23 | 24 | # Kotlin serialization looks up the generated serializer classes through a function on companion 25 | # objects. The companions are looked up reflectively so we need to explicitly keep these functions. 26 | -keepclasseswithmembers class **.*$Companion { 27 | kotlinx.serialization.KSerializer serializer(...); 28 | } 29 | # If a companion has the serializer function, keep the companion field on the original type so that 30 | # the reflective lookup succeeds. 31 | -if class **.*$Companion { 32 | kotlinx.serialization.KSerializer serializer(...); 33 | } 34 | -keepclassmembers class <1>.<2> { 35 | <1>.<2>$Companion Companion; 36 | } 37 | -------------------------------------------------------------------------------- /app/src/androidTest/java/io/github/reactivecircus/streamlined/NavigationTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import androidx.test.filters.LargeTest 4 | import dagger.hilt.android.testing.HiltAndroidTest 5 | import dagger.hilt.android.testing.UninstallModules 6 | import io.github.reactivecircus.streamlined.di.AppModule 7 | import io.github.reactivecircus.streamlined.robot.appScreen 8 | import io.github.reactivecircus.streamlined.testing.BaseScreenTest 9 | import org.junit.Test 10 | 11 | @LargeTest 12 | @HiltAndroidTest 13 | @UninstallModules( 14 | AppModule::class 15 | ) 16 | class NavigationTest : BaseScreenTest() { 17 | 18 | @Test 19 | fun selectNewBottomNavigationItem_correctDestinationSelected() { 20 | appScreen { 21 | perform { 22 | launchActivityScenario() 23 | } 24 | check { 25 | homeScreenDisplayed() 26 | } 27 | perform { 28 | selectHeadlinesNavItem() 29 | } 30 | check { 31 | headlinesScreenDisplayed() 32 | } 33 | perform { 34 | selectReadingListNavItem() 35 | } 36 | check { 37 | readingListScreenDisplayed() 38 | } 39 | perform { 40 | selectSettingsNavItem() 41 | } 42 | check { 43 | settingsScreenDisplayedSelected() 44 | } 45 | perform { 46 | selectHomeNavItem() 47 | } 48 | check { 49 | homeScreenDisplayed() 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/debug/java/io/github/reactivecircus/streamlined/DebugStreamlinedApp.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import android.os.StrictMode 4 | import timber.log.Timber 5 | 6 | class DebugStreamlinedApp : StreamlinedApp() { 7 | 8 | override fun onCreate() { 9 | super.onCreate() 10 | if (BuildConfig.ENABLE_STRICT_MODE) { 11 | StrictMode.enableDefaults() 12 | } 13 | } 14 | 15 | override fun initializeTimber() { 16 | if (BuildConfig.ENABLE_BUGSNAG) { 17 | super.initializeTimber() 18 | } else { 19 | Timber.plant(DebugTree()) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/debug/java/io/github/reactivecircus/streamlined/DebugTree.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import timber.log.Timber 4 | 5 | /** 6 | * Custom Timber debug tree with line number in the tag. 7 | */ 8 | class DebugTree : Timber.DebugTree() { 9 | 10 | override fun createStackElementTag(element: StackTraceElement): String? { 11 | return super.createStackElementTag(element) + ":" + element.lineNumber 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 16 | 17 | 20 | 21 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/io/github/reactivecircus/streamlined/BugsnagInitializer.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import android.content.Context 4 | import com.bugsnag.android.Bugsnag 5 | import com.bugsnag.android.Configuration 6 | import io.github.reactivecircus.bugsnag.BugsnagTree 7 | import radiography.Radiography 8 | import radiography.ScanScopes 9 | 10 | fun Context.initializeBugsnag(bugsnagTree: BugsnagTree) { 11 | val config = Configuration.load(this).apply { 12 | enabledReleaseStages = setOf(BuildConfig.BUILD_TYPE) 13 | enabledErrorTypes.ndkCrashes = false 14 | addOnError { event -> 15 | val viewHierarchy = Radiography.scan( 16 | scanScope = ScanScopes.FocusedWindowScope 17 | ) 18 | event.addMetadata("diagnostic", "view-hierarchy", viewHierarchy) 19 | bugsnagTree.update(event) 20 | true 21 | } 22 | } 23 | Bugsnag.start(this, config) 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/reactivecircus/streamlined/MainFragment.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.core.view.doOnPreDraw 7 | import androidx.fragment.app.Fragment 8 | import androidx.navigation.Navigation 9 | import androidx.navigation.ui.NavigationUI 10 | import com.google.android.material.transition.MaterialElevationScale 11 | import io.github.reactivecircus.streamlined.databinding.FragmentMainBinding 12 | 13 | class MainFragment : Fragment(R.layout.fragment_main) { 14 | 15 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 16 | postponeEnterTransition() 17 | (view.parent as ViewGroup).doOnPreDraw { startPostponedEnterTransition() } 18 | exitTransition = MaterialElevationScale(false) 19 | reenterTransition = MaterialElevationScale(true) 20 | 21 | val binding = FragmentMainBinding.bind(view) 22 | 23 | val fragmentContainer = view.findViewById(R.id.mainNavHostFragment) 24 | val navController = Navigation.findNavController(fragmentContainer) 25 | 26 | // setup NavController with BottomNavigationView 27 | NavigationUI.setupWithNavController( 28 | binding.bottomNavigationView, 29 | navController 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/reactivecircus/streamlined/ScreenNameNotifier.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.fragment.app.FragmentManager 5 | import io.github.reactivecircus.analytics.AnalyticsApi 6 | import io.github.reactivecircus.streamlined.ui.ScreenForAnalytics 7 | import javax.inject.Inject 8 | 9 | /** 10 | * Notify the analytics framework when a new [ScreenForAnalytics] is displayed. 11 | */ 12 | class ScreenNameNotifier @Inject constructor( 13 | private val analyticsApi: AnalyticsApi 14 | ) : FragmentManager.FragmentLifecycleCallbacks() { 15 | override fun onFragmentResumed(fragmentManager: FragmentManager, fragment: Fragment) { 16 | if (fragment is ScreenForAnalytics) { 17 | analyticsApi.setCurrentScreenName( 18 | screenName = fragment.javaClass.simpleName, 19 | screenClass = fragment.javaClass.simpleName 20 | ) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/reactivecircus/streamlined/StreamlinedActivity.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.core.view.WindowCompat 6 | import dagger.hilt.android.AndroidEntryPoint 7 | import io.github.reactivecircus.streamlined.design.setDefaultTaskBarColor 8 | import javax.inject.Inject 9 | import io.github.reactivecircus.streamlined.design.R as ThemeResource 10 | 11 | @AndroidEntryPoint 12 | class StreamlinedActivity : AppCompatActivity(R.layout.activity_streamlined) { 13 | 14 | @Inject 15 | lateinit var screenNameNotifier: ScreenNameNotifier 16 | 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | setTheme(ThemeResource.style.Theme_Streamlined_DayNight) 19 | setDefaultTaskBarColor() 20 | super.onCreate(savedInstanceState) 21 | 22 | supportFragmentManager.registerFragmentLifecycleCallbacks( 23 | screenNameNotifier, 24 | true 25 | ) 26 | 27 | // configure edge-to-edge window insets 28 | WindowCompat.setDecorFitsSystemWindows(window, false) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/reactivecircus/streamlined/StreamlinedApp.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Application 5 | import androidx.hilt.work.HiltWorkerFactory 6 | import androidx.work.Configuration 7 | import coil.Coil.setImageLoader 8 | import coil.ImageLoader 9 | import dagger.hilt.android.HiltAndroidApp 10 | import io.github.reactivecircus.analytics.AnalyticsApi 11 | import io.github.reactivecircus.bugsnag.BugsnagTree 12 | import io.github.reactivecircus.streamlined.work.scheduler.TaskScheduler 13 | import javax.inject.Inject 14 | import timber.log.Timber 15 | 16 | @SuppressLint("Registered") 17 | @HiltAndroidApp 18 | open class StreamlinedApp : Application(), Configuration.Provider { 19 | 20 | @Inject 21 | lateinit var analyticsApi: AnalyticsApi 22 | 23 | @Inject 24 | lateinit var workerFactory: HiltWorkerFactory 25 | 26 | @Inject 27 | lateinit var taskScheduler: TaskScheduler 28 | 29 | @Inject 30 | lateinit var imageLoader: ImageLoader 31 | 32 | override fun onCreate() { 33 | super.onCreate() 34 | 35 | // initialize Timber 36 | initializeTimber() 37 | 38 | // initialize analytics api 39 | analyticsApi.setEnableAnalytics(BuildConfig.ENABLE_ANALYTICS) 40 | 41 | // schedule background sync 42 | taskScheduler.scheduleHourlyStorySync() 43 | 44 | // set default image loader 45 | setImageLoader(imageLoader) 46 | } 47 | 48 | protected open fun initializeTimber() { 49 | val tree = BugsnagTree() 50 | Timber.plant(tree) 51 | 52 | // initialize Bugsnag 53 | if (BuildConfig.ENABLE_BUGSNAG) { 54 | initializeBugsnag(tree) 55 | } 56 | } 57 | 58 | override fun getWorkManagerConfiguration(): Configuration { 59 | return Configuration.Builder().setWorkerFactory(workerFactory).build() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/reactivecircus/streamlined/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.di 2 | 3 | import android.content.Context 4 | import coil.ImageLoader 5 | import dagger.Binds 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.Reusable 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import io.github.reactivecircus.streamlined.BuildConfig 13 | import io.github.reactivecircus.streamlined.persistence.DatabaseConfigs 14 | import io.github.reactivecircus.streamlined.ui.configs.AnimationConfigs 15 | import io.github.reactivecircus.streamlined.ui.configs.DefaultAnimationConfigs 16 | import javax.inject.Singleton 17 | import kotlinx.coroutines.Dispatchers 18 | import reactivecircus.blueprint.async.coroutines.CoroutineDispatcherProvider 19 | 20 | @Module 21 | @InstallIn(SingletonComponent::class) 22 | abstract class AppModule { 23 | 24 | @Binds 25 | @Reusable 26 | abstract fun animationConfigs(impl: DefaultAnimationConfigs): AnimationConfigs 27 | 28 | companion object { 29 | 30 | @Provides 31 | @Singleton 32 | fun coroutineDispatcherProvider(): CoroutineDispatcherProvider { 33 | return CoroutineDispatcherProvider( 34 | io = Dispatchers.IO, 35 | computation = Dispatchers.Default, 36 | ui = Dispatchers.Main.immediate 37 | ) 38 | } 39 | 40 | @Provides 41 | @Singleton 42 | fun imageLoader(@ApplicationContext context: Context): ImageLoader { 43 | return ImageLoader.Builder(context) 44 | .crossfade(true) 45 | .build() 46 | } 47 | 48 | @Provides 49 | @Reusable 50 | fun databaseConfigs( 51 | coroutineDispatcherProvider: CoroutineDispatcherProvider 52 | ): DatabaseConfigs { 53 | return DatabaseConfigs( 54 | databaseName = BuildConfig.DATABASE_NAME, 55 | coroutineContext = coroutineDispatcherProvider.io, 56 | ) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-night/background_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-notnight-v27/background_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/background_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 15 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_streamlined.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 20 | 21 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/res/menu/bottom_navigation.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 13 | 14 | 18 | 19 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/navigation/navigation_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 12 | 13 | 18 | 19 | 24 | 25 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/res/navigation/navigation_root.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 11 | 12 | 16 | 17 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/values-night-v27/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/values-v27/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #2E2858 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Home 4 | Headlines 5 | Readings 6 | Settings 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/mock/java/io/github/reactivecircus/streamlined/di/SdkModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.Reusable 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.components.SingletonComponent 8 | import io.github.reactivecircus.analytics.AnalyticsApi 9 | import io.github.reactivecircus.analytics.noop.NoOpAnalyticsApi 10 | 11 | @Module 12 | @InstallIn(SingletonComponent::class) 13 | object SdkModule { 14 | 15 | @Provides 16 | @Reusable 17 | fun analyticsApi(): AnalyticsApi { 18 | return NoOpAnalyticsApi 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/online/java/io/github/reactivecircus/streamlined/di/EnvironmentModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.Reusable 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.components.SingletonComponent 8 | import io.github.reactivecircus.streamlined.BuildConfig 9 | import io.github.reactivecircus.streamlined.remote.ApiConfigs 10 | 11 | @Module 12 | @InstallIn(SingletonComponent::class) 13 | object EnvironmentModule { 14 | 15 | @Provides 16 | @Reusable 17 | fun apiConfigs(): ApiConfigs { 18 | return ApiConfigs( 19 | apiKey = BuildConfig.API_KEY, 20 | baseUrl = BuildConfig.BASE_URL, 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/online/java/io/github/reactivecircus/streamlined/di/SdkModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.Reusable 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.components.SingletonComponent 8 | import io.github.reactivecircus.analytics.AnalyticsApi 9 | import io.github.reactivecircus.analytics.firebase.FirebaseAnalyticsApi 10 | 11 | @Module 12 | @InstallIn(SingletonComponent::class) 13 | object SdkModule { 14 | 15 | @Provides 16 | @Reusable 17 | fun analyticsApi(): AnalyticsApi { 18 | return FirebaseAnalyticsApi 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `streamlined-plugin` 3 | } 4 | -------------------------------------------------------------------------------- /buildSrc/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.kt] 2 | # Allow longer max_line_length within buildSrc/ 3 | max_line_length = 200 4 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Plugins 2 | 3 | plugins { 4 | `kotlin-dsl` 5 | } 6 | 7 | dependencies { 8 | implementation(Plugins.kotlinGradlePlugin) 9 | implementation(Plugins.detektGradlePlugin) 10 | implementation(Plugins.dependencyGraphGeneratorPlugin) 11 | implementation(Plugins.androidGradlePlugin) 12 | implementation(Plugins.appVersioningGradlePlugin) 13 | implementation(Plugins.kotlinSerializationPlugin) 14 | implementation(Plugins.hiltGradlePlugin) 15 | implementation(Plugins.googleServicesGradlePlugin) 16 | implementation(Plugins.sqldelightGradlePlugin) 17 | implementation(Plugins.playPublisherPlugin) 18 | } 19 | 20 | gradlePlugin { 21 | plugins { 22 | register("streamlined") { 23 | id = "streamlined-plugin" 24 | implementationClass = "io.github.reactivecircus.streamlined.StreamlinedPlugin" 25 | } 26 | register("coreLibraryDesugaring") { 27 | id = "core-library-desugaring" 28 | implementationClass = "io.github.reactivecircus.streamlined.CoreLibraryDesugaringPlugin" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /buildSrc/buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | // force compilation of Dependencies.kt so it can be referenced in buildSrc/build.gradle.kts 6 | sourceSets.main { 7 | java { 8 | setSrcDirs(setOf(projectDir.parentFile.resolve("src/main/kotlin"))) 9 | include("io/github/reactivecircus/streamlined/Dependencies.kt") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /buildSrc/buildSrc/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("UnstableApiUsage") 2 | dependencyResolutionManagement { 3 | repositories { 4 | mavenCentral() 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /buildSrc/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("UnstableApiUsage") 2 | dependencyResolutionManagement { 3 | repositories { 4 | mavenCentral() 5 | google() 6 | gradlePluginPortal() 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/io/github/reactivecircus/streamlined/AdditionalCompilerArgs.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | internal val additionalCompilerArgs = listOf( 4 | "-progressive", 5 | "-XXLanguage:+InlineClasses", 6 | "-Xjvm-default=all", 7 | "-opt-in=kotlin.Experimental", 8 | "-opt-in=kotlin.ExperimentalStdlibApi", 9 | "-opt-in=kotlin.time.ExperimentalTime" 10 | ) 11 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/io/github/reactivecircus/streamlined/AndroidSdk.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("ClassName") 2 | 3 | package io.github.reactivecircus.streamlined 4 | 5 | internal object androidSdk { 6 | const val minSdk = 23 7 | const val targetSdk = 32 8 | const val compileSdk = 32 9 | const val buildTools = "32.0.0" 10 | } 11 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/io/github/reactivecircus/streamlined/CoreLibraryDesugaringPlugin.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("unused") 2 | 3 | package io.github.reactivecircus.streamlined 4 | 5 | import com.android.build.gradle.AppPlugin 6 | import com.android.build.gradle.LibraryPlugin 7 | import com.android.build.gradle.TestedExtension 8 | import org.gradle.api.Plugin 9 | import org.gradle.api.Project 10 | import org.gradle.kotlin.dsl.getByType 11 | import org.gradle.kotlin.dsl.withType 12 | 13 | /** 14 | * A plugin that enables Java 8 desugaring for consuming new Java language APIs. 15 | * 16 | * Apply this plugin to the build.gradle.kts file in Android Application or Android Library projects: 17 | * ``` 18 | * plugins { 19 | * `core-library-desugaring` 20 | * } 21 | * ``` 22 | */ 23 | class CoreLibraryDesugaringPlugin : Plugin { 24 | override fun apply(project: Project) { 25 | project.plugins.withType { 26 | project.extensions.getByType().configure(project) 27 | } 28 | 29 | project.plugins.withType { 30 | project.extensions.getByType().configure(project) 31 | } 32 | } 33 | 34 | private fun TestedExtension.configure(project: Project) { 35 | compileOptions.isCoreLibraryDesugaringEnabled = true 36 | project.dependencies.add("coreLibraryDesugaring", Libraries.desugarLibs) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/io/github/reactivecircus/streamlined/DependencyGraphGenerator.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import com.vanniktech.dependency.graph.generator.DependencyGraphGeneratorExtension 4 | import com.vanniktech.dependency.graph.generator.DependencyGraphGeneratorExtension.Generator 5 | import com.vanniktech.dependency.graph.generator.DependencyGraphGeneratorPlugin 6 | import guru.nidi.graphviz.attribute.Style 7 | import guru.nidi.graphviz.engine.Format 8 | import org.gradle.api.Project 9 | import org.gradle.kotlin.dsl.configure 10 | import org.gradle.kotlin.dsl.withType 11 | 12 | /** 13 | * Configure project dependency graph generation. 14 | * Run `./gradlew generateDependencyGraph` to generate the project dependency graph. 15 | * Generated svg file will be available at ./build/reports/dependency-graph/dependency-graph.svg. 16 | * 17 | * To also generate a png file: 18 | * `./gradlew generateDependencyGraph -PgeneratePng` 19 | * Generated png file will be available at ./build/reports/dependency-graph/dependency-graph.png. 20 | * 21 | */ 22 | internal fun Project.configureDependencyGraphGenerator() { 23 | require(isRoot) 24 | pluginManager.apply(DependencyGraphGeneratorPlugin::class.java) 25 | plugins.withType { 26 | extensions.configure { 27 | generators = listOf( 28 | Generator( 29 | include = { dependency -> 30 | // only include projects and filter out the ones that depend on itself in other configurations (e.g. test) 31 | dependency.moduleGroup == rootProject.name && dependency.parents.none { 32 | dependency.moduleName == it.moduleName 33 | } 34 | }, 35 | dependencyNode = { node, _ -> 36 | node.add(Style.SOLID) 37 | }, 38 | includeConfiguration = { configuration -> 39 | configuration.name.contains("runtimeClasspath", ignoreCase = true) 40 | }, 41 | outputFormats = mutableListOf(Format.SVG).apply { 42 | if (providers.gradleProperty(GENERATE_PNG_PROPERTY).isPresent) { 43 | add(Format.PNG) 44 | } 45 | } 46 | ) 47 | ) 48 | } 49 | } 50 | } 51 | 52 | private const val GENERATE_PNG_PROPERTY = "generatePng" 53 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/io/github/reactivecircus/streamlined/DetektConfigs.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import io.gitlab.arturbosch.detekt.Detekt 4 | import io.gitlab.arturbosch.detekt.DetektPlugin 5 | import io.gitlab.arturbosch.detekt.extensions.DetektExtension 6 | import org.gradle.api.Project 7 | import org.gradle.kotlin.dsl.configure 8 | import org.gradle.kotlin.dsl.withType 9 | 10 | /** 11 | * Apply detekt configs to the [Project]. 12 | */ 13 | internal fun Project.configureDetektPlugin() { 14 | // apply detekt plugin 15 | pluginManager.apply(DetektPlugin::class.java) 16 | 17 | // enable Ktlint formatting 18 | dependencies.add("detektPlugins", "io.gitlab.arturbosch.detekt:detekt-formatting:${Versions.detekt}") 19 | 20 | plugins.withType { 21 | extensions.configure { 22 | source = files("src/") 23 | config = files("${project.rootDir}/detekt.yml") 24 | buildUponDefaultConfig = true 25 | allRules = true 26 | } 27 | tasks.withType().configureEach { 28 | reports { 29 | html.outputLocation.set(file("build/reports/detekt/${project.name}.html")) 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/io/github/reactivecircus/streamlined/Environment.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import org.gradle.api.Project 4 | 5 | val Project.isCiBuild: Boolean 6 | get() = providers.environmentVariable("CI").orNull == "true" 7 | 8 | fun Project.envOrProp(name: String): String { 9 | return providers.environmentVariable(name).orNull 10 | ?: providers.gradleProperty(name).getOrElse("") 11 | } 12 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/io/github/reactivecircus/streamlined/KaptConfigs.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import org.gradle.api.Project 4 | import org.gradle.kotlin.dsl.configure 5 | import org.jetbrains.kotlin.gradle.plugin.KaptExtension 6 | 7 | /** 8 | * Apply default kapt configs to the [Project]. 9 | */ 10 | internal fun Project.configureKapt() { 11 | extensions.configure { 12 | javacOptions { 13 | if (hasHiltCompilerDependency) { 14 | option("-Adagger.fastInit=enabled") 15 | option("-Adagger.strictMultibindingValidation=enabled") 16 | option("-Adagger.experimentalDaggerErrorMessages=enabled") 17 | if (isCiBuild) { 18 | option("-Xmaxerrs", 500) 19 | } else { 20 | option("-Adagger.moduleBindingValidation=ERROR") 21 | } 22 | } 23 | } 24 | } 25 | // disable kapt tasks for unit tests 26 | tasks.matching { 27 | it.name.startsWith("kapt") && it.name.endsWith("UnitTestKotlin") 28 | }.configureEach { 29 | enabled = false 30 | } 31 | } 32 | 33 | private val Project.hasHiltCompilerDependency: Boolean 34 | get() = configurations.any { 35 | it.dependencies.any { dependency -> 36 | dependency.name == "hilt-compiler" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/io/github/reactivecircus/streamlined/PluginUtils.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import org.gradle.api.Project 4 | import org.gradle.kotlin.dsl.findByType 5 | import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension 6 | import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension 7 | 8 | internal val Project.hasUnitTestSource: Boolean 9 | get() { 10 | extensions.findByType()?.sourceSets?.findByName("test")?.let { 11 | if (it.kotlin.files.isNotEmpty()) return true 12 | } 13 | extensions.findByType()?.sourceSets?.findByName("test")?.let { 14 | if (it.kotlin.files.isNotEmpty()) return true 15 | } 16 | return false 17 | } 18 | 19 | internal val Project.hasAndroidTestSource: Boolean 20 | get() { 21 | extensions.findByType()?.sourceSets?.findByName("androidTest")?.let { 22 | if (it.kotlin.files.isNotEmpty()) return true 23 | } 24 | return false 25 | } 26 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/io/github/reactivecircus/streamlined/ProductFlavors.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | object FlavorDimensions { 4 | const val ENVIRONMENT = "environment" 5 | } 6 | 7 | object ProductFlavors { 8 | const val MOCK = "mock" 9 | const val DEV = "dev" 10 | const val PROD = "prod" 11 | } 12 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/io/github/reactivecircus/streamlined/SlimTests.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import com.android.build.api.variant.ApplicationAndroidComponentsExtension 4 | import com.android.build.api.variant.LibraryAndroidComponentsExtension 5 | import org.gradle.api.Project 6 | import org.gradle.kotlin.dsl.findByType 7 | import org.gradle.language.nativeplatform.internal.BuildType 8 | 9 | /** 10 | * When the "slimTests" project property is provided, disable the unit test tasks 11 | * on `release` build type and `dev` and `prod` product flavors to avoid running the same tests repeatedly 12 | * in different build variants. 13 | * 14 | * Examples: 15 | * `./gradlew test -PslimTests` will run unit tests for `mockDebug` and `debug` build variants 16 | * in Android App and Library projects, and all tests in JVM projects. 17 | */ 18 | internal fun Project.configureSlimTests() { 19 | if (providers.gradleProperty(SLIM_TESTS_PROPERTY).isPresent) { 20 | // disable unit test tasks on the release build type for Android Library projects 21 | extensions.findByType()?.run { 22 | beforeVariants(selector().withBuildType(BuildType.RELEASE.name)) { 23 | it.enableUnitTest = false 24 | } 25 | } 26 | 27 | // disable unit test tasks on the release build type and all non-mock flavors for Android Application projects. 28 | extensions.findByType()?.run { 29 | beforeVariants(selector().withBuildType(BuildType.RELEASE.name)) { 30 | it.enableUnitTest = false 31 | } 32 | beforeVariants(selector().withFlavor(FlavorDimensions.ENVIRONMENT to ProductFlavors.DEV)) { 33 | it.enableUnitTest = false 34 | } 35 | beforeVariants(selector().withFlavor(FlavorDimensions.ENVIRONMENT to ProductFlavors.PROD)) { 36 | it.enableUnitTest = false 37 | } 38 | } 39 | } 40 | } 41 | 42 | private const val SLIM_TESTS_PROPERTY = "slimTests" 43 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/io/github/reactivecircus/streamlined/VariantExt.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined 2 | 3 | import com.android.build.api.variant.BuildConfigField 4 | import com.android.build.api.variant.ResValue 5 | import com.android.build.api.variant.Variant 6 | import java.io.Serializable 7 | 8 | fun Variant.addResValue(key: String, type: String, value: String) { 9 | resValues.put(makeResValueKey(type, key), ResValue(value)) 10 | } 11 | 12 | fun Variant.addBuildConfigField(key: String, value: T) { 13 | val buildConfigField = BuildConfigField(type = value::class.java.simpleName, value = value, comment = null) 14 | buildConfigFields.put(key, buildConfigField) 15 | } 16 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/io/github/reactivecircus/streamlined/dsl/ProductFlavorConfigurationAccessors.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.dsl 2 | 3 | import org.gradle.api.artifacts.Dependency 4 | import org.gradle.api.artifacts.dsl.DependencyHandler 5 | 6 | /** 7 | * Adds a dependency to the 'mockImplementation' configuration. 8 | * 9 | * @param dependencyNotation notation for the dependency to be added. 10 | * @return The dependency. 11 | */ 12 | fun DependencyHandler.mockImplementation(dependencyNotation: Any): Dependency? = 13 | add("mockImplementation", dependencyNotation) 14 | 15 | /** 16 | * Adds a dependency to the 'devImplementation' configuration. 17 | * 18 | * @param dependencyNotation notation for the dependency to be added. 19 | * @return The dependency. 20 | */ 21 | fun DependencyHandler.devImplementation(dependencyNotation: Any): Dependency? = 22 | add("devImplementation", dependencyNotation) 23 | 24 | /** 25 | * Adds a dependency to the 'prodImplementation' configuration. 26 | * 27 | * @param dependencyNotation notation for the dependency to be added. 28 | * @return The dependency. 29 | */ 30 | fun DependencyHandler.prodImplementation(dependencyNotation: Any): Dependency? = 31 | add("prodImplementation", dependencyNotation) 32 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /data/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | `core-library-desugaring` 6 | id("com.android.library") 7 | kotlin("android") 8 | kotlin("kapt") 9 | } 10 | 11 | android { 12 | namespace = "io.github.reactivecircus.streamlined.data" 13 | } 14 | 15 | dependencies { 16 | implementation(project(":domain-api")) 17 | implementation(project(":remote-base")) 18 | api(project(":persistence")) 19 | api(project(":store-ext")) 20 | 21 | // Blueprint 22 | implementation(Libraries.blueprint.asyncCoroutines) 23 | 24 | // Hilt 25 | implementation(Libraries.hilt.android) 26 | kapt(Libraries.hilt.compiler) 27 | 28 | // timber 29 | implementation(Libraries.timber) 30 | 31 | // Unit tests 32 | testImplementation(Libraries.junit) 33 | testImplementation(Libraries.truth) 34 | testImplementation(project(":coroutines-test-ext")) 35 | } 36 | -------------------------------------------------------------------------------- /data/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /data/src/main/java/io/github/reactivecircus/streamlined/data/Stores.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.data 2 | 3 | import com.dropbox.android.external.store4.Store 4 | import io.github.reactivecircus.streamlined.domain.model.Story 5 | 6 | typealias HeadlineStoryStore = Store> 7 | typealias PersonalizedStoryStore = Store> 8 | -------------------------------------------------------------------------------- /data/src/main/java/io/github/reactivecircus/streamlined/data/mapper/DateParser.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.data.mapper 2 | 3 | import java.time.Instant 4 | 5 | /** 6 | * Converts a [String] in ISO-8601 format to a UTC timestamp in [Long]. 7 | * 8 | * E.g. "2020-02-07T22:37:23Z" -> 1581115043000 9 | */ 10 | fun String.toTimestamp(): Long { 11 | return Instant.parse(this).toEpochMilli() 12 | } 13 | -------------------------------------------------------------------------------- /data/src/main/java/io/github/reactivecircus/streamlined/data/mapper/StoryMappers.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.data.mapper 2 | 3 | import io.github.reactivecircus.streamlined.domain.model.Story 4 | import io.github.reactivecircus.streamlined.persistence.StoryEntity 5 | import io.github.reactivecircus.streamlined.remote.dto.StoryDTO 6 | 7 | internal fun StoryDTO.toEntity(isHeadline: Boolean): StoryEntity { 8 | return StoryEntity( 9 | id = -1, 10 | source = source.name, 11 | title = title, 12 | author = author, 13 | description = description, 14 | url = url, 15 | imageUrl = urlToImage, 16 | publishedTime = publishedAt.toTimestamp(), 17 | isHeadline = isHeadline 18 | ) 19 | } 20 | 21 | internal fun StoryEntity.toModel(): Story { 22 | return Story( 23 | id = id, 24 | source = source, 25 | title = title, 26 | author = author, 27 | description = description, 28 | url = url, 29 | imageUrl = imageUrl, 30 | publishedTime = publishedTime 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /data/src/main/java/io/github/reactivecircus/streamlined/data/repository/BookmarkRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.data.repository 2 | 3 | import io.github.reactivecircus.streamlined.domain.repository.BookmarkRepository 4 | import javax.inject.Inject 5 | 6 | internal class BookmarkRepositoryImpl @Inject constructor() : BookmarkRepository 7 | -------------------------------------------------------------------------------- /data/src/main/java/io/github/reactivecircus/streamlined/data/repository/StoryRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.data.repository 2 | 3 | import com.dropbox.android.external.store4.StoreResponse 4 | import com.dropbox.android.external.store4.fresh 5 | import io.github.reactivecircus.store.ext.RefreshPolicy 6 | import io.github.reactivecircus.store.ext.streamWithRefreshPolicy 7 | import io.github.reactivecircus.streamlined.data.HeadlineStoryStore 8 | import io.github.reactivecircus.streamlined.data.PersonalizedStoryStore 9 | import io.github.reactivecircus.streamlined.data.mapper.toModel 10 | import io.github.reactivecircus.streamlined.domain.model.Story 11 | import io.github.reactivecircus.streamlined.domain.repository.StoryRepository 12 | import io.github.reactivecircus.streamlined.persistence.StoryDao 13 | import kotlinx.coroutines.ExperimentalCoroutinesApi 14 | import kotlinx.coroutines.FlowPreview 15 | import kotlinx.coroutines.flow.Flow 16 | import javax.inject.Inject 17 | 18 | @FlowPreview 19 | @ExperimentalCoroutinesApi 20 | internal class StoryRepositoryImpl @Inject constructor( 21 | private val headlineStoryStore: HeadlineStoryStore, 22 | private val personalizedStoryStore: PersonalizedStoryStore, 23 | private val refreshPolicy: RefreshPolicy, 24 | private val storyDao: StoryDao 25 | ) : StoryRepository { 26 | 27 | override fun streamHeadlineStories(): Flow>> { 28 | return headlineStoryStore.streamWithRefreshPolicy(Unit, refreshPolicy) 29 | } 30 | 31 | override fun streamPersonalizedStories(query: String): Flow>> { 32 | return personalizedStoryStore.streamWithRefreshPolicy(query, refreshPolicy) 33 | } 34 | 35 | override suspend fun fetchHeadlineStories(): List { 36 | return headlineStoryStore.fresh(Unit) 37 | } 38 | 39 | override suspend fun fetchPersonalizedStories(query: String): List { 40 | return personalizedStoryStore.fresh(query) 41 | } 42 | 43 | override suspend fun getStoryById(id: Long): Story? { 44 | return storyDao.storyById(id)?.toModel() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /data/src/test/java/io/github/reactivecircus/streamlined/data/mapper/DateParserTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.data.mapper 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.junit.Assert.assertThrows 5 | import org.junit.Test 6 | import java.time.format.DateTimeParseException 7 | 8 | class DateParserTest { 9 | 10 | @Test 11 | fun `converts valid date time string to UTC timestamp`() { 12 | assertThat("2020-02-07T22:37:23Z".toTimestamp()) 13 | .isEqualTo(1_581_115_043_000) 14 | assertThat("2020-02-13T10:00:03Z".toTimestamp()) 15 | .isEqualTo(1_581_588_003_000) 16 | } 17 | 18 | @Test 19 | fun `throws exception when converting an invalid date time string to UTC timestamp`() { 20 | assertThrows(DateTimeParseException::class.java) { 21 | "2020/02/07 22:37:23".toTimestamp() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /data/src/test/java/io/github/reactivecircus/streamlined/data/mapper/StoryMappersTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.data.mapper 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import io.github.reactivecircus.streamlined.domain.model.Story 5 | import io.github.reactivecircus.streamlined.persistence.StoryEntity 6 | import io.github.reactivecircus.streamlined.remote.dto.SourceDTO 7 | import io.github.reactivecircus.streamlined.remote.dto.StoryDTO 8 | import org.junit.Test 9 | 10 | class StoryMappersTest { 11 | 12 | private val isHeadline = true 13 | 14 | @Test 15 | fun `StoryDTO to entity()`() { 16 | val storyDTO = StoryDTO( 17 | source = SourceDTO("source"), 18 | author = "author", 19 | title = "Article", 20 | description = "Description...", 21 | url = "url", 22 | urlToImage = "image-url", 23 | publishedAt = "2020-02-07T22:37:23Z" 24 | ) 25 | 26 | val expected = StoryEntity( 27 | id = -1, 28 | source = "source", 29 | title = "Article", 30 | author = "author", 31 | description = "Description...", 32 | url = "url", 33 | imageUrl = "image-url", 34 | publishedTime = 1581115043000L, 35 | isHeadline = isHeadline 36 | ) 37 | 38 | assertThat(storyDTO.toEntity(isHeadline)) 39 | .isEqualTo(expected) 40 | } 41 | 42 | @Test 43 | fun `StoryEntity to model()`() { 44 | val storyEntity = StoryEntity( 45 | id = 1, 46 | source = "source", 47 | title = "Article", 48 | author = "author", 49 | description = "Description...", 50 | url = "url", 51 | imageUrl = "image-url", 52 | publishedTime = 1234L, 53 | isHeadline = isHeadline 54 | ) 55 | 56 | val expected = Story( 57 | id = 1, 58 | source = "source", 59 | title = "Article", 60 | author = "author", 61 | description = "Description...", 62 | url = "url", 63 | imageUrl = "image-url", 64 | publishedTime = 1234L 65 | ) 66 | 67 | assertThat(storyEntity.toModel()) 68 | .isEqualTo(expected) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /data/src/test/java/io/github/reactivecircus/streamlined/data/testutil/TestStoryDao.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.data.testutil 2 | 3 | import io.github.reactivecircus.streamlined.persistence.StoryDao 4 | import io.github.reactivecircus.streamlined.persistence.StoryEntity 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.emptyFlow 7 | 8 | class TestStoryDao( 9 | private val expectedAllStoriesFlow: Flow> = emptyFlow(), 10 | private val expectedHeadlineStoriesFlow: Flow> = emptyFlow(), 11 | private val expectedNonHeadlineStoriesFlow: Flow> = emptyFlow(), 12 | private val expectedStoryByIdResult: StoryEntity? = null, 13 | private val updateStoriesAction: () -> Unit = {}, 14 | private val deleteAllAction: () -> Unit = {}, 15 | private val deleteHeadlineStoriesAction: () -> Unit = {}, 16 | private val deleteNonHeadlineStoriesAction: () -> Unit = {} 17 | ) : StoryDao { 18 | override fun allStories(): Flow> { 19 | return expectedAllStoriesFlow 20 | } 21 | 22 | override fun headlineStories(): Flow> { 23 | return expectedHeadlineStoriesFlow 24 | } 25 | 26 | override fun nonHeadlineStories(): Flow> { 27 | return expectedNonHeadlineStoriesFlow 28 | } 29 | 30 | override fun storyById(id: Long): StoryEntity? { 31 | return expectedStoryByIdResult 32 | } 33 | 34 | override suspend fun updateStories(forHeadlines: Boolean, stories: List) { 35 | updateStoriesAction.invoke() 36 | } 37 | 38 | override suspend fun deleteAll() { 39 | deleteAllAction.invoke() 40 | } 41 | 42 | override suspend fun deleteHeadlineStories() { 43 | deleteHeadlineStoriesAction.invoke() 44 | } 45 | 46 | override suspend fun deleteNonHeadlineStories() { 47 | deleteNonHeadlineStoriesAction.invoke() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /design-themes/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /design-themes/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | id("com.android.library") 6 | kotlin("android") 7 | } 8 | 9 | android { 10 | namespace = "io.github.reactivecircus.streamlined.design" 11 | buildFeatures.androidResources = true 12 | } 13 | 14 | dependencies { 15 | // AndroidX 16 | implementation(Libraries.androidx.appCompat) 17 | 18 | // Material Components 19 | implementation(Libraries.material) 20 | } 21 | -------------------------------------------------------------------------------- /design-themes/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /design-themes/src/main/java/io/github/reactivecircus/streamlined/design/ActivityExt.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.design 2 | 3 | import android.app.Activity 4 | import android.app.ActivityManager 5 | import android.os.Build 6 | import android.util.TypedValue 7 | 8 | fun Activity.setDefaultTaskBarColor() { 9 | val typedValue = TypedValue() 10 | theme.resolveAttribute(android.R.attr.colorBackground, typedValue, true) 11 | 12 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 13 | setTaskDescription( 14 | ActivityManager.TaskDescription(null, 0, typedValue.data) 15 | ) 16 | } else { 17 | @Suppress("DEPRECATION") 18 | setTaskDescription( 19 | ActivityManager.TaskDescription(null, null, typedValue.data) 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /design-themes/src/main/java/io/github/reactivecircus/streamlined/design/SnackbarExt.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.design 2 | 3 | import android.content.res.Configuration 4 | import androidx.core.content.ContextCompat 5 | import com.google.android.material.snackbar.Snackbar 6 | 7 | fun Snackbar.setDefaultBackgroundColor(): Snackbar { 8 | val nightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK 9 | return takeIf { nightMode != Configuration.UI_MODE_NIGHT_YES }?.let { 10 | setBackgroundTint(ContextCompat.getColor(context, android.R.color.background_dark)) 11 | } ?: this 12 | } 13 | -------------------------------------------------------------------------------- /design-themes/src/main/res/anim/slide_in_and_fade_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /design-themes/src/main/res/color/bottom_nav_color.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /design-themes/src/main/res/drawable/ic_twotone_bookmark_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /design-themes/src/main/res/drawable/ic_twotone_bookmark_border_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /design-themes/src/main/res/drawable/ic_twotone_home_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /design-themes/src/main/res/drawable/ic_twotone_language_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /design-themes/src/main/res/drawable/ic_twotone_more_horiz_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /design-themes/src/main/res/font/fira_sans_condensed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /design-themes/src/main/res/font/fira_sans_condensed_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/design-themes/src/main/res/font/fira_sans_condensed_bold.ttf -------------------------------------------------------------------------------- /design-themes/src/main/res/font/fira_sans_condensed_medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/design-themes/src/main/res/font/fira_sans_condensed_medium.ttf -------------------------------------------------------------------------------- /design-themes/src/main/res/font/fjalla_one.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/design-themes/src/main/res/font/fjalla_one.ttf -------------------------------------------------------------------------------- /design-themes/src/main/res/values-night-v27/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 14 | 15 | -------------------------------------------------------------------------------- /design-themes/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | #4fc3f7 10 | #0093c4 11 | #f6f9ff 12 | #f6f9ff 13 | 14 | #2e2858 15 | #2e2858 16 | #b00020 17 | 18 | #2e2858 19 | #2e2858 20 | #f6f9ff 21 | #f6f9ff 22 | #2e2858 23 | 24 | 25 | -------------------------------------------------------------------------------- /design-themes/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /design-themes/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | -------------------------------------------------------------------------------- /design-themes/src/main/res/values-v27/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 14 | 15 | -------------------------------------------------------------------------------- /design-themes/src/main/res/values/attr.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /design-themes/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | #1565c0 10 | #003c8f 11 | #2e2858 12 | #2e2858 13 | 14 | 15 | 16 | 17 | 18 | #f6f9ff 19 | #f6f9ff 20 | #b00020 21 | 22 | 23 | 24 | #ffffff 25 | #f6f9ff 26 | #2e2858 27 | #2e2858 28 | #ffffff 29 | 30 | 31 | -------------------------------------------------------------------------------- /design-themes/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4dp 4 | 8dp 5 | 16dp 6 | 24dp 7 | 32dp 8 | 40dp 9 | 48dp 10 | 64dp 11 | 52dp 12 | 56dp 13 | 72dp 14 | 80dp 15 | 96dp 16 | 104dp 17 | 18 | 1dp 19 | 20 | 24sp 21 | 22 | 23 | @dimen/material_emphasis_high_type 24 | @dimen/material_emphasis_medium 25 | @dimen/material_emphasis_disabled 26 | 27 | 28 | -------------------------------------------------------------------------------- /design-themes/src/main/res/values/shape.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 12 | 13 | 14 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /detekt.yml: -------------------------------------------------------------------------------- 1 | complexity: 2 | LongMethod: 3 | active: false 4 | LongParameterList: 5 | active: false 6 | 7 | formatting: 8 | android: true 9 | ImportOrdering: 10 | active: false 11 | MaximumLineLength: 12 | excludes: ["**/test/**","**/androidTest/**"] 13 | 14 | style: 15 | MaxLineLength: 16 | excludes: ["**/test/**","**/androidTest/**"] 17 | MagicNumber: 18 | ignorePropertyDeclaration: true 19 | UnnecessaryAbstractClass: 20 | active: false 21 | -------------------------------------------------------------------------------- /domain-api/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /domain-api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | kotlin("jvm") 6 | id("com.android.lint") 7 | } 8 | 9 | dependencies { 10 | // Coroutines 11 | api(Libraries.kotlinx.coroutines.core) 12 | 13 | // Store 14 | api(Libraries.store) 15 | } 16 | -------------------------------------------------------------------------------- /domain-api/src/main/java/io/github/reactivecircus/streamlined/domain/model/Story.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.model 2 | 3 | data class Story( 4 | val id: Long, 5 | val source: String, 6 | val author: String?, 7 | val title: String, 8 | val description: String?, 9 | val url: String, 10 | val imageUrl: String?, 11 | val publishedTime: Long 12 | ) 13 | -------------------------------------------------------------------------------- /domain-api/src/main/java/io/github/reactivecircus/streamlined/domain/repository/BookmarkRepository.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.repository 2 | 3 | interface BookmarkRepository 4 | -------------------------------------------------------------------------------- /domain-api/src/main/java/io/github/reactivecircus/streamlined/domain/repository/StoryRepository.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.repository 2 | 3 | import com.dropbox.android.external.store4.StoreResponse 4 | import io.github.reactivecircus.streamlined.domain.model.Story 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | interface StoryRepository { 8 | 9 | fun streamHeadlineStories(): Flow>> 10 | 11 | // TODO use custom query type 12 | fun streamPersonalizedStories(query: String): Flow>> 13 | 14 | suspend fun fetchHeadlineStories(): List 15 | 16 | // TODO use custom query type 17 | suspend fun fetchPersonalizedStories(query: String): List 18 | 19 | suspend fun getStoryById(id: Long): Story? 20 | } 21 | -------------------------------------------------------------------------------- /domain-runtime/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /domain-runtime/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | kotlin("jvm") 6 | kotlin("kapt") 7 | id("com.android.lint") 8 | } 9 | 10 | dependencies { 11 | api(project(":domain-api")) 12 | 13 | // Blueprint 14 | api(Libraries.blueprint.interactorCoroutines) 15 | api(Libraries.blueprint.asyncCoroutines) 16 | 17 | // Hilt 18 | implementation(Libraries.hilt.core) 19 | kapt(Libraries.hilt.compiler) 20 | 21 | // Unit tests 22 | testImplementation(Libraries.junit) 23 | testImplementation(Libraries.truth) 24 | testImplementation(project(":domain-testing")) 25 | testImplementation(project(":coroutines-test-ext")) 26 | } 27 | -------------------------------------------------------------------------------- /domain-runtime/src/main/java/io/github/reactivecircus/streamlined/domain/interactor/FetchHeadlineStories.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.interactor 2 | 3 | import io.github.reactivecircus.streamlined.domain.model.Story 4 | import io.github.reactivecircus.streamlined.domain.repository.StoryRepository 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import reactivecircus.blueprint.async.coroutines.CoroutineDispatcherProvider 7 | import reactivecircus.blueprint.interactor.EmptyParams 8 | import reactivecircus.blueprint.interactor.coroutines.SuspendingInteractor 9 | import javax.inject.Inject 10 | 11 | class FetchHeadlineStories @Inject constructor( 12 | private val storyRepository: StoryRepository, 13 | dispatcherProvider: CoroutineDispatcherProvider 14 | ) : SuspendingInteractor>() { 15 | 16 | override val dispatcher: CoroutineDispatcher = dispatcherProvider.io 17 | 18 | override suspend fun doWork(params: EmptyParams): List { 19 | return storyRepository.fetchHeadlineStories() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /domain-runtime/src/main/java/io/github/reactivecircus/streamlined/domain/interactor/FetchPersonalizedStories.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.interactor 2 | 3 | import io.github.reactivecircus.streamlined.domain.model.Story 4 | import io.github.reactivecircus.streamlined.domain.repository.StoryRepository 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import reactivecircus.blueprint.async.coroutines.CoroutineDispatcherProvider 7 | import reactivecircus.blueprint.interactor.InteractorParams 8 | import reactivecircus.blueprint.interactor.coroutines.SuspendingInteractor 9 | import javax.inject.Inject 10 | 11 | class FetchPersonalizedStories @Inject constructor( 12 | private val storyRepository: StoryRepository, 13 | dispatcherProvider: CoroutineDispatcherProvider 14 | ) : SuspendingInteractor>() { 15 | 16 | override val dispatcher: CoroutineDispatcher = dispatcherProvider.io 17 | 18 | override suspend fun doWork(params: Params): List { 19 | return storyRepository.fetchPersonalizedStories(params.query) 20 | } 21 | 22 | // TODO use custom query / filter type 23 | class Params(internal val query: String) : InteractorParams 24 | } 25 | -------------------------------------------------------------------------------- /domain-runtime/src/main/java/io/github/reactivecircus/streamlined/domain/interactor/GetStoryById.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.interactor 2 | 3 | import io.github.reactivecircus.streamlined.domain.model.Story 4 | import io.github.reactivecircus.streamlined.domain.repository.StoryRepository 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import reactivecircus.blueprint.async.coroutines.CoroutineDispatcherProvider 7 | import reactivecircus.blueprint.interactor.InteractorParams 8 | import reactivecircus.blueprint.interactor.coroutines.SuspendingInteractor 9 | import javax.inject.Inject 10 | 11 | class GetStoryById @Inject constructor( 12 | private val storyRepository: StoryRepository, 13 | dispatcherProvider: CoroutineDispatcherProvider 14 | ) : SuspendingInteractor() { 15 | 16 | override val dispatcher: CoroutineDispatcher = dispatcherProvider.io 17 | 18 | override suspend fun doWork(params: Params): Story { 19 | return storyRepository.getStoryById(params.id) 20 | ?: throw NoSuchElementException("Could not find story for id ${params.id}") 21 | } 22 | 23 | class Params(internal val id: Long) : InteractorParams 24 | } 25 | -------------------------------------------------------------------------------- /domain-runtime/src/main/java/io/github/reactivecircus/streamlined/domain/interactor/StreamHeadlineStories.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.interactor 2 | 3 | import com.dropbox.android.external.store4.StoreResponse 4 | import io.github.reactivecircus.streamlined.domain.model.Story 5 | import io.github.reactivecircus.streamlined.domain.repository.StoryRepository 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import kotlinx.coroutines.flow.Flow 8 | import reactivecircus.blueprint.async.coroutines.CoroutineDispatcherProvider 9 | import reactivecircus.blueprint.interactor.EmptyParams 10 | import reactivecircus.blueprint.interactor.coroutines.FlowInteractor 11 | import javax.inject.Inject 12 | 13 | class StreamHeadlineStories @Inject constructor( 14 | private val storyRepository: StoryRepository, 15 | dispatcherProvider: CoroutineDispatcherProvider 16 | ) : FlowInteractor>>() { 17 | 18 | override val dispatcher: CoroutineDispatcher = dispatcherProvider.io 19 | 20 | override fun createFlow(params: EmptyParams): Flow>> { 21 | return storyRepository.streamHeadlineStories() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /domain-runtime/src/main/java/io/github/reactivecircus/streamlined/domain/interactor/StreamPersonalizedStories.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.interactor 2 | 3 | import com.dropbox.android.external.store4.StoreResponse 4 | import io.github.reactivecircus.streamlined.domain.model.Story 5 | import io.github.reactivecircus.streamlined.domain.repository.StoryRepository 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import kotlinx.coroutines.flow.Flow 8 | import reactivecircus.blueprint.async.coroutines.CoroutineDispatcherProvider 9 | import reactivecircus.blueprint.interactor.InteractorParams 10 | import reactivecircus.blueprint.interactor.coroutines.FlowInteractor 11 | import javax.inject.Inject 12 | 13 | class StreamPersonalizedStories @Inject constructor( 14 | private val storyRepository: StoryRepository, 15 | dispatcherProvider: CoroutineDispatcherProvider 16 | ) : FlowInteractor>>() { 17 | 18 | override val dispatcher: CoroutineDispatcher = dispatcherProvider.io 19 | 20 | override fun createFlow(params: Params): Flow>> { 21 | return storyRepository.streamPersonalizedStories(params.query) 22 | } 23 | 24 | // TODO use custom query / filter type 25 | class Params(internal val query: String) : InteractorParams 26 | } 27 | -------------------------------------------------------------------------------- /domain-runtime/src/main/java/io/github/reactivecircus/streamlined/domain/interactor/SyncStories.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.interactor 2 | 3 | import io.github.reactivecircus.streamlined.domain.repository.StoryRepository 4 | import javax.inject.Inject 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import reactivecircus.blueprint.async.coroutines.CoroutineDispatcherProvider 7 | import reactivecircus.blueprint.interactor.EmptyParams 8 | import reactivecircus.blueprint.interactor.coroutines.SuspendingInteractor 9 | 10 | class SyncStories @Inject constructor( 11 | private val storyRepository: StoryRepository, 12 | dispatcherProvider: CoroutineDispatcherProvider 13 | ) : SuspendingInteractor() { 14 | 15 | override val dispatcher: CoroutineDispatcher = dispatcherProvider.io 16 | 17 | override suspend fun doWork(params: EmptyParams) { 18 | storyRepository 19 | // TODO sync stories (headlines and personalized stories) based on applied filters 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /domain-runtime/src/test/java/io/github/reactivecircus/streamlined/domain/TestCoroutineDispatcherProvider.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain 2 | 3 | import kotlinx.coroutines.ExperimentalCoroutinesApi 4 | import kotlinx.coroutines.test.TestCoroutineDispatcher 5 | import reactivecircus.blueprint.async.coroutines.CoroutineDispatcherProvider 6 | 7 | @ExperimentalCoroutinesApi 8 | internal val testCoroutineDispatcherProvider = CoroutineDispatcherProvider( 9 | io = TestCoroutineDispatcher(), 10 | computation = TestCoroutineDispatcher(), 11 | ui = TestCoroutineDispatcher() 12 | ) 13 | -------------------------------------------------------------------------------- /domain-runtime/src/test/java/io/github/reactivecircus/streamlined/domain/interactor/FetchHeadlineStoriesTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.interactor 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import io.github.reactivecircus.streamlined.domain.repository.FakeStoryRepository 5 | import io.github.reactivecircus.streamlined.domain.testCoroutineDispatcherProvider 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import kotlinx.coroutines.test.runBlockingTest 8 | import org.junit.Test 9 | import reactivecircus.blueprint.interactor.EmptyParams 10 | 11 | @ExperimentalCoroutinesApi 12 | class FetchHeadlineStoriesTest { 13 | 14 | private val storyRepository = FakeStoryRepository() 15 | 16 | private val fetchHeadlineStories = FetchHeadlineStories( 17 | storyRepository = storyRepository, 18 | dispatcherProvider = testCoroutineDispatcherProvider 19 | ) 20 | 21 | @Test 22 | fun `headline stories fetched from repository are returned`() = 23 | runBlockingTest { 24 | val result = fetchHeadlineStories.execute(EmptyParams) 25 | 26 | assertThat(result) 27 | .isEqualTo(FakeStoryRepository.DUMMY_HEADLINE_STORY_LIST) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /domain-runtime/src/test/java/io/github/reactivecircus/streamlined/domain/interactor/FetchPersonalizedStoriesTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.interactor 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import io.github.reactivecircus.streamlined.domain.repository.FakeStoryRepository 5 | import io.github.reactivecircus.streamlined.domain.testCoroutineDispatcherProvider 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import kotlinx.coroutines.test.runBlockingTest 8 | import org.junit.Test 9 | 10 | @ExperimentalCoroutinesApi 11 | class FetchPersonalizedStoriesTest { 12 | 13 | private val storyRepository = FakeStoryRepository() 14 | 15 | private val fetchPersonalizedStories = FetchPersonalizedStories( 16 | storyRepository = storyRepository, 17 | dispatcherProvider = testCoroutineDispatcherProvider, 18 | ) 19 | 20 | @Test 21 | fun `personalized stories fetched from repository are returned`() = runBlockingTest { 22 | val query = "query" 23 | 24 | val result = fetchPersonalizedStories 25 | .execute(FetchPersonalizedStories.Params(query)) 26 | 27 | assertThat(result) 28 | .isEqualTo(FakeStoryRepository.DUMMY_PERSONALIZED_STORY_LIST) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /domain-runtime/src/test/java/io/github/reactivecircus/streamlined/domain/interactor/GetStoryByIdTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.interactor 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import io.github.reactivecircus.coroutines.test.ext.assertThrows 5 | import io.github.reactivecircus.streamlined.domain.repository.FakeStoryRepository 6 | import io.github.reactivecircus.streamlined.domain.testCoroutineDispatcherProvider 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.test.runBlockingTest 9 | import org.junit.Test 10 | 11 | @ExperimentalCoroutinesApi 12 | class GetStoryByIdTest { 13 | 14 | private val storyRepository = FakeStoryRepository() 15 | 16 | private val getStoryById = GetStoryById( 17 | storyRepository = storyRepository, 18 | dispatcherProvider = testCoroutineDispatcherProvider 19 | ) 20 | 21 | @Test 22 | fun `getStoryById from repository when story is found`() = runBlockingTest { 23 | val id = FakeStoryRepository.DUMMY_HEADLINE_STORY_LIST[0].id 24 | 25 | assertThat(getStoryById.execute(GetStoryById.Params(id))) 26 | .isEqualTo(FakeStoryRepository.DUMMY_HEADLINE_STORY_LIST[0]) 27 | } 28 | 29 | @Test 30 | fun `getStoryById from repository when story is not found`() = runBlockingTest { 31 | val id = 100L 32 | 33 | val exception = assertThrows { 34 | getStoryById.execute(GetStoryById.Params(id)) 35 | } 36 | 37 | assertThat(exception) 38 | .hasMessageThat() 39 | .isEqualTo("Could not find story for id $id") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /domain-runtime/src/test/java/io/github/reactivecircus/streamlined/domain/interactor/StreamHeadlineStoriesTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.interactor 2 | 3 | import com.dropbox.android.external.store4.ResponseOrigin 4 | import com.dropbox.android.external.store4.StoreResponse 5 | import io.github.reactivecircus.coroutines.test.ext.assertThat 6 | import io.github.reactivecircus.streamlined.domain.repository.FakeStoryRepository 7 | import io.github.reactivecircus.streamlined.domain.testCoroutineDispatcherProvider 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.test.runBlockingTest 10 | import org.junit.Test 11 | import reactivecircus.blueprint.interactor.EmptyParams 12 | 13 | @ExperimentalCoroutinesApi 14 | class StreamHeadlineStoriesTest { 15 | 16 | private val storyRepository = FakeStoryRepository() 17 | 18 | private val streamHeadlineStories = StreamHeadlineStories( 19 | storyRepository = storyRepository, 20 | dispatcherProvider = testCoroutineDispatcherProvider 21 | ) 22 | 23 | @Test 24 | fun `streamHeadlineStories from repository`() = runBlockingTest { 25 | assertThat(streamHeadlineStories.buildFlow(EmptyParams)).emitsExactly( 26 | StoreResponse.Data( 27 | FakeStoryRepository.DUMMY_HEADLINE_STORY_LIST, 28 | ResponseOrigin.Fetcher 29 | ) 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /domain-runtime/src/test/java/io/github/reactivecircus/streamlined/domain/interactor/StreamPersonalizedStoriesTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.interactor 2 | 3 | import com.dropbox.android.external.store4.ResponseOrigin 4 | import com.dropbox.android.external.store4.StoreResponse 5 | import io.github.reactivecircus.coroutines.test.ext.assertThat 6 | import io.github.reactivecircus.streamlined.domain.repository.FakeStoryRepository 7 | import io.github.reactivecircus.streamlined.domain.testCoroutineDispatcherProvider 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.test.runBlockingTest 10 | import org.junit.Test 11 | 12 | @ExperimentalCoroutinesApi 13 | class StreamPersonalizedStoriesTest { 14 | 15 | private val storyRepository = FakeStoryRepository() 16 | 17 | private val streamPersonalizedStories = StreamPersonalizedStories( 18 | storyRepository = storyRepository, 19 | dispatcherProvider = testCoroutineDispatcherProvider 20 | ) 21 | 22 | @Test 23 | fun `streamPersonalizedStories from repository`() = runBlockingTest { 24 | val query = "query" 25 | 26 | val params = StreamPersonalizedStories.Params(query) 27 | assertThat(streamPersonalizedStories.buildFlow(params)).emitsExactly( 28 | StoreResponse.Data( 29 | FakeStoryRepository.DUMMY_PERSONALIZED_STORY_LIST, 30 | ResponseOrigin.Fetcher 31 | ) 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /domain-testing/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /domain-testing/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | kotlin("jvm") 6 | id("com.android.lint") 7 | } 8 | 9 | dependencies { 10 | implementation(project(":domain-api")) 11 | 12 | // Coroutines 13 | implementation(Libraries.kotlinx.coroutines.core) 14 | 15 | // Unit tests 16 | testImplementation(Libraries.junit) 17 | testImplementation(Libraries.truth) 18 | testImplementation(project(":coroutines-test-ext")) 19 | } 20 | -------------------------------------------------------------------------------- /domain-testing/src/main/java/io/github/reactivecircus/streamlined/domain/repository/FakeResponse.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.domain.repository 2 | 3 | sealed class FakeResponse { 4 | data class Success(val result: Output) : FakeResponse() 5 | data class Error(val exception: Exception) : FakeResponse() 6 | } 7 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.parallel=true 2 | org.gradle.configureondemand=true 3 | org.gradle.caching=true 4 | 5 | # Enable Kotlin incremental compilation 6 | kotlin.incremental=true 7 | 8 | # Kotlin code style 9 | kotlin.code.style=official 10 | 11 | # Turn off AP discovery in compile path to enable compile avoidance 12 | kapt.include.compile.classpath=false 13 | 14 | # Use R8 instead of ProGuard for code shrinking. 15 | android.enableR8.fullMode=true 16 | 17 | # Enable AndroidX 18 | android.useAndroidX=true 19 | 20 | # Enable non-transitive R class namespacing where each library only contains 21 | # references to the resources it declares instead of declarations plus all 22 | # transitive dependency references. 23 | android.nonTransitiveRClass=true 24 | 25 | # Generate compile-time only R class for app modules. 26 | # TODO re-enable once fixed in AGP: android.enableAppCompileTimeRClass=true 27 | 28 | # Experimental flags 29 | android.keepWorkerActionServicesBetweenBuilds=true 30 | android.nonFinalResIds=true 31 | android.enablePartialRIncrementalBuilds=true 32 | android.experimental.enableNewResourceShrinker.preciseShrinking=true 33 | android.generateManifestClass=true 34 | 35 | # Default Android build features 36 | android.defaults.buildfeatures.buildconfig=false 37 | android.defaults.buildfeatures.aidl=false 38 | android.defaults.buildfeatures.renderscript=false 39 | android.defaults.buildfeatures.resvalues=false 40 | android.defaults.buildfeatures.shaders=false 41 | android.library.defaults.buildfeatures.androidresources=false 42 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Jan 09 00:38:45 AEDT 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip 7 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /libraries/analytics/analytics-api-base/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /libraries/analytics/analytics-api-base/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `streamlined-plugin` 3 | kotlin("jvm") 4 | id("com.android.lint") 5 | } 6 | -------------------------------------------------------------------------------- /libraries/analytics/analytics-api-base/src/main/java/io/github/reactivecircus/analytics/AnalyticsApi.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.analytics 2 | 3 | interface AnalyticsApi { 4 | 5 | fun setCurrentScreenName(screenName: String, screenClass: String) 6 | 7 | fun setEnableAnalytics(enable: Boolean) 8 | 9 | fun setUserId(userId: String?) 10 | 11 | fun setUserProperty(name: String, value: String) 12 | 13 | fun logEvent(name: String, params: Map? = null) 14 | } 15 | -------------------------------------------------------------------------------- /libraries/analytics/analytics-api-firebase/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /libraries/analytics/analytics-api-firebase/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | id("com.android.library") 6 | kotlin("android") 7 | } 8 | 9 | android { 10 | namespace = "io.github.reactivecircus.analytics.firebase" 11 | } 12 | 13 | dependencies { 14 | api(project(":analytics-api-base")) 15 | 16 | // Firebase 17 | api(Libraries.firebase.analyticsKtx) 18 | } 19 | -------------------------------------------------------------------------------- /libraries/analytics/analytics-api-firebase/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /libraries/analytics/analytics-api-firebase/src/main/java/io/github/reactivecircus/analytics/firebase/FirebaseAnalyticsApi.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.analytics.firebase 2 | 3 | import com.google.firebase.analytics.FirebaseAnalytics 4 | import com.google.firebase.analytics.ktx.analytics 5 | import com.google.firebase.analytics.ktx.logEvent 6 | import com.google.firebase.ktx.Firebase 7 | import io.github.reactivecircus.analytics.AnalyticsApi 8 | 9 | object FirebaseAnalyticsApi : AnalyticsApi { 10 | 11 | private val firebaseAnalytics: FirebaseAnalytics = Firebase.analytics 12 | 13 | override fun setCurrentScreenName(screenName: String, screenClass: String) { 14 | firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) { 15 | param(FirebaseAnalytics.Param.SCREEN_NAME, screenName) 16 | param(FirebaseAnalytics.Param.SCREEN_CLASS, screenClass) 17 | } 18 | } 19 | 20 | override fun setEnableAnalytics(enable: Boolean) { 21 | firebaseAnalytics.setAnalyticsCollectionEnabled(enable) 22 | } 23 | 24 | override fun setUserId(userId: String?) { 25 | firebaseAnalytics.setUserId(userId) 26 | } 27 | 28 | override fun setUserProperty(name: String, value: String) { 29 | firebaseAnalytics.setUserProperty(name, value) 30 | } 31 | 32 | override fun logEvent(name: String, params: Map?) { 33 | firebaseAnalytics.logEvent(name) { 34 | params?.entries?.run { 35 | forEach { entry -> 36 | when (entry.value) { 37 | is String -> param(entry.key, entry.value as String) 38 | is Long -> param(entry.key, (entry.value as Long)) 39 | is Double -> param(entry.key, (entry.value as Double)) 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /libraries/analytics/analytics-api-no-op/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /libraries/analytics/analytics-api-no-op/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `streamlined-plugin` 3 | kotlin("jvm") 4 | id("com.android.lint") 5 | } 6 | 7 | dependencies { 8 | api(project(":analytics-api-base")) 9 | } 10 | -------------------------------------------------------------------------------- /libraries/analytics/analytics-api-no-op/src/main/java/io/github/reactivecircus/analytics/noop/NoOpAnalyticsApi.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.analytics.noop 2 | 3 | import io.github.reactivecircus.analytics.AnalyticsApi 4 | 5 | object NoOpAnalyticsApi : AnalyticsApi { 6 | 7 | override fun setCurrentScreenName(screenName: String, screenClass: String) = Unit 8 | 9 | override fun setEnableAnalytics(enable: Boolean) = Unit 10 | 11 | override fun setUserId(userId: String?) = Unit 12 | 13 | override fun setUserProperty(name: String, value: String) = Unit 14 | 15 | override fun logEvent(name: String, params: Map?) = Unit 16 | } 17 | -------------------------------------------------------------------------------- /libraries/bugsnag-tree/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /libraries/bugsnag-tree/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | id("com.android.library") 6 | kotlin("android") 7 | } 8 | 9 | android { 10 | namespace = "io.github.reactivecircus.bugsnag" 11 | } 12 | 13 | dependencies { 14 | // timber 15 | implementation(Libraries.timber) 16 | 17 | // Bugsnag 18 | api(Libraries.bugsnag) { 19 | exclude(module = "bugsnag-plugin-android-ndk") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /libraries/bugsnag-tree/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /libraries/coroutines-test-ext/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /libraries/coroutines-test-ext/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | kotlin("jvm") 6 | id("com.android.lint") 7 | } 8 | 9 | dependencies { 10 | implementation(Libraries.junit) 11 | implementation(Libraries.truth) 12 | implementation(Libraries.kotlinx.coroutines.core) 13 | api(Libraries.kotlinx.coroutines.test) 14 | } 15 | -------------------------------------------------------------------------------- /libraries/coroutines-test-ext/src/main/java/io/github/reactivecircus/coroutines/test/ext/AssertThrows.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.coroutines.test.ext 2 | 3 | import kotlinx.coroutines.ExperimentalCoroutinesApi 4 | import kotlinx.coroutines.async 5 | import kotlinx.coroutines.test.TestCoroutineScope 6 | import org.junit.Assert 7 | import org.junit.function.ThrowingRunnable 8 | 9 | @ExperimentalCoroutinesApi 10 | inline fun TestCoroutineScope.assertThrows( 11 | crossinline runnable: suspend () -> Unit 12 | ): T { 13 | val throwingRunnable = ThrowingRunnable { 14 | val job = async { runnable() } 15 | job.getCompletionExceptionOrNull()?.run { throw this } 16 | job.cancel() 17 | } 18 | return Assert.assertThrows(T::class.java, throwingRunnable) 19 | } 20 | -------------------------------------------------------------------------------- /libraries/coroutines-test-ext/src/main/java/io/github/reactivecircus/coroutines/test/ext/CoroutinesTestRule.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.coroutines.test.ext 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.test.TestCoroutineDispatcher 6 | import kotlinx.coroutines.test.resetMain 7 | import kotlinx.coroutines.test.setMain 8 | import org.junit.rules.TestWatcher 9 | import org.junit.runner.Description 10 | 11 | /** 12 | * A test rule that sets the Main coroutine dispatcher for unit testing. 13 | */ 14 | @ExperimentalCoroutinesApi 15 | class CoroutinesTestRule( 16 | val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() 17 | ) : TestWatcher() { 18 | 19 | override fun starting(description: Description?) { 20 | super.starting(description) 21 | Dispatchers.setMain(testDispatcher) 22 | } 23 | 24 | override fun finished(description: Description?) { 25 | super.finished(description) 26 | Dispatchers.resetMain() 27 | testDispatcher.cleanupTestCoroutines() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /libraries/coroutines-test-ext/src/main/java/io/github/reactivecircus/coroutines/test/ext/FlowRecorder.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.coroutines.test.ext 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.launchIn 6 | import kotlinx.coroutines.flow.onEach 7 | 8 | fun Flow.recordWith(recorder: FlowRecorder) { 9 | onEach { recorder.values += it }.launchIn(recorder.coroutineScope) 10 | } 11 | 12 | class FlowRecorder(internal val coroutineScope: CoroutineScope) { 13 | 14 | internal val values = mutableListOf() 15 | 16 | /** 17 | * Takes the first [numberOfValues] recorded values emitted by the [Flow]. 18 | */ 19 | fun take(numberOfValues: Int): List { 20 | require(numberOfValues > 0) { 21 | "Least number of values to take is 1." 22 | } 23 | require(numberOfValues <= values.size) { 24 | "Taking $numberOfValues but only ${values.size} value(s) have been recorded." 25 | } 26 | val drainedValues = mutableListOf() 27 | while (drainedValues.size < numberOfValues) { 28 | drainedValues += values.removeFirst() 29 | } 30 | return drainedValues 31 | } 32 | 33 | /** 34 | * Takes all recorded values emitted by the [Flow]. 35 | */ 36 | fun takeAll(): List { 37 | val drainedValues = buildList { addAll(values) } 38 | values.clear() 39 | return drainedValues 40 | } 41 | 42 | /** 43 | * Clears all recorded values emitted by the [Flow]. 44 | */ 45 | fun reset() { 46 | values.clear() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /libraries/store-ext/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /libraries/store-ext/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | kotlin("jvm") 6 | id("com.android.lint") 7 | } 8 | 9 | dependencies { 10 | // Coroutines 11 | implementation(Libraries.kotlinx.coroutines.core) 12 | 13 | // Store 14 | implementation(Libraries.store) 15 | 16 | // Unit tests 17 | testImplementation(Libraries.junit) 18 | testImplementation(Libraries.truth) 19 | testImplementation(project(":coroutines-test-ext")) 20 | } 21 | -------------------------------------------------------------------------------- /libraries/store-ext/src/main/java/io/github/reactivecircus/store/ext/RefreshPolicy.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.store.ext 2 | 3 | import com.dropbox.android.external.store4.ResponseOrigin 4 | import com.dropbox.android.external.store4.StoreResponse 5 | 6 | /** 7 | * An API that defines the policy for whether [Store.streamWithRefreshPolicy(key, refreshPolicy)] 8 | * should start by doing a fetch from network. 9 | */ 10 | interface RefreshPolicy { 11 | 12 | /** 13 | * Returns whether the store should fetch data from the network at the start of the stream. 14 | * @param refreshScope a string that represents a specific data set whose refresh policy 15 | * the consumer is concerned with. 16 | */ 17 | suspend fun shouldRefresh(refreshScope: RefreshScope): Boolean 18 | 19 | /** 20 | * Called when a new [StoreResponse.Data] with [ResponseOrigin.Fetcher] is emitted from the stream. 21 | * @param refreshScope a string that represents a specific data set that has just been refreshed. 22 | */ 23 | suspend fun onRefreshed(refreshScope: RefreshScope) 24 | } 25 | 26 | /** 27 | * A string representation of a specific data set which the [RefreshPolicy] is concerned with. 28 | */ 29 | @JvmInline 30 | value class RefreshScope(val scope: String) 31 | -------------------------------------------------------------------------------- /libraries/store-ext/src/main/java/io/github/reactivecircus/store/ext/StoreExt.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.store.ext 2 | 3 | import com.dropbox.android.external.store4.ResponseOrigin 4 | import com.dropbox.android.external.store4.Store 5 | import com.dropbox.android.external.store4.StoreRequest 6 | import com.dropbox.android.external.store4.StoreResponse 7 | import kotlinx.coroutines.FlowPreview 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.flatMapConcat 10 | import kotlinx.coroutines.flow.flow 11 | import kotlinx.coroutines.flow.onEach 12 | 13 | /** 14 | * Returns a [Flow] of [StoreResponse] with the [StoreRequest.cached] request, using [refreshPolicy] 15 | * to decide whether to do a `refresh` at the start. 16 | */ 17 | @FlowPreview 18 | inline fun Store.streamWithRefreshPolicy( 19 | key: Key, 20 | refreshPolicy: RefreshPolicy 21 | ): Flow> { 22 | val refreshScope = getRefreshScope(key) 23 | return flow { emit(refreshPolicy.shouldRefresh(refreshScope)) } 24 | .flatMapConcat { shouldRefresh -> 25 | stream(StoreRequest.cached(key, refresh = shouldRefresh)) 26 | } 27 | .onEach { response -> 28 | if (response is StoreResponse.Data && response.origin == ResponseOrigin.Fetcher) { 29 | refreshPolicy.onRefreshed(refreshScope) 30 | } 31 | } 32 | } 33 | 34 | /** 35 | * Generates the refreshScope of the [RefreshPolicy] from a [Store]'s Output type and a [key]. 36 | */ 37 | @PublishedApi 38 | internal inline fun getRefreshScope( 39 | key: Key 40 | ): RefreshScope { 41 | return RefreshScope("$key ${Output::class.java}") 42 | } 43 | -------------------------------------------------------------------------------- /libraries/store-ext/src/test/java/io/github/reactivecircus/store/ext/RefreshScopeGeneratorTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.store.ext 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.junit.Test 5 | 6 | class RefreshScopeGeneratorTest { 7 | 8 | @Test 9 | fun `generated refreshScope is a unique combination of Store key and the Store's Output type`() { 10 | assertThat(getRefreshScope("key1").scope) 11 | .isEqualTo("key1 class java.lang.Integer") 12 | 13 | assertThat(getRefreshScope("key2").scope) 14 | .isEqualTo("key2 class java.lang.Long") 15 | 16 | assertThat(getRefreshScope>(3).scope) 17 | .isEqualTo("3 class kotlin.collections.ArrayDeque") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libraries/store-ext/src/test/java/io/github/reactivecircus/store/ext/testutil/FlowingTestPersister.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.store.ext.testutil 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | class FlowingTestPersister(private val responseFlow: Flow) { 6 | 7 | @Suppress("RedundantSuspendModifier", "UNUSED_PARAMETER") 8 | fun read(key: Key): Flow = responseFlow 9 | 10 | @Suppress("RedundantSuspendModifier", "UNUSED_PARAMETER") 11 | suspend fun write(key: Key, output: Output) = Unit 12 | } 13 | -------------------------------------------------------------------------------- /libraries/store-ext/src/test/java/io/github/reactivecircus/store/ext/testutil/NonFlowingTestPersister.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.store.ext.testutil 2 | 3 | class NonFlowingTestPersister { 4 | 5 | private val data = mutableMapOf() 6 | 7 | @Suppress("RedundantSuspendModifier") 8 | suspend fun read(key: Key): Output? = data[key] 9 | 10 | @Suppress("RedundantSuspendModifier") 11 | suspend fun write(key: Key, output: Output) { 12 | data[key] = output 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /libraries/store-ext/src/test/java/io/github/reactivecircus/store/ext/testutil/TestFetcher.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.store.ext.testutil 2 | 3 | class TestFetcher( 4 | private vararg val scheduledResponses: FetcherResponse 5 | ) { 6 | private var index = 0 7 | 8 | @Suppress("RedundantSuspendModifier", "UNUSED_PARAMETER") 9 | suspend fun fetch(key: Key): Output { 10 | if (index >= scheduledResponses.size) { 11 | throw AssertionError("unexpected fetch request") 12 | } 13 | when (val response = scheduledResponses[index++]) { 14 | is FetcherResponse.Success -> return response.output 15 | is FetcherResponse.Error -> throw response.exception 16 | } 17 | } 18 | } 19 | 20 | sealed class FetcherResponse { 21 | data class Success(val output: Output) : FetcherResponse() 22 | data class Error(val exception: Exception) : FetcherResponse() 23 | } 24 | -------------------------------------------------------------------------------- /navigator/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /navigator/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | id("com.android.library") 6 | kotlin("android") 7 | id("kotlin-parcelize") 8 | } 9 | 10 | android { 11 | namespace = "io.github.reactivecircus.streamlined.navigator" 12 | buildFeatures.androidResources = true 13 | } 14 | 15 | dependencies { 16 | // AndroidX 17 | implementation(Libraries.androidx.annotation) 18 | implementation(Libraries.androidx.fragment.ktx) 19 | implementation(Libraries.androidx.navigation.fragmentKtx) 20 | 21 | // timber 22 | implementation(Libraries.timber) 23 | } 24 | -------------------------------------------------------------------------------- /navigator/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /navigator/src/main/java/io/github/reactivecircus/streamlined/navigator/NavControllerType.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.navigator 2 | 3 | import androidx.annotation.IdRes 4 | 5 | sealed class NavControllerType { 6 | /** 7 | * Navigate to a root-level destination within nested navigation graphs. 8 | */ 9 | object Root : NavControllerType() 10 | 11 | /** 12 | * Navigate to a sibling destination within nested navigation graphs. 13 | */ 14 | object Parent : NavControllerType() 15 | 16 | /** 17 | * Navigate to a child destination within nested navigation graphs. 18 | */ 19 | data class Child(@IdRes val navHostFragmentId: Int) : NavControllerType() 20 | } 21 | -------------------------------------------------------------------------------- /navigator/src/main/java/io/github/reactivecircus/streamlined/navigator/NavigationExt.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.navigator 2 | 3 | import android.os.Bundle 4 | import android.os.Parcelable 5 | import android.view.View 6 | import androidx.annotation.IdRes 7 | import androidx.core.os.bundleOf 8 | import androidx.fragment.app.Fragment 9 | import androidx.navigation.NavController 10 | import androidx.navigation.findNavController 11 | import androidx.navigation.fragment.NavHostFragment 12 | import androidx.navigation.fragment.findNavController 13 | import timber.log.Timber 14 | 15 | fun Fragment.navigate( 16 | @IdRes destination: Int, 17 | args: Parcelable? = null, 18 | navControllerType: NavControllerType = NavControllerType.Parent, 19 | ) = navControllerByType(navControllerType).safeNavigate(destination, createNavArgsBundle(args)) 20 | 21 | fun Fragment.requireNavInput(): Input = 22 | requireArguments().getParcelable(NAV_ARGS_KEY)!! 23 | 24 | fun Fragment.navInputOrNull(): Input? = 25 | arguments?.getParcelable(NAV_ARGS_KEY) 26 | 27 | fun Fragment.navControllerByType( 28 | navControllerType: NavControllerType = NavControllerType.Parent 29 | ): NavController = when (navControllerType) { 30 | NavControllerType.Root -> requireActivity() 31 | .findViewById(R.id.rootNavHostFragment)?.findNavController() 32 | ?: findNavController() 33 | NavControllerType.Parent -> findNavController() 34 | is NavControllerType.Child -> { 35 | val navHostFragment = childFragmentManager 36 | .findFragmentById(navControllerType.navHostFragmentId) as NavHostFragment 37 | navHostFragment.navController 38 | } 39 | } 40 | 41 | fun createNavArgsBundle(args: Parcelable?): Bundle? = args?.let { 42 | bundleOf(NAV_ARGS_KEY to it) 43 | } 44 | 45 | private const val NAV_ARGS_KEY = "NAV_ARGS_KEY" 46 | 47 | /** 48 | * Performs a navigation on the [NavController] using the provided [destination] and [args], 49 | * catching any [IllegalArgumentException] which usually happens when users trigger (e.g. click) 50 | * navigation multiple times very quickly on slower devices. 51 | * For more context, see https://stackoverflow.com/questions/51060762/illegalargumentexception-navigation-destination-xxx-is-unknown-to-this-navcontr. 52 | */ 53 | private fun NavController.safeNavigate(destination: Int, args: Bundle?) { 54 | try { 55 | navigate(destination, args) 56 | } catch (e: IllegalArgumentException) { 57 | Timber.e(e, "Handled navigation destination not found issue gracefully.") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /navigator/src/main/java/io/github/reactivecircus/streamlined/navigator/input/StoryDetailsInput.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.navigator.input 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | @Parcelize 7 | data class StoryDetailsInput( 8 | val storyId: Long 9 | ) : Parcelable 10 | -------------------------------------------------------------------------------- /navigator/src/main/res/values/navigation_ids.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /persistence/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /persistence/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | id("com.android.library") 6 | id("com.squareup.sqldelight") 7 | kotlin("android") 8 | kotlin("kapt") 9 | } 10 | 11 | sqldelight { 12 | database("StreamlinedDatabase") { 13 | schemaOutputDirectory = file("src/main/sqldelight/databases") 14 | } 15 | } 16 | 17 | dependencies { 18 | // SQLDelight 19 | implementation(Libraries.sqldelight.driver.android) 20 | implementation(Libraries.sqldelight.coroutinesExtensions) 21 | 22 | // Coroutines 23 | implementation(Libraries.kotlinx.coroutines.core) 24 | 25 | // Hilt 26 | implementation(Libraries.hilt.android) 27 | kapt(Libraries.hilt.compiler) 28 | 29 | // timber 30 | implementation(Libraries.timber) 31 | 32 | // Unit tests 33 | testImplementation(Libraries.junit) 34 | testImplementation(Libraries.truth) 35 | testImplementation(Libraries.sqldelight.driver.jvm) 36 | testImplementation(project(":coroutines-test-ext")) 37 | } 38 | -------------------------------------------------------------------------------- /persistence/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /persistence/src/main/java/io/github/reactivecircus/streamlined/persistence/DatabaseConfigs.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.persistence 2 | 3 | import kotlin.coroutines.CoroutineContext 4 | 5 | class DatabaseConfigs( 6 | val databaseName: String?, 7 | val coroutineContext: CoroutineContext, 8 | ) 9 | -------------------------------------------------------------------------------- /persistence/src/main/java/io/github/reactivecircus/streamlined/persistence/SQLiteConstants.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("MagicNumber") 2 | 3 | package io.github.reactivecircus.streamlined.persistence 4 | 5 | // SQLite only supports up to 999 parameters in a single statement 6 | internal const val MAX_PARAMETERS_PER_STATEMENT = 999 7 | -------------------------------------------------------------------------------- /persistence/src/main/java/io/github/reactivecircus/streamlined/persistence/StoryDao.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.persistence 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface StoryDao { 6 | 7 | fun allStories(): Flow> 8 | 9 | fun headlineStories(): Flow> 10 | 11 | fun nonHeadlineStories(): Flow> 12 | 13 | fun storyById(id: Long): StoryEntity? 14 | 15 | suspend fun updateStories(forHeadlines: Boolean, stories: List) 16 | 17 | suspend fun deleteAll() 18 | 19 | suspend fun deleteHeadlineStories() 20 | 21 | suspend fun deleteNonHeadlineStories() 22 | } 23 | -------------------------------------------------------------------------------- /persistence/src/main/java/io/github/reactivecircus/streamlined/persistence/di/PersistenceModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.persistence.di 2 | 3 | import android.content.Context 4 | import com.squareup.sqldelight.android.AndroidSqliteDriver 5 | import dagger.Binds 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.Reusable 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import io.github.reactivecircus.streamlined.persistence.DatabaseConfigs 13 | import io.github.reactivecircus.streamlined.persistence.StoryDao 14 | import io.github.reactivecircus.streamlined.persistence.StoryDaoImpl 15 | import io.github.reactivecircus.streamlined.persistence.StoryEntityQueries 16 | import io.github.reactivecircus.streamlined.persistence.StreamlinedDatabase 17 | import javax.inject.Singleton 18 | 19 | @Module 20 | @InstallIn(SingletonComponent::class) 21 | internal abstract class PersistenceModule { 22 | 23 | @Binds 24 | @Reusable 25 | abstract fun storyDao(impl: StoryDaoImpl): StoryDao 26 | 27 | internal companion object { 28 | 29 | @Provides 30 | @Singleton 31 | fun database( 32 | @ApplicationContext context: Context, 33 | databaseConfigs: DatabaseConfigs, 34 | ): StreamlinedDatabase { 35 | return StreamlinedDatabase( 36 | AndroidSqliteDriver( 37 | StreamlinedDatabase.Schema, 38 | context, 39 | databaseConfigs.databaseName, 40 | ) 41 | ) 42 | } 43 | 44 | @Provides 45 | @Reusable 46 | fun storyQueries(database: StreamlinedDatabase): StoryEntityQueries { 47 | return database.storyEntityQueries 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /persistence/src/main/sqldelight/io/github/reactivecircus/streamlined/persistence/StoryEntity.sq: -------------------------------------------------------------------------------- 1 | CREATE TABLE storyEntity ( 2 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 3 | source TEXT NOT NULL, 4 | title TEXT NOT NULL, 5 | author TEXT, 6 | description TEXT, 7 | url TEXT NOT NULL, 8 | imageUrl TEXT, 9 | publishedTime INTEGER NOT NULL, 10 | isHeadline INTEGER AS Boolean NOT NULL, 11 | UNIQUE (title, publishedTime) 12 | ); 13 | 14 | CREATE INDEX storyEntity_title_publishedTime ON storyEntity(title, publishedTime); 15 | 16 | findAllStories: 17 | SELECT * FROM storyEntity; 18 | 19 | findStories: 20 | SELECT * FROM storyEntity 21 | WHERE isHeadline = ?1; 22 | 23 | findStoryIds: 24 | SELECT id FROM storyEntity 25 | WHERE isHeadline = ?1; 26 | 27 | findStoryById: 28 | SELECT * FROM 29 | storyEntity WHERE id = ?; 30 | 31 | findStoryIdByTitleAndPublishedTime: 32 | SELECT id FROM storyEntity 33 | WHERE title = ?1 AND publishedTime = ?2; 34 | 35 | insertStory: 36 | INSERT OR FAIL INTO storyEntity(title, source, author, description, url, imageUrl, publishedTime, isHeadline) 37 | VALUES (?, ?, ?, ?, ?, ?, ?, ?); 38 | 39 | updateStory: 40 | UPDATE storyEntity 41 | SET author = ?3, 42 | description = ?4, 43 | url = ?5, 44 | imageUrl = ?6 45 | WHERE title = ?1 AND publishedTime = ?2; 46 | 47 | deleteAll: 48 | DELETE FROM storyEntity; 49 | 50 | deleteHeadlineStories: 51 | DELETE FROM storyEntity 52 | WHERE isHeadline = 1; 53 | 54 | deleteNonHeadlineStories: 55 | DELETE FROM storyEntity 56 | WHERE isHeadline = 0; 57 | 58 | deleteStoriesByIds: 59 | DELETE FROM storyEntity 60 | WHERE id IN ?; 61 | -------------------------------------------------------------------------------- /remote-base/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /remote-base/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | kotlin("jvm") 6 | kotlin("plugin.serialization") 7 | id("com.android.lint") 8 | } 9 | 10 | dependencies { 11 | // Retrofit 12 | implementation(Libraries.retrofit.client) 13 | 14 | // Serialization 15 | implementation(Libraries.kotlinx.serialization) 16 | } 17 | -------------------------------------------------------------------------------- /remote-base/src/main/java/io/github/reactivecircus/streamlined/remote/api/NewsApiService.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.remote.api 2 | 3 | import io.github.reactivecircus.streamlined.remote.dto.StoryListResponse 4 | import retrofit2.http.GET 5 | import retrofit2.http.Query 6 | 7 | interface NewsApiService { 8 | 9 | @GET("top-headlines") 10 | suspend fun headlines(@Query("country") country: String): StoryListResponse 11 | 12 | @GET("everything") 13 | suspend fun everything(@Query("q") query: String): StoryListResponse 14 | } 15 | -------------------------------------------------------------------------------- /remote-base/src/main/java/io/github/reactivecircus/streamlined/remote/dto/StoryDTO.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.remote.dto 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | class StoryDTO( 7 | val source: SourceDTO, 8 | val author: String?, 9 | val title: String, 10 | val description: String?, 11 | val url: String, 12 | val urlToImage: String?, 13 | val publishedAt: String 14 | ) 15 | 16 | @Serializable 17 | class SourceDTO(val name: String) 18 | -------------------------------------------------------------------------------- /remote-base/src/main/java/io/github/reactivecircus/streamlined/remote/dto/StoryListResponse.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.remote.dto 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | class StoryListResponse( 8 | val totalResults: Int, 9 | @SerialName("articles") val stories: List 10 | ) 11 | -------------------------------------------------------------------------------- /remote-mock/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /remote-mock/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | kotlin("jvm") 6 | kotlin("kapt") 7 | id("com.android.lint") 8 | } 9 | 10 | dependencies { 11 | api(project(":remote-base")) 12 | 13 | // OkHttp 14 | implementation(Libraries.okhttp.client) 15 | implementation(Libraries.okhttp.loggingInterceptor) 16 | 17 | // Retrofit 18 | implementation(Libraries.retrofit.client) 19 | implementation(Libraries.retrofit.mock) 20 | 21 | // Hilt 22 | implementation(Libraries.hilt.core) 23 | kapt(Libraries.hilt.compiler) 24 | } 25 | -------------------------------------------------------------------------------- /remote-mock/src/main/java/io/github/reactivecircus/streamlined/remote/api/MockNewsApiService.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.remote.api 2 | 3 | import io.github.reactivecircus.streamlined.remote.MockData 4 | import io.github.reactivecircus.streamlined.remote.dto.StoryListResponse 5 | import retrofit2.mock.BehaviorDelegate 6 | 7 | class MockNewsApiService( 8 | private val delegate: BehaviorDelegate 9 | ) : NewsApiService { 10 | 11 | override suspend fun headlines(country: String): StoryListResponse { 12 | val headlineStoriesResponse = StoryListResponse( 13 | totalResults = MockData.mockHeadlineStories.size, 14 | stories = MockData.mockHeadlineStories 15 | ) 16 | return delegate.returningResponse(headlineStoriesResponse) 17 | .headlines(country) 18 | } 19 | 20 | override suspend fun everything(query: String): StoryListResponse { 21 | val personalizedStoriesResponse = StoryListResponse( 22 | totalResults = MockData.mockPersonalizedStories.size, 23 | stories = MockData.mockPersonalizedStories 24 | ) 25 | return delegate.returningResponse(personalizedStoriesResponse) 26 | .everything(query) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /remote-mock/src/main/java/io/github/reactivecircus/streamlined/remote/di/MockRemoteModule.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("MagicNumber") 2 | 3 | package io.github.reactivecircus.streamlined.remote.di 4 | 5 | import dagger.Lazy 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.components.SingletonComponent 10 | import io.github.reactivecircus.streamlined.remote.api.MockNewsApiService 11 | import io.github.reactivecircus.streamlined.remote.api.NewsApiService 12 | import okhttp3.OkHttpClient 13 | import okhttp3.logging.HttpLoggingInterceptor 14 | import retrofit2.Retrofit 15 | import retrofit2.mock.MockRetrofit 16 | import retrofit2.mock.NetworkBehavior 17 | import java.util.concurrent.TimeUnit 18 | import javax.inject.Singleton 19 | 20 | @Module 21 | @InstallIn(SingletonComponent::class) 22 | object MockRemoteModule { 23 | 24 | @Provides 25 | @Singleton 26 | fun okHttpClient(): OkHttpClient { 27 | return OkHttpClient.Builder() 28 | .addInterceptor( 29 | HttpLoggingInterceptor().apply { 30 | level = HttpLoggingInterceptor.Level.BASIC 31 | } 32 | ) 33 | .build() 34 | } 35 | 36 | @Provides 37 | @Singleton 38 | fun retrofit(okhttpClient: Lazy): Retrofit { 39 | return Retrofit.Builder() 40 | .baseUrl(DUMMY_URL) 41 | .callFactory { request -> okhttpClient.get().newCall(request) } 42 | .build() 43 | } 44 | 45 | @Provides 46 | @Singleton 47 | fun networkBehavior(): NetworkBehavior { 48 | return NetworkBehavior.create().apply { 49 | // make sure behavior is deterministic 50 | setVariancePercent(0) 51 | // 200 ms delay 52 | setDelay(200, TimeUnit.MILLISECONDS) 53 | // 5% failure 54 | setFailurePercent(5) 55 | } 56 | } 57 | 58 | @Provides 59 | @Singleton 60 | fun newsApiService( 61 | networkBehavior: NetworkBehavior, 62 | retrofit: Retrofit 63 | ): NewsApiService { 64 | return MockNewsApiService( 65 | delegate = MockRetrofit.Builder(retrofit) 66 | .apply { networkBehavior(networkBehavior) } 67 | .build().create(NewsApiService::class.java) 68 | ) 69 | } 70 | } 71 | 72 | private const val MOCK_SERVER_PORT = 5_000 73 | private const val DUMMY_URL = "http://localhost:$MOCK_SERVER_PORT/" 74 | -------------------------------------------------------------------------------- /remote-real/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /remote-real/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | kotlin("jvm") 6 | kotlin("kapt") 7 | id("com.android.lint") 8 | } 9 | 10 | dependencies { 11 | api(project(":remote-base")) 12 | 13 | // Coroutines 14 | implementation(Libraries.kotlinx.coroutines.core) 15 | 16 | // OkHttp 17 | implementation(Libraries.okhttp.client) 18 | implementation(Libraries.okhttp.loggingInterceptor) 19 | 20 | // Retrofit 21 | implementation(Libraries.retrofit.client) 22 | implementation(Libraries.retrofit.serializationConverter) 23 | 24 | // Serialization 25 | implementation(Libraries.kotlinx.serialization) 26 | 27 | // Hilt 28 | implementation(Libraries.hilt.core) 29 | kapt(Libraries.hilt.compiler) 30 | 31 | // Unit tests 32 | testImplementation(Libraries.junit) 33 | testImplementation(Libraries.truth) 34 | testImplementation(Libraries.okhttp.mockWebServer) 35 | } 36 | -------------------------------------------------------------------------------- /remote-real/src/main/java/io/github/reactivecircus/streamlined/remote/ApiConfigs.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.remote 2 | 3 | class ApiConfigs( 4 | val apiKey: String, 5 | val baseUrl: String, 6 | ) 7 | -------------------------------------------------------------------------------- /remote-real/src/main/java/io/github/reactivecircus/streamlined/remote/AuthInterceptor.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.remote 2 | 3 | import javax.inject.Inject 4 | import okhttp3.Interceptor 5 | import okhttp3.Response 6 | 7 | /** 8 | * An [Interceptor] that adds the [apiKey] as an HTTP header for each request. 9 | */ 10 | internal class AuthInterceptor @Inject constructor( 11 | private val apiConfigs: ApiConfigs 12 | ) : Interceptor { 13 | 14 | override fun intercept(chain: Interceptor.Chain): Response { 15 | val newRequest = chain.request().newBuilder() 16 | .addHeader(API_KEY_HEADER_NAME, apiConfigs.apiKey) 17 | .build() 18 | return chain.proceed(newRequest) 19 | } 20 | 21 | companion object { 22 | const val API_KEY_HEADER_NAME = "X-Api-Key" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /remote-real/src/main/java/io/github/reactivecircus/streamlined/remote/di/RealRemoteModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.remote.di 2 | 3 | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory 4 | import dagger.Lazy 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | import io.github.reactivecircus.streamlined.remote.ApiConfigs 10 | import io.github.reactivecircus.streamlined.remote.AuthInterceptor 11 | import io.github.reactivecircus.streamlined.remote.api.NewsApiService 12 | import javax.inject.Singleton 13 | import kotlinx.serialization.ExperimentalSerializationApi 14 | import kotlinx.serialization.json.Json 15 | import okhttp3.MediaType.Companion.toMediaType 16 | import okhttp3.OkHttpClient 17 | import okhttp3.logging.HttpLoggingInterceptor 18 | import retrofit2.Retrofit 19 | import retrofit2.create 20 | 21 | @Module 22 | @InstallIn(SingletonComponent::class) 23 | internal object RealRemoteModule { 24 | 25 | @Provides 26 | @Singleton 27 | fun okHttpClient(authInterceptor: AuthInterceptor): OkHttpClient { 28 | return OkHttpClient.Builder() 29 | .addInterceptor(authInterceptor) 30 | // add logging interceptor 31 | .addInterceptor( 32 | HttpLoggingInterceptor().apply { 33 | level = HttpLoggingInterceptor.Level.BASIC 34 | } 35 | ) 36 | .build() 37 | } 38 | 39 | @OptIn(ExperimentalSerializationApi::class) 40 | @Provides 41 | @Singleton 42 | fun retrofit( 43 | apiConfigs: ApiConfigs, 44 | okhttpClient: Lazy 45 | ): Retrofit { 46 | val contentType = "application/json; charset=utf-8".toMediaType() 47 | val json = Json { 48 | ignoreUnknownKeys = true 49 | explicitNulls = false 50 | } 51 | return Retrofit.Builder() 52 | .baseUrl(apiConfigs.baseUrl) 53 | .callFactory { request -> okhttpClient.get().newCall(request) } 54 | .addConverterFactory( 55 | json.asConverterFactory(contentType) 56 | ) 57 | .build() 58 | } 59 | 60 | @Provides 61 | @Singleton 62 | fun newsApiService(retrofit: Retrofit): NewsApiService { 63 | return retrofit.create() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /remote-real/src/test/java/io/github/reactivecircus/streamlined/remote/AuthInterceptorTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.remote 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import okhttp3.OkHttpClient 5 | import okhttp3.Request 6 | import okhttp3.mockwebserver.MockResponse 7 | import okhttp3.mockwebserver.MockWebServer 8 | import org.junit.After 9 | import org.junit.Before 10 | import org.junit.Test 11 | 12 | class AuthInterceptorTest { 13 | 14 | private val apiKey = "abc" 15 | 16 | private val server = MockWebServer() 17 | 18 | private val client = OkHttpClient.Builder().apply { 19 | addInterceptor( 20 | AuthInterceptor( 21 | ApiConfigs(apiKey = apiKey, baseUrl = "url") 22 | ) 23 | ) 24 | }.build() 25 | 26 | @Before 27 | fun setUp() { 28 | // start mock server 29 | server.start() 30 | } 31 | 32 | @After 33 | fun tearDown() { 34 | server.shutdown() 35 | } 36 | 37 | @Test 38 | fun `API key is injected as HTTP header`() { 39 | server.enqueue(MockResponse()) 40 | val request = Request.Builder() 41 | .url(server.url("/")) 42 | .build() 43 | 44 | client.newCall(request).execute() 45 | 46 | val recordedRequest = server.takeRequest() 47 | 48 | val injectedApiKey = recordedRequest.getHeader(AuthInterceptor.API_KEY_HEADER_NAME) 49 | 50 | assertThat(injectedApiKey).isEqualTo(apiKey) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /scheduled-tasks/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /scheduled-tasks/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | `android-library` 6 | `kotlin-android` 7 | `kotlin-kapt` 8 | } 9 | 10 | android { 11 | namespace = "io.github.reactivecircus.streamlined.work" 12 | } 13 | 14 | dependencies { 15 | implementation(project(":domain-runtime")) 16 | 17 | // Coroutines 18 | implementation(Libraries.kotlinx.coroutines.core) 19 | 20 | // AndroidX 21 | implementation(Libraries.androidx.work.runtimeKtx) 22 | 23 | // Hilt 24 | implementation(Libraries.hilt.android) 25 | kapt(Libraries.hilt.compiler) 26 | 27 | // Hilt AndroidX 28 | implementation(Libraries.androidx.hilt.work) 29 | kapt(Libraries.androidx.hilt.compiler) 30 | 31 | // timber 32 | implementation(Libraries.timber) 33 | } 34 | -------------------------------------------------------------------------------- /scheduled-tasks/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /scheduled-tasks/src/main/java/io/github/reactivecircus/streamlined/work/di/ScheduledTasksModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.work.di 2 | 3 | import android.content.Context 4 | import androidx.work.WorkManager 5 | import dagger.Binds 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.android.qualifiers.ApplicationContext 10 | import dagger.hilt.components.SingletonComponent 11 | import io.github.reactivecircus.streamlined.work.scheduler.DefaultTaskScheduler 12 | import io.github.reactivecircus.streamlined.work.scheduler.TaskScheduler 13 | import javax.inject.Singleton 14 | 15 | @Module 16 | @InstallIn(SingletonComponent::class) 17 | abstract class ScheduledTasksModule { 18 | 19 | @Binds 20 | @Singleton 21 | abstract fun taskScheduler(impl: DefaultTaskScheduler): TaskScheduler 22 | 23 | companion object { 24 | 25 | @Provides 26 | @Singleton 27 | fun workManager(@ApplicationContext context: Context): WorkManager { 28 | return WorkManager.getInstance(context) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /scheduled-tasks/src/main/java/io/github/reactivecircus/streamlined/work/scheduler/DefaultTaskScheduler.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.work.scheduler 2 | 3 | import androidx.work.Constraints 4 | import androidx.work.ExistingPeriodicWorkPolicy 5 | import androidx.work.NetworkType 6 | import androidx.work.PeriodicWorkRequestBuilder 7 | import androidx.work.WorkManager 8 | import io.github.reactivecircus.streamlined.work.worker.StorySyncWorker 9 | import java.util.concurrent.TimeUnit 10 | import javax.inject.Inject 11 | 12 | class DefaultTaskScheduler @Inject constructor( 13 | private val workManager: WorkManager 14 | ) : TaskScheduler { 15 | 16 | override fun scheduleHourlyStorySync() { 17 | val request = PeriodicWorkRequestBuilder( 18 | repeatInterval = SYNC_REPEAT_INTERVAL_MINUTES, 19 | repeatIntervalTimeUnit = TimeUnit.MINUTES, 20 | flexTimeInterval = SYNC_FLEX_TIME_INTERVAL_MINUTES, 21 | flexTimeIntervalUnit = TimeUnit.MINUTES 22 | ).setConstraints( 23 | Constraints.Builder() 24 | .setRequiredNetworkType(NetworkType.UNMETERED) 25 | .setRequiresBatteryNotLow(true) 26 | .build() 27 | ).build() 28 | 29 | workManager.enqueueUniquePeriodicWork( 30 | StorySyncWorker.TAG, 31 | ExistingPeriodicWorkPolicy.REPLACE, 32 | request 33 | ) 34 | } 35 | 36 | companion object { 37 | private const val SYNC_REPEAT_INTERVAL_MINUTES = 60L 38 | private const val SYNC_FLEX_TIME_INTERVAL_MINUTES = 30L 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /scheduled-tasks/src/main/java/io/github/reactivecircus/streamlined/work/scheduler/TaskScheduler.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.work.scheduler 2 | 3 | interface TaskScheduler { 4 | 5 | /** 6 | * Schedules hourly stories syncing in the background. 7 | */ 8 | fun scheduleHourlyStorySync() 9 | } 10 | -------------------------------------------------------------------------------- /scheduled-tasks/src/main/java/io/github/reactivecircus/streamlined/work/worker/StorySyncWorker.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.work.worker 2 | 3 | import android.content.Context 4 | import androidx.hilt.work.HiltWorker 5 | import androidx.work.CoroutineWorker 6 | import androidx.work.WorkerParameters 7 | import dagger.assisted.Assisted 8 | import dagger.assisted.AssistedInject 9 | import io.github.reactivecircus.streamlined.domain.interactor.SyncStories 10 | import reactivecircus.blueprint.interactor.EmptyParams 11 | 12 | @HiltWorker 13 | class StorySyncWorker @AssistedInject constructor( 14 | @Assisted appContext: Context, 15 | @Assisted params: WorkerParameters, 16 | private val syncStories: SyncStories 17 | ) : CoroutineWorker(appContext, params) { 18 | 19 | override suspend fun doWork(): Result { 20 | syncStories.execute(EmptyParams) 21 | return Result.success() 22 | } 23 | 24 | companion object { 25 | const val TAG = "story-sync" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /secrets/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/secrets/debug.keystore -------------------------------------------------------------------------------- /secrets/google-services-dev.aes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/secrets/google-services-dev.aes -------------------------------------------------------------------------------- /secrets/google-services-prod.aes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/secrets/google-services-prod.aes -------------------------------------------------------------------------------- /secrets/play-api.aes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/secrets/play-api.aes -------------------------------------------------------------------------------- /secrets/streamlined.aes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/streamlined/64b3bddcfe66bb376770b506107e307669de8005/secrets/streamlined.aes -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name= "streamlined" 2 | 3 | @Suppress("UnstableApiUsage") 4 | dependencyResolutionManagement { 5 | repositories { 6 | mavenCentral() 7 | google() 8 | } 9 | } 10 | 11 | include(":app") 12 | include(":navigator") 13 | include(":design-themes") 14 | include(":ui-common") 15 | include(":ui-home") 16 | include(":ui-headlines") 17 | include(":ui-reading-list") 18 | include(":ui-settings") 19 | include(":ui-story-details") 20 | include(":ui-testing-framework") 21 | include(":domain-api") 22 | include(":domain-runtime") 23 | include(":domain-testing") 24 | include(":remote-base") 25 | include(":remote-mock") 26 | include(":remote-real") 27 | include(":persistence") 28 | include(":data") 29 | include(":scheduled-tasks") 30 | includeProject(":store-ext", "libraries/store-ext") 31 | includeProject(":bugsnag-tree", "libraries/bugsnag-tree") 32 | includeProject(":analytics-api-base", "libraries/analytics/analytics-api-base") 33 | includeProject(":analytics-api-firebase", "libraries/analytics/analytics-api-firebase") 34 | includeProject(":analytics-api-no-op", "libraries/analytics/analytics-api-no-op") 35 | includeProject(":coroutines-test-ext", "libraries/coroutines-test-ext") 36 | 37 | fun includeProject(name: String, filePath: String) { 38 | include(name) 39 | project(name).projectDir = File(filePath) 40 | } 41 | -------------------------------------------------------------------------------- /ui-common/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /ui-common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | `core-library-desugaring` 6 | id("com.android.library") 7 | kotlin("android") 8 | kotlin("kapt") 9 | } 10 | 11 | android { 12 | namespace = "io.github.reactivecircus.streamlined.ui" 13 | buildFeatures.androidResources = true 14 | } 15 | 16 | dependencies { 17 | api(project(":analytics-api-base")) 18 | api(project(":design-themes")) 19 | 20 | // Blueprint 21 | api(Libraries.blueprint.ui) 22 | 23 | // Workflow 24 | api(Libraries.workflow.ui) 25 | 26 | // AndroidX 27 | api(Libraries.androidx.annotation) 28 | api(Libraries.androidx.appCompat) 29 | api(Libraries.androidx.constraintLayout) 30 | api(Libraries.androidx.coordinatorLayout) 31 | api(Libraries.androidx.activity.ktx) 32 | api(Libraries.androidx.fragment.ktx) 33 | api(Libraries.androidx.core.ktx) 34 | api(Libraries.androidx.navigation.fragmentKtx) 35 | api(Libraries.androidx.navigation.uiKtx) 36 | implementation(Libraries.androidx.lifecycle.runtimeKtx) 37 | 38 | // Material Components 39 | api(Libraries.material) 40 | 41 | // Window inset handling 42 | api(Libraries.insetter) 43 | 44 | // Image loading 45 | api(Libraries.coil) 46 | 47 | // Hilt 48 | implementation(Libraries.hilt.android) 49 | kapt(Libraries.hilt.compiler) 50 | 51 | // Unit tests 52 | testImplementation(Libraries.junit) 53 | testImplementation(Libraries.truth) 54 | } 55 | -------------------------------------------------------------------------------- /ui-common/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui-common/src/main/java/io/github/reactivecircus/streamlined/ui/ScreenForAnalytics.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.ui 2 | 3 | /** 4 | * An empty interface representing a "screen" in the app. This is intended for analytics tracking 5 | * so that a screen name can be automatically generated from the name of a class implementing this interface. 6 | */ 7 | interface ScreenForAnalytics 8 | -------------------------------------------------------------------------------- /ui-common/src/main/java/io/github/reactivecircus/streamlined/ui/configs/AnimationConfigs.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.ui.configs 2 | 3 | import javax.inject.Inject 4 | 5 | interface AnimationConfigs { 6 | val defaultStartOffset: Int 7 | val adapterPayloadAnimationDuration: Int 8 | } 9 | 10 | class DefaultAnimationConfigs @Inject constructor() : AnimationConfigs { 11 | override val defaultStartOffset = 200 12 | override val adapterPayloadAnimationDuration = 300 13 | } 14 | -------------------------------------------------------------------------------- /ui-common/src/main/java/io/github/reactivecircus/streamlined/ui/util/FragmentExt.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.ui.util 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.lifecycle.flowWithLifecycle 5 | import androidx.lifecycle.Lifecycle 6 | import androidx.lifecycle.lifecycleScope 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.launchIn 9 | import kotlinx.coroutines.flow.onEach 10 | 11 | /** 12 | * Collects the [flow] when the [Lifecycle] of the [Fragment] is at least in [Lifecycle.State.STARTED] state, 13 | * and invokes the given [block] with the collected value [T]. 14 | */ 15 | fun Fragment.collectWhenStarted(flow: Flow, block: (T) -> Unit) { 16 | flow.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) 17 | .onEach { block(it) } 18 | .launchIn(viewLifecycleOwner.lifecycleScope) 19 | } 20 | -------------------------------------------------------------------------------- /ui-common/src/main/java/io/github/reactivecircus/streamlined/ui/util/ItemActionListener.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.ui.util 2 | 3 | fun interface ItemActionListener { 4 | operator fun invoke(action: A) 5 | } 6 | -------------------------------------------------------------------------------- /ui-common/src/main/java/io/github/reactivecircus/streamlined/ui/util/PrettyTime.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("MagicNumber") 2 | 3 | package io.github.reactivecircus.streamlined.ui.util 4 | 5 | import java.time.Instant 6 | import java.time.ZoneId 7 | import java.time.format.DateTimeFormatter 8 | import java.util.Locale 9 | import kotlin.time.Duration.Companion.days 10 | import kotlin.time.Duration.Companion.hours 11 | import kotlin.time.Duration.Companion.minutes 12 | import kotlin.time.DurationUnit 13 | import kotlin.time.toDuration 14 | 15 | /** 16 | * Converts the timestamp to a formatted String 17 | */ 18 | fun Long.toFormattedDateString( 19 | pattern: String, 20 | zoneId: ZoneId = ZoneId.systemDefault(), 21 | locale: Locale = Locale.getDefault() 22 | ): String { 23 | val formatter = DateTimeFormatter.ofPattern(pattern).withLocale(locale) 24 | return Instant.ofEpochMilli(this).atZone(zoneId).format(formatter) 25 | // make sure "." is removed when using three-letter abbreviation for month 26 | .replace(".", "") 27 | } 28 | 29 | /** 30 | * Returns prettified duration between a previous timestamp and now. 31 | */ 32 | fun Long.timeAgo( 33 | fallbackDatePattern: String, 34 | zoneId: ZoneId = ZoneId.systemDefault(), 35 | locale: Locale = Locale.getDefault(), 36 | clock: Clock = RealClock() 37 | ): String { 38 | val timeAgo = (clock.currentTimeMillis - this) 39 | .toDuration(DurationUnit.MILLISECONDS) 40 | return when { 41 | timeAgo < 1.minutes -> "Moments ago" 42 | timeAgo < 1.hours -> { 43 | if (timeAgo < 2.minutes) { 44 | "1 minute ago" 45 | } else { 46 | "${timeAgo.inWholeMinutes} minutes ago" 47 | } 48 | } 49 | timeAgo < 1.days -> { 50 | if (timeAgo < 2.hours) { 51 | "1 hour ago" 52 | } else { 53 | "${timeAgo.inWholeHours.toInt()} hours ago" 54 | } 55 | } 56 | timeAgo < 7.days -> { 57 | if (timeAgo < 2.days) { 58 | "Yesterday" 59 | } else { 60 | "${timeAgo.inWholeDays.toInt()} days ago" 61 | } 62 | } 63 | else -> this.toFormattedDateString(fallbackDatePattern, zoneId, locale) 64 | } 65 | } 66 | 67 | interface Clock { 68 | val currentTimeMillis: Long 69 | } 70 | 71 | internal class RealClock : Clock { 72 | override val currentTimeMillis: Long 73 | get() = System.currentTimeMillis() 74 | } 75 | -------------------------------------------------------------------------------- /ui-common/src/main/java/io/github/reactivecircus/streamlined/ui/util/RecyclerViewIExt.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.ui.util 2 | 3 | import androidx.recyclerview.widget.RecyclerView 4 | import reactivecircus.blueprint.ui.extension.isAnimationOn 5 | 6 | /** 7 | * Sets the RecyclerView's `itemAnimator` to null if animation is turned off on the device. 8 | */ 9 | fun RecyclerView.disableItemAnimatorIfTurnedOffGlobally() { 10 | if (!context.isAnimationOn) { 11 | itemAnimator = null 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ui-common/src/main/java/io/github/reactivecircus/streamlined/ui/viewmodel/LazyViewModelProvider.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.ui.viewmodel 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.fragment.app.viewModels 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.ViewModelProvider 7 | 8 | /** 9 | * Returns a property delegate to access [ViewModel] which is created by invoking the [provider]. 10 | */ 11 | inline fun Fragment.fragmentViewModel( 12 | crossinline provider: () -> T 13 | ) = viewModels { 14 | object : ViewModelProvider.Factory { 15 | override fun create(modelClass: Class): T { 16 | @Suppress("UNCHECKED_CAST") 17 | return provider() as T 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ui-common/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Looks like you\'re offline.\nPlease connect to the Internet and try again. 4 | Looks like you\'re offline. Content may be out of date. 5 | 6 | Retry 7 | 8 | 9 | -------------------------------------------------------------------------------- /ui-headlines/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /ui-headlines/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | id("com.android.library") 6 | id("com.google.dagger.hilt.android") 7 | kotlin("android") 8 | kotlin("kapt") 9 | } 10 | 11 | android { 12 | namespace = "io.github.reactivecircus.streamlined.headlines" 13 | 14 | buildFeatures { 15 | viewBinding = true 16 | androidResources = true 17 | } 18 | 19 | defaultConfig { 20 | testApplicationId = "io.github.reactivecircus.streamlined.headlines.test" 21 | testInstrumentationRunner = "io.github.reactivecircus.streamlined.testing.ScreenTestRunner" 22 | } 23 | } 24 | 25 | dependencies { 26 | implementation(project(":navigator")) 27 | implementation(project(":ui-common")) 28 | implementation(project(":domain-runtime")) 29 | 30 | // Coroutines 31 | implementation(Libraries.kotlinx.coroutines.core) 32 | 33 | // AndroidX 34 | implementation(Libraries.androidx.swipeRefreshLayout) 35 | implementation(Libraries.androidx.recyclerView) 36 | implementation(Libraries.androidx.lifecycle.viewModelKtx) 37 | implementation(Libraries.androidx.lifecycle.commonJava8) 38 | 39 | // Hilt 40 | implementation(Libraries.hilt.android) 41 | kapt(Libraries.hilt.compiler) 42 | 43 | // timber 44 | implementation(Libraries.timber) 45 | 46 | // Unit tests 47 | testImplementation(Libraries.junit) 48 | testImplementation(Libraries.truth) 49 | testImplementation(project(":coroutines-test-ext")) 50 | 51 | // Android tests 52 | androidTestImplementation(project(":ui-testing-framework")) 53 | debugImplementation(Libraries.androidx.fragment.testing) { 54 | exclude(group = "androidx.test") 55 | } 56 | kaptAndroidTest(Libraries.hilt.compiler) 57 | } 58 | -------------------------------------------------------------------------------- /ui-headlines/src/androidTest/java/io/github/reactivecircus/streamlined/headlines/HeadlinesScreenTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.headlines 2 | 3 | import androidx.test.filters.LargeTest 4 | import dagger.hilt.android.testing.HiltAndroidTest 5 | import io.github.reactivecircus.streamlined.testing.BaseScreenTest 6 | import org.junit.Test 7 | 8 | @LargeTest 9 | @HiltAndroidTest 10 | class HeadlinesScreenTest : BaseScreenTest() { 11 | 12 | @Test 13 | fun launchHeadlinesScreen_headlinesDisplayed() { 14 | launchFragmentInTest() 15 | // TODO 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ui-headlines/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui-headlines/src/main/java/io/github/reactivecircus/streamlined/headlines/HeadlinesFragment.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.headlines 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.fragment.app.Fragment 6 | import dagger.hilt.android.AndroidEntryPoint 7 | import io.github.reactivecircus.streamlined.headlines.databinding.FragmentHeadlinesBinding 8 | import io.github.reactivecircus.streamlined.ui.ScreenForAnalytics 9 | 10 | @AndroidEntryPoint 11 | class HeadlinesFragment : Fragment(R.layout.fragment_headlines), ScreenForAnalytics { 12 | 13 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 14 | val binding = FragmentHeadlinesBinding.bind(view) 15 | 16 | binding.toolbar.title = getString(R.string.title_headlines) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ui-headlines/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Headlines 4 | 5 | 6 | -------------------------------------------------------------------------------- /ui-home/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /ui-home/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | `core-library-desugaring` 6 | id("com.android.library") 7 | id("com.google.dagger.hilt.android") 8 | kotlin("android") 9 | kotlin("kapt") 10 | } 11 | 12 | android { 13 | namespace = "io.github.reactivecircus.streamlined.home" 14 | 15 | buildFeatures { 16 | viewBinding = true 17 | androidResources = true 18 | } 19 | 20 | defaultConfig { 21 | testApplicationId = "io.github.reactivecircus.streamlined.home.test" 22 | testInstrumentationRunner = "io.github.reactivecircus.streamlined.testing.ScreenTestRunner" 23 | } 24 | } 25 | 26 | dependencies { 27 | implementation(project(":navigator")) 28 | implementation(project(":ui-common")) 29 | implementation(project(":domain-runtime")) 30 | 31 | // Coroutines 32 | implementation(Libraries.kotlinx.coroutines.core) 33 | 34 | // AndroidX 35 | implementation(Libraries.androidx.swipeRefreshLayout) 36 | implementation(Libraries.androidx.recyclerView) 37 | implementation(Libraries.androidx.lifecycle.viewModelKtx) 38 | implementation(Libraries.androidx.lifecycle.commonJava8) 39 | 40 | // Hilt 41 | implementation(Libraries.hilt.android) 42 | kapt(Libraries.hilt.compiler) 43 | 44 | // timber 45 | implementation(Libraries.timber) 46 | 47 | // Unit tests 48 | testImplementation(Libraries.junit) 49 | testImplementation(Libraries.truth) 50 | testImplementation(Libraries.workflow.testing) 51 | testImplementation(project(":domain-testing")) 52 | testImplementation(project(":coroutines-test-ext")) 53 | 54 | // Android tests 55 | androidTestImplementation(project(":ui-testing-framework")) 56 | debugImplementation(Libraries.androidx.fragment.testing) { 57 | exclude(group = "androidx.test") 58 | } 59 | kaptAndroidTest(Libraries.hilt.compiler) 60 | } 61 | -------------------------------------------------------------------------------- /ui-home/src/androidTest/java/io/github/reactivecircus/streamlined/home/TestHomeUiConfigs.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | 3 | package io.github.reactivecircus.streamlined.home 4 | 5 | import android.os.AsyncTask 6 | import kotlinx.coroutines.asCoroutineDispatcher 7 | import javax.inject.Inject 8 | import kotlin.time.Duration 9 | 10 | class TestHomeUiConfigs @Inject constructor() : HomeUiConfigs { 11 | 12 | override val numberOfHeadlinesDisplayed = DefaultHomeUiConfigs.NUMBER_OF_HEADLINES_DISPLAYED 13 | 14 | override val transientErrorDisplayDuration = Duration.seconds(5) 15 | 16 | override val delayDispatcher = AsyncTask.THREAD_POOL_EXECUTOR.asCoroutineDispatcher() 17 | } 18 | -------------------------------------------------------------------------------- /ui-home/src/androidTest/java/io/github/reactivecircus/streamlined/home/di/TestHomeModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.home.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.Reusable 6 | import dagger.hilt.android.components.ViewModelComponent 7 | import dagger.hilt.testing.TestInstallIn 8 | import io.github.reactivecircus.streamlined.home.HomeUiConfigs 9 | import io.github.reactivecircus.streamlined.home.TestHomeUiConfigs 10 | 11 | @Module 12 | @TestInstallIn( 13 | components = [ViewModelComponent::class], 14 | replaces = [HomeModule::class], 15 | ) 16 | abstract class TestHomeModule { 17 | 18 | @Binds 19 | @Reusable 20 | abstract fun homeUiConfigs(impl: TestHomeUiConfigs): HomeUiConfigs 21 | } 22 | -------------------------------------------------------------------------------- /ui-home/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui-home/src/main/java/io/github/reactivecircus/streamlined/home/FeedItemsGenerator.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.home 2 | 3 | import io.github.reactivecircus.streamlined.domain.model.Story 4 | 5 | /** 6 | * Merges headline stories and personalized stories into a single list of [FeedItem]s, 7 | * adding the appropriate headers and footers. 8 | */ 9 | internal fun generateFeedItems( 10 | maxNumberOfHeadlines: Int, 11 | headlineStories: List, 12 | personalizedStories: List 13 | ): List { 14 | return buildList { 15 | // headline stories 16 | add(FeedItem.Header(FeedType.TopHeadlines)) 17 | if (headlineStories.isNotEmpty()) { 18 | addAll( 19 | headlineStories.take(maxNumberOfHeadlines).map { story -> 20 | FeedItem.Content(FeedType.TopHeadlines, story) 21 | } 22 | ) 23 | add(FeedItem.TopHeadlinesFooter) 24 | } else { 25 | add(FeedItem.Empty(FeedType.TopHeadlines)) 26 | } 27 | // personalized stories 28 | add(FeedItem.Header(FeedType.ForYou)) 29 | if (personalizedStories.isNotEmpty()) { 30 | addAll( 31 | personalizedStories.map { story -> 32 | FeedItem.Content(FeedType.ForYou, story) 33 | } 34 | ) 35 | } else { 36 | add(FeedItem.Empty(FeedType.ForYou)) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ui-home/src/main/java/io/github/reactivecircus/streamlined/home/HomeUiConfigs.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.home 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import reactivecircus.blueprint.async.coroutines.CoroutineDispatcherProvider 5 | import javax.inject.Inject 6 | import kotlin.time.Duration 7 | import kotlin.time.Duration.Companion.seconds 8 | 9 | interface HomeUiConfigs { 10 | val numberOfHeadlinesDisplayed: Int 11 | val transientErrorDisplayDuration: Duration 12 | val delayDispatcher: CoroutineDispatcher 13 | } 14 | 15 | class DefaultHomeUiConfigs @Inject constructor( 16 | coroutineDispatcherProvider: CoroutineDispatcherProvider 17 | ) : HomeUiConfigs { 18 | override val numberOfHeadlinesDisplayed: Int = NUMBER_OF_HEADLINES_DISPLAYED 19 | override val transientErrorDisplayDuration: Duration = TRANSIENT_ERROR_DISPLAY_DURATION 20 | override val delayDispatcher: CoroutineDispatcher = coroutineDispatcherProvider.computation 21 | 22 | companion object { 23 | internal const val NUMBER_OF_HEADLINES_DISPLAYED = 3 24 | internal val TRANSIENT_ERROR_DISPLAY_DURATION = 2.seconds 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ui-home/src/main/java/io/github/reactivecircus/streamlined/home/HomeUiModels.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.home 2 | 3 | import io.github.reactivecircus.streamlined.domain.model.Story 4 | 5 | data class HomeRendering( 6 | val state: HomeState, 7 | val onRefresh: () -> Unit 8 | ) 9 | 10 | sealed class HomeState { 11 | sealed class InFlight : HomeState() { 12 | object Initial : InFlight() 13 | data class FetchWithCache(val items: List) : InFlight() 14 | data class Refresh(val items: List?) : InFlight() 15 | } 16 | 17 | data class ShowingContent(val items: List) : HomeState() 18 | 19 | sealed class Error : HomeState() { 20 | data class Transient(val items: List) : Error() 21 | object Permanent : Error() 22 | } 23 | 24 | internal val itemsOrNull: List? 25 | get() = when (this) { 26 | is InFlight.FetchWithCache -> items 27 | is InFlight.Refresh -> items 28 | is ShowingContent -> items 29 | is Error.Transient -> items 30 | else -> null 31 | } 32 | } 33 | 34 | sealed class FeedItem { 35 | data class Header(val feedType: FeedType) : FeedItem() 36 | data class Content(val feedType: FeedType, val story: Story) : FeedItem() 37 | object TopHeadlinesFooter : FeedItem() 38 | data class Empty(val feedType: FeedType) : FeedItem() 39 | } 40 | 41 | sealed class FeedType { 42 | object TopHeadlines : FeedType() 43 | object ForYou : FeedType() 44 | } 45 | -------------------------------------------------------------------------------- /ui-home/src/main/java/io/github/reactivecircus/streamlined/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.home 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.squareup.workflow1.ui.WorkflowUiExperimentalApi 6 | import com.squareup.workflow1.ui.renderWorkflowIn 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import javax.inject.Inject 9 | import kotlinx.coroutines.flow.Flow 10 | 11 | @HiltViewModel 12 | class HomeViewModel @Inject constructor( 13 | homeWorkflow: HomeWorkflow 14 | ) : ViewModel() { 15 | @OptIn(WorkflowUiExperimentalApi::class) 16 | val rendering: Flow = renderWorkflowIn( 17 | workflow = homeWorkflow, 18 | scope = viewModelScope, 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /ui-home/src/main/java/io/github/reactivecircus/streamlined/home/di/HomeModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.home.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.android.components.ViewModelComponent 7 | import dagger.hilt.android.scopes.ViewModelScoped 8 | import io.github.reactivecircus.streamlined.home.DefaultHomeUiConfigs 9 | import io.github.reactivecircus.streamlined.home.HomeUiConfigs 10 | 11 | @Module 12 | @InstallIn(ViewModelComponent::class) 13 | internal abstract class HomeModule { 14 | 15 | @Binds 16 | @ViewModelScoped 17 | abstract fun homeUiConfigs(impl: DefaultHomeUiConfigs): HomeUiConfigs 18 | } 19 | -------------------------------------------------------------------------------- /ui-home/src/main/res/layout/item_empty_placeholder.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 16 | 17 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /ui-home/src/main/res/layout/item_read_more_headlines.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 11 | 12 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /ui-home/src/main/res/layout/item_section_header.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /ui-home/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Streamlined. 4 | 5 | Read more headlines 6 | 7 | Top headlines 8 | For you 9 | 10 | No headlines found. 11 | No stories found. 12 | 13 | 14 | -------------------------------------------------------------------------------- /ui-reading-list/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /ui-reading-list/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | id("com.android.library") 6 | id("com.google.dagger.hilt.android") 7 | kotlin("android") 8 | kotlin("kapt") 9 | } 10 | 11 | android { 12 | namespace = "io.github.reactivecircus.streamlined.readinglist" 13 | 14 | buildFeatures { 15 | viewBinding = true 16 | androidResources = true 17 | } 18 | 19 | defaultConfig { 20 | testApplicationId = "io.github.reactivecircus.streamlined.readinglist.test" 21 | testInstrumentationRunner = "io.github.reactivecircus.streamlined.testing.ScreenTestRunner" 22 | } 23 | } 24 | 25 | dependencies { 26 | implementation(project(":navigator")) 27 | implementation(project(":ui-common")) 28 | implementation(project(":domain-runtime")) 29 | 30 | // Coroutines 31 | implementation(Libraries.kotlinx.coroutines.core) 32 | 33 | // AndroidX 34 | implementation(Libraries.androidx.recyclerView) 35 | implementation(Libraries.androidx.lifecycle.viewModelKtx) 36 | implementation(Libraries.androidx.lifecycle.commonJava8) 37 | 38 | // Hilt 39 | implementation(Libraries.hilt.android) 40 | kapt(Libraries.hilt.compiler) 41 | 42 | // timber 43 | implementation(Libraries.timber) 44 | 45 | // Unit tests 46 | testImplementation(Libraries.junit) 47 | testImplementation(Libraries.truth) 48 | testImplementation(project(":coroutines-test-ext")) 49 | 50 | // Android tests 51 | androidTestImplementation(project(":ui-testing-framework")) 52 | debugImplementation(Libraries.androidx.fragment.testing) { 53 | exclude(group = "androidx.test") 54 | } 55 | kaptAndroidTest(Libraries.hilt.compiler) 56 | } 57 | -------------------------------------------------------------------------------- /ui-reading-list/src/androidTest/java/io/github/reactivecircus/streamlined/readinglist/ReadingListScreenTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.readinglist 2 | 3 | import androidx.test.filters.LargeTest 4 | import dagger.hilt.android.testing.HiltAndroidTest 5 | import io.github.reactivecircus.streamlined.testing.BaseScreenTest 6 | import org.junit.Test 7 | 8 | @LargeTest 9 | @HiltAndroidTest 10 | class ReadingListScreenTest : BaseScreenTest() { 11 | 12 | @Test 13 | fun launchHeadlinesScreen_readingListDisplayed() { 14 | launchFragmentInTest() 15 | // TODO 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ui-reading-list/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui-reading-list/src/main/java/io/github/reactivecircus/streamlined/readinglist/ReadingListFragment.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.readinglist 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.fragment.app.Fragment 6 | import dagger.hilt.android.AndroidEntryPoint 7 | import io.github.reactivecircus.streamlined.readinglist.databinding.FragmentReadingListBinding 8 | import io.github.reactivecircus.streamlined.ui.ScreenForAnalytics 9 | 10 | @AndroidEntryPoint 11 | class ReadingListFragment : Fragment(R.layout.fragment_reading_list), ScreenForAnalytics { 12 | 13 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 14 | val binding = FragmentReadingListBinding.bind(view) 15 | 16 | binding.toolbar.title = getString(R.string.title_reading_list) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ui-reading-list/src/main/res/layout/fragment_reading_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 13 | 18 | 19 | 20 | 21 | 25 | 26 | 31 | 32 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /ui-reading-list/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Reading list 4 | 5 | No saved stories. 6 | 7 | 8 | -------------------------------------------------------------------------------- /ui-settings/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /ui-settings/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | id("com.android.library") 6 | id("com.google.dagger.hilt.android") 7 | kotlin("android") 8 | kotlin("kapt") 9 | } 10 | 11 | android { 12 | namespace = "io.github.reactivecircus.streamlined.settings" 13 | 14 | buildFeatures { 15 | viewBinding = true 16 | androidResources = true 17 | } 18 | 19 | defaultConfig { 20 | testApplicationId = "io.github.reactivecircus.streamlined.settings.test" 21 | testInstrumentationRunner = "io.github.reactivecircus.streamlined.testing.ScreenTestRunner" 22 | } 23 | } 24 | 25 | dependencies { 26 | implementation(project(":navigator")) 27 | implementation(project(":ui-common")) 28 | implementation(project(":domain-runtime")) 29 | 30 | // Coroutines 31 | implementation(Libraries.kotlinx.coroutines.core) 32 | 33 | // AndroidX 34 | implementation(Libraries.androidx.lifecycle.viewModelKtx) 35 | implementation(Libraries.androidx.lifecycle.commonJava8) 36 | 37 | // Hilt 38 | implementation(Libraries.hilt.android) 39 | kapt(Libraries.hilt.compiler) 40 | 41 | // timber 42 | implementation(Libraries.timber) 43 | 44 | // Unit tests 45 | testImplementation(Libraries.junit) 46 | testImplementation(Libraries.truth) 47 | testImplementation(project(":coroutines-test-ext")) 48 | 49 | // Android tests 50 | androidTestImplementation(project(":ui-testing-framework")) 51 | debugImplementation(Libraries.androidx.fragment.testing) { 52 | exclude(group = "androidx.test") 53 | } 54 | kaptAndroidTest(Libraries.hilt.compiler) 55 | } 56 | -------------------------------------------------------------------------------- /ui-settings/src/androidTest/java/io/github/reactivecircus/streamlined/settings/SettingsScreenTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.settings 2 | 3 | import androidx.test.filters.LargeTest 4 | import dagger.hilt.android.testing.HiltAndroidTest 5 | import io.github.reactivecircus.streamlined.testing.BaseScreenTest 6 | import org.junit.Test 7 | 8 | @LargeTest 9 | @HiltAndroidTest 10 | class SettingsScreenTest : BaseScreenTest() { 11 | 12 | @Test 13 | fun launchSettingsScreen_settingsDisplayed() { 14 | launchFragmentInTest() 15 | // TODO 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ui-settings/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui-settings/src/main/java/io/github/reactivecircus/streamlined/settings/SettingsFragment.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.settings 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.fragment.app.Fragment 6 | import dagger.hilt.android.AndroidEntryPoint 7 | import io.github.reactivecircus.streamlined.settings.databinding.FragmentSettingsBinding 8 | import io.github.reactivecircus.streamlined.ui.ScreenForAnalytics 9 | 10 | @AndroidEntryPoint 11 | class SettingsFragment : Fragment(R.layout.fragment_settings), ScreenForAnalytics { 12 | 13 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 14 | val binding = FragmentSettingsBinding.bind(view) 15 | 16 | binding.toolbar.title = getString(R.string.title_settings) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ui-settings/src/main/res/layout/fragment_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 13 | 18 | 19 | 20 | 21 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /ui-settings/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Settings 4 | 5 | 6 | -------------------------------------------------------------------------------- /ui-story-details/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /ui-story-details/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | id("com.android.library") 6 | id("com.google.dagger.hilt.android") 7 | kotlin("android") 8 | kotlin("kapt") 9 | } 10 | 11 | android { 12 | namespace = "io.github.reactivecircus.streamlined.storydetails" 13 | 14 | buildFeatures { 15 | viewBinding = true 16 | androidResources = true 17 | } 18 | 19 | defaultConfig { 20 | testApplicationId = "io.github.reactivecircus.streamlined.storydetails.test" 21 | testInstrumentationRunner = "io.github.reactivecircus.streamlined.testing.ScreenTestRunner" 22 | } 23 | } 24 | 25 | dependencies { 26 | implementation(project(":navigator")) 27 | implementation(project(":ui-common")) 28 | implementation(project(":domain-runtime")) 29 | 30 | // Coroutines 31 | implementation(Libraries.kotlinx.coroutines.core) 32 | 33 | // FlowBinding 34 | implementation(Libraries.flowbinding.swipeRefreshLayout) 35 | 36 | // AndroidX 37 | implementation(Libraries.androidx.swipeRefreshLayout) 38 | implementation(Libraries.androidx.lifecycle.viewModelKtx) 39 | implementation(Libraries.androidx.lifecycle.commonJava8) 40 | 41 | // Hilt 42 | implementation(Libraries.hilt.android) 43 | kapt(Libraries.hilt.compiler) 44 | 45 | // timber 46 | implementation(Libraries.timber) 47 | 48 | // Unit tests 49 | testImplementation(Libraries.junit) 50 | testImplementation(Libraries.truth) 51 | testImplementation(project(":coroutines-test-ext")) 52 | 53 | // Android tests 54 | androidTestImplementation(project(":ui-testing-framework")) 55 | debugImplementation(Libraries.androidx.fragment.testing) { 56 | exclude(group = "androidx.test") 57 | } 58 | kaptAndroidTest(Libraries.hilt.compiler) 59 | } 60 | -------------------------------------------------------------------------------- /ui-story-details/src/androidTest/java/io/github/reactivecircus/streamlined/storydetails/StoryDetailsScreenTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.storydetails 2 | 3 | import androidx.test.filters.LargeTest 4 | import dagger.hilt.android.testing.HiltAndroidTest 5 | import io.github.reactivecircus.streamlined.navigator.createNavArgsBundle 6 | import io.github.reactivecircus.streamlined.navigator.input.StoryDetailsInput 7 | import io.github.reactivecircus.streamlined.testing.BaseScreenTest 8 | import org.junit.Test 9 | 10 | @LargeTest 11 | @HiltAndroidTest 12 | class StoryDetailsScreenTest : BaseScreenTest() { 13 | 14 | private val args = createNavArgsBundle(StoryDetailsInput(storyId = 3)) 15 | 16 | @Test 17 | fun launchStoryDetailsScreen_storyDisplayed() { 18 | launchFragmentInTest(args) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ui-story-details/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui-story-details/src/main/java/io/github/reactivecircus/streamlined/storydetails/StoryDetailsFragment.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.storydetails 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.fragment.app.Fragment 6 | import com.google.android.material.transition.MaterialElevationScale 7 | import dagger.hilt.android.AndroidEntryPoint 8 | import io.github.reactivecircus.streamlined.navigator.input.StoryDetailsInput 9 | import io.github.reactivecircus.streamlined.navigator.requireNavInput 10 | import io.github.reactivecircus.streamlined.storydetails.databinding.FragmentStoryDetailsBinding 11 | import io.github.reactivecircus.streamlined.ui.ScreenForAnalytics 12 | import io.github.reactivecircus.streamlined.ui.util.collectWhenStarted 13 | import io.github.reactivecircus.streamlined.ui.viewmodel.fragmentViewModel 14 | import javax.inject.Inject 15 | 16 | @AndroidEntryPoint 17 | class StoryDetailsFragment : Fragment(R.layout.fragment_story_details), ScreenForAnalytics { 18 | 19 | @Inject 20 | lateinit var viewModelFactory: StoryDetailsViewModel.Factory 21 | 22 | private val viewModel: StoryDetailsViewModel by fragmentViewModel { 23 | val storyId = requireNavInput().storyId 24 | viewModelFactory.create(storyId) 25 | } 26 | 27 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 28 | enterTransition = MaterialElevationScale(true) 29 | returnTransition = MaterialElevationScale(false) 30 | 31 | val binding = FragmentStoryDetailsBinding.bind(view) 32 | 33 | binding.toolbar.title = "Story title" 34 | 35 | // TODO transparent navigationBarColor for API 29+; #B3FFFFFF (light) and #B3000000 (night) for API < 29 36 | 37 | collectWhenStarted(viewModel.rendering) { 38 | // TODO 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ui-story-details/src/main/java/io/github/reactivecircus/streamlined/storydetails/StoryDetailsUiModels.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.storydetails 2 | 3 | data class StoryDetailsRendering( 4 | val state: StoryDetailsState, 5 | val onAddToReadingList: () -> Unit, 6 | val onRemoveFromReadingList: () -> Unit 7 | ) 8 | 9 | sealed class StoryDetailsState { 10 | abstract val storyId: Long 11 | 12 | data class InFlight(override val storyId: Long) : StoryDetailsState() 13 | } 14 | -------------------------------------------------------------------------------- /ui-story-details/src/main/java/io/github/reactivecircus/streamlined/storydetails/StoryDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.storydetails 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.squareup.workflow1.ui.WorkflowUiExperimentalApi 6 | import com.squareup.workflow1.ui.renderWorkflowIn 7 | import dagger.assisted.Assisted 8 | import dagger.assisted.AssistedFactory 9 | import dagger.assisted.AssistedInject 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | 13 | class StoryDetailsViewModel @AssistedInject constructor( 14 | @Assisted private val storyId: Long, 15 | storyDetailsWorkflow: StoryDetailsWorkflow, 16 | ) : ViewModel() { 17 | @OptIn(WorkflowUiExperimentalApi::class) 18 | val rendering: Flow = renderWorkflowIn( 19 | workflow = storyDetailsWorkflow, 20 | scope = viewModelScope, 21 | props = MutableStateFlow(storyId), 22 | ) 23 | 24 | @AssistedFactory 25 | interface Factory { 26 | fun create(storyId: Long): StoryDetailsViewModel 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ui-story-details/src/main/java/io/github/reactivecircus/streamlined/storydetails/StoryDetailsWorkflow.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.storydetails 2 | 3 | import com.squareup.workflow1.Snapshot 4 | import com.squareup.workflow1.StatefulWorkflow 5 | import com.squareup.workflow1.WorkflowAction 6 | import com.squareup.workflow1.parse 7 | import io.github.reactivecircus.streamlined.domain.interactor.GetStoryById 8 | import javax.inject.Inject 9 | 10 | class StoryDetailsWorkflow @Inject constructor( 11 | private val getStoryById: GetStoryById 12 | ) : StatefulWorkflow() { 13 | 14 | override fun initialState(props: Long, snapshot: Snapshot?): StoryDetailsState { 15 | return StoryDetailsState.InFlight( 16 | storyId = snapshot?.bytes?.parse { source -> source.readLong() } ?: props 17 | ) 18 | } 19 | 20 | override fun render( 21 | renderProps: Long, 22 | renderState: StoryDetailsState, 23 | context: RenderContext, 24 | ): StoryDetailsRendering { 25 | getStoryById 26 | 27 | return StoryDetailsRendering( 28 | renderState, 29 | onAddToReadingList = {}, 30 | onRemoveFromReadingList = {} 31 | ) 32 | } 33 | 34 | override fun snapshotState(state: StoryDetailsState): Snapshot = Snapshot.write { sink -> 35 | sink.writeLong(state.storyId) 36 | } 37 | } 38 | 39 | private typealias StoryDetailsAction = WorkflowAction 40 | -------------------------------------------------------------------------------- /ui-story-details/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /ui-testing-framework/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /ui-testing-framework/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.github.reactivecircus.streamlined.Libraries 2 | 3 | plugins { 4 | `streamlined-plugin` 5 | id("com.android.library") 6 | id("com.google.dagger.hilt.android") 7 | kotlin("android") 8 | kotlin("kapt") 9 | } 10 | 11 | android { 12 | namespace = "io.github.reactivecircus.streamlined.testing" 13 | } 14 | 15 | dependencies { 16 | implementation(project(":analytics-api-no-op")) 17 | implementation(project(":navigator")) 18 | implementation(project(":ui-common")) 19 | implementation(project(":domain-api")) 20 | api(project(":data")) 21 | api(project(":remote-mock")) 22 | 23 | // Blueprint 24 | implementation(Libraries.blueprint.asyncCoroutines) 25 | 26 | // Hilt 27 | implementation(Libraries.hilt.android) 28 | api(Libraries.hilt.androidTesting) 29 | kapt(Libraries.hilt.compiler) 30 | 31 | // OkHttp 32 | implementation(Libraries.okhttp.client) 33 | implementation(Libraries.okhttp.loggingInterceptor) 34 | 35 | // Retrofit 36 | api(Libraries.retrofit.client) 37 | api(Libraries.retrofit.mock) 38 | 39 | // timber 40 | implementation(Libraries.timber) 41 | 42 | implementation(Libraries.androidx.fragment.testing) 43 | implementation(Libraries.radiography) 44 | api(Libraries.blueprint.testingRobot) 45 | api(Libraries.androidx.test.coreKtx) 46 | api(Libraries.androidx.test.monitor) 47 | api(Libraries.androidx.test.runner) 48 | api(Libraries.androidx.test.rules) 49 | api(Libraries.androidx.test.ext.junitKtx) 50 | api(Libraries.androidx.test.ext.truth) 51 | api(Libraries.androidx.espresso.core) 52 | api(Libraries.androidx.espresso.contrib) 53 | api(Libraries.androidx.espresso.intents) 54 | api(Libraries.truth) 55 | } 56 | -------------------------------------------------------------------------------- /ui-testing-framework/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /ui-testing-framework/src/main/java/io/github/reactivecircus/streamlined/testing/HiltTestActivity.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.testing 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import dagger.hilt.android.AndroidEntryPoint 6 | import io.github.reactivecircus.streamlined.design.R as ThemeResource 7 | 8 | @AndroidEntryPoint 9 | class HiltTestActivity : AppCompatActivity() { 10 | override fun onCreate(savedInstanceState: Bundle?) { 11 | setTheme(ThemeResource.style.Theme_Streamlined_DayNight) 12 | super.onCreate(savedInstanceState) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /ui-testing-framework/src/main/java/io/github/reactivecircus/streamlined/testing/ScreenTestApp.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.testing 2 | 3 | import dagger.hilt.android.testing.CustomTestApplication 4 | 5 | @CustomTestApplication(BaseScreenTestApp::class) 6 | interface ScreenTestApp 7 | -------------------------------------------------------------------------------- /ui-testing-framework/src/main/java/io/github/reactivecircus/streamlined/testing/ScreenTestRunner.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.testing 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import androidx.test.runner.AndroidJUnitRunner 6 | 7 | class ScreenTestRunner : AndroidJUnitRunner() { 8 | 9 | @Throws( 10 | InstantiationException::class, 11 | IllegalAccessException::class, 12 | ClassNotFoundException::class, 13 | ) 14 | override fun newApplication(cl: ClassLoader, className: String, context: Context): Application { 15 | return super.newApplication(cl, ScreenTestApp_Application::class.java.name, context) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ui-testing-framework/src/main/java/io/github/reactivecircus/streamlined/testing/TestAnimationConfigs.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.testing 2 | 3 | import io.github.reactivecircus.streamlined.ui.configs.AnimationConfigs 4 | import javax.inject.Inject 5 | 6 | class TestAnimationConfigs @Inject constructor() : AnimationConfigs { 7 | override val defaultStartOffset: Int = 0 8 | override val adapterPayloadAnimationDuration: Int = 0 9 | } 10 | -------------------------------------------------------------------------------- /ui-testing-framework/src/main/java/io/github/reactivecircus/streamlined/testing/TestData.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.testing 2 | 3 | import io.github.reactivecircus.streamlined.data.mapper.toTimestamp 4 | import io.github.reactivecircus.streamlined.domain.model.Story 5 | import io.github.reactivecircus.streamlined.remote.MockData 6 | import io.github.reactivecircus.streamlined.remote.dto.StoryDTO 7 | 8 | val testHeadlineStories = MockData.mockHeadlineStories.map { it.toModel() } 9 | val testPersonalizedStories = MockData.mockPersonalizedStories.map { it.toModel() } 10 | 11 | private fun StoryDTO.toModel(): Story { 12 | return Story( 13 | id = -1, 14 | source = source.name, 15 | title = title, 16 | author = author, 17 | description = description, 18 | url = url, 19 | imageUrl = urlToImage, 20 | publishedTime = publishedAt.toTimestamp() 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /ui-testing-framework/src/main/java/io/github/reactivecircus/streamlined/testing/TestDebugTree.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.testing 2 | 3 | import timber.log.Timber 4 | 5 | // TODO replace with LogcatTree once Timber multiplatform is released 6 | /** 7 | * Custom Timber debug tree with line number in the tag. 8 | */ 9 | class TestDebugTree : Timber.DebugTree() { 10 | 11 | override fun createStackElementTag(element: StackTraceElement): String? { 12 | return super.createStackElementTag(element) + ":" + element.lineNumber 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /ui-testing-framework/src/main/java/io/github/reactivecircus/streamlined/testing/TestRefreshPolicy.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.testing 2 | 3 | import io.github.reactivecircus.store.ext.RefreshPolicy 4 | import io.github.reactivecircus.store.ext.RefreshScope 5 | import io.github.reactivecircus.streamlined.testing.TestRefreshPolicy.shouldRefresh 6 | 7 | /** 8 | * Implementation of [RefreshPolicy] where [shouldRefresh] is always true. 9 | */ 10 | object TestRefreshPolicy : RefreshPolicy { 11 | 12 | override suspend fun shouldRefresh(refreshScope: RefreshScope): Boolean = true 13 | 14 | override suspend fun onRefreshed(refreshScope: RefreshScope) = Unit 15 | } 16 | -------------------------------------------------------------------------------- /ui-testing-framework/src/main/java/io/github/reactivecircus/streamlined/testing/assumption/DataAssumptions.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.streamlined.testing.assumption 2 | 3 | import com.dropbox.android.external.store4.ExperimentalStoreApi 4 | import com.dropbox.android.external.store4.fresh 5 | import dagger.Reusable 6 | import io.github.reactivecircus.streamlined.data.HeadlineStoryStore 7 | import io.github.reactivecircus.streamlined.data.PersonalizedStoryStore 8 | import javax.inject.Inject 9 | import kotlinx.coroutines.runBlocking 10 | import kotlinx.coroutines.withContext 11 | import reactivecircus.blueprint.async.coroutines.CoroutineDispatcherProvider 12 | 13 | @Reusable 14 | @OptIn(ExperimentalStoreApi::class) 15 | class DataAssumptions @Inject constructor( 16 | private val headlineStoryStore: HeadlineStoryStore, 17 | private val personalizedStoryStore: PersonalizedStoryStore, 18 | private val dispatcherProvider: CoroutineDispatcherProvider 19 | ) { 20 | fun assumeNoCachedHeadlineStories() = runBlocking { 21 | withContext(dispatcherProvider.io) { 22 | headlineStoryStore.clearAll() 23 | } 24 | } 25 | 26 | fun assumeNoCachedPersonalizedStories() = runBlocking { 27 | withContext(dispatcherProvider.io) { 28 | personalizedStoryStore.clearAll() 29 | } 30 | } 31 | 32 | fun populateHeadlineStories() = runBlocking { 33 | withContext(dispatcherProvider.io) { 34 | headlineStoryStore.fresh(Unit) 35 | } 36 | } 37 | 38 | fun populatePersonalizedStories() = runBlocking { 39 | withContext(dispatcherProvider.io) { 40 | personalizedStoryStore.fresh(DUMMY_QUERY) 41 | } 42 | } 43 | } 44 | 45 | private const val DUMMY_QUERY = "query" 46 | -------------------------------------------------------------------------------- /ui-testing-framework/src/main/java/io/github/reactivecircus/streamlined/testing/assumption/NetworkAssumptions.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("MagicNumber") 2 | 3 | package io.github.reactivecircus.streamlined.testing.assumption 4 | 5 | import dagger.Reusable 6 | import javax.inject.Inject 7 | import retrofit2.mock.NetworkBehavior 8 | 9 | @Reusable 10 | class NetworkAssumptions @Inject constructor( 11 | private val networkBehavior: NetworkBehavior 12 | ) { 13 | fun assumeNetworkConnected() { 14 | networkBehavior.setFailurePercent(0) 15 | } 16 | 17 | fun assumeNetworkDisconnected() { 18 | networkBehavior.setFailurePercent(100) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ui-testing-framework/src/main/java/io/github/reactivecircus/streamlined/testing/di/TestAppModule.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | 3 | package io.github.reactivecircus.streamlined.testing.di 4 | 5 | import android.content.Context 6 | import android.os.AsyncTask 7 | import coil.ImageLoader 8 | import dagger.Binds 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.Reusable 12 | import dagger.hilt.InstallIn 13 | import dagger.hilt.android.qualifiers.ApplicationContext 14 | import dagger.hilt.components.SingletonComponent 15 | import io.github.reactivecircus.streamlined.persistence.DatabaseConfigs 16 | import io.github.reactivecircus.streamlined.testing.TestAnimationConfigs 17 | import io.github.reactivecircus.streamlined.ui.configs.AnimationConfigs 18 | import kotlinx.coroutines.Dispatchers 19 | import kotlinx.coroutines.asCoroutineDispatcher 20 | import reactivecircus.blueprint.async.coroutines.CoroutineDispatcherProvider 21 | 22 | @Module 23 | @InstallIn(SingletonComponent::class) 24 | internal abstract class TestAppModule { 25 | 26 | @Binds 27 | @Reusable 28 | abstract fun animationConfigs(impl: TestAnimationConfigs): AnimationConfigs 29 | 30 | companion object { 31 | 32 | @Suppress("DEPRECATION") 33 | @Provides 34 | @Reusable 35 | fun coroutineDispatcherProvider(): CoroutineDispatcherProvider { 36 | // TODO use proper io dispatcher https://github.com/Kotlin/kotlinx.coroutines/issues/242 37 | return CoroutineDispatcherProvider( 38 | io = AsyncTask.THREAD_POOL_EXECUTOR.asCoroutineDispatcher(), 39 | computation = AsyncTask.THREAD_POOL_EXECUTOR.asCoroutineDispatcher(), 40 | ui = Dispatchers.Main.immediate 41 | ) 42 | } 43 | 44 | @Provides 45 | @Reusable 46 | fun imageLoader(@ApplicationContext context: Context): ImageLoader { 47 | return ImageLoader.Builder(context).build() 48 | } 49 | 50 | @Provides 51 | @Reusable 52 | fun databaseConfigs( 53 | coroutineDispatcherProvider: CoroutineDispatcherProvider 54 | ): DatabaseConfigs { 55 | return DatabaseConfigs( 56 | databaseName = null, 57 | coroutineContext = coroutineDispatcherProvider.io, 58 | ) 59 | } 60 | } 61 | } 62 | --------------------------------------------------------------------------------