diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..bd4987b --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,15 @@ +coverage: + range: 50..80 + round: down + precision: 2 + +comment: + layout: diff, files + +ignore: + - "**/fake" + - "**/commonTest" + - "**/androidTest" + - "**/iOSTest" + - "**/jsTest" + - "**/jvmTest" \ No newline at end of file diff --git a/.github/workflows/.ci_test_and_publish.yml b/.github/workflows/.ci_test_and_publish.yml index 4a8b961..0baa6f1 100644 --- a/.github/workflows/.ci_test_and_publish.yml +++ b/.github/workflows/.ci_test_and_publish.yml @@ -12,7 +12,7 @@ on: jobs: publish: - runs-on: ubuntu-latest + runs-on: macos-latest if: github.repository == 'MobileNativeFoundation/Store' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/store5') steps: @@ -69,4 +69,4 @@ jobs: env: API_LEVEL: ${{ matrix.api-level }} - name: Upload code coverage - run: bash <(curl -s https://codecov.io/bash) + run: bash <(curl -s https://codecov.io/bash) \ No newline at end of file diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..842d279 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,22 @@ +name: Check +on: push +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + - name: Run check with Gradle Wrapper + run: ./gradlew check + + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - uses: codecov/codecov-action@v3 + with: + files: ./kover/coverage.xml + name: codecov-umbrella + verbose: true \ No newline at end of file diff --git a/.github/workflows/create_swift_package.yml b/.github/workflows/create_swift_package.yml new file mode 100644 index 0000000..dca0c1f --- /dev/null +++ b/.github/workflows/create_swift_package.yml @@ -0,0 +1,7 @@ +name: Create Swift Package + +on: + workflow_dispatch: +jobs: + publish: + uses: touchlab/KMMBridgeGithubWorkflow/.github/workflows/faktorybuildbranches.yml@v0.6 \ No newline at end of file diff --git a/.gitignore b/.gitignore index c8408e9..b54ac9c 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ captures/ # Keystore files *.jks + +store/kover +*.podspec +yarn.lock \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 48edf6d..ec7ad3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,86 +1,104 @@ -Change Log -========== +# Changelog +## [Unreleased] -The change log for Store version 1.x can be found [here](https://github.com/NYTimes/Store/blob/develop/CHANGELOG.md). +## [5.0.0-alpha03] (2022-12-18) -Version 4.0.7 (2022-11-20) ----------------------------- -* Update CI +This release adds support for Store on iOS, JVM, and JS. Concepts and usage are unchanged from +Store4. In a future release we will reintroduce support for local and remote writes with conflict +resolution based on Google's offline first guidance. -Version 4.0.6 (2022-11-20) ----------------------------- -* Flip coordinates from `com.dropbox.mobile.store` to `org.mobilenativefoundation.store` +* Target Android, iOS, JVM, JS +* Remove concept of Market +* Remove support for local and remote writes (temporary) + +## [5.0.0-alpha02] (2022-12-04) + +* Target iOS and JS +* Rename packages + +## [5.0.0-alpha1] (2022-12-04) + +* Introduce Market +* Support local and remote writes with conflict resolution based on Google's offline-first guidance +* Target Android and JVM + +## [4.0.5] (2021-03-30) -Version 4.0.5 (2021-03-30) ----------------------------- * Update to Kotlin 1.6.10 - * Store `4.0.4-KT15` is the last version supporting Kotlin 1.5 - * Store `4.0.1` is the last version supporting Kotlin 1.4 + * Store `4.0.4-KT15` is the last version supporting Kotlin 1.5 + * Store `4.0.1` is the last version supporting Kotlin 1.4 -Version 4.0.4-KT15 *(2021-12-08) ----------------------------- -** Bug fixes and documentation updates +## [4.0.4-KT15] (2021-12-08) -Version 4.0.3-KT15 *(2021-11-18) ----------------------------- -** Update to Kotlin 1.5.31 and Coroutines 1.5.2 -** Bug fixes and documentation updates +* Bug fixes and documentation updates + +## [4.0.3-KT15] (2021-11-18) + +* Update to Kotlin 1.5.31 and Coroutines 1.5.2 +* Bug fixes and documentation updates + +## [4.0.2-KT15] (2021-05-06) -Version 4.0.2-KT15 *(2021-05-06) ----------------------------- **Kotlin 1.5 introduced breaking changes in the experimental Duration apis we used** **4.0.2-KT15 is a duplicate of 4.0.1 but compiled for kotlin 1.5** **Version 4.0.1 is the last version compatible with Kotlin 1.4** * Fire off kotlin 1.5 compatible snapshot (#273) +## [4.0.1] (2021-05-06) -Version 4.0.1 *(2021-05-06)* ----------------------------- -* Fixes issues when upgrading to kotlin 1.5 (Deprecated duration api) +* Fix issues when upgrading to kotlin 1.5 (Deprecated duration api) * Add piggyback to all stores -Version 4.0.0 *(2020-11-30)* ----------------------------- -* update coroutines to 1.4.0, kotlin to 1.4.10 [#242](https://github.com/dropbox/Store/pull/242) +## [4.0.0] (2020-11-30) +* Update coroutines to 1.4.0, kotlin to 1.4.10 [#242](https://github.com/dropbox/Store/pull/242) + +## [4.0.0-beta] (2020-09-21) -Version 4.0.0-beta *(2020-09-21)* ----------------------------- **API change** + * Remove need for generics with `Error` type (#220) **Bug Fixes and Stability Improvements** + * Revert cache implementation to guava, rather than rolling our own (#200) * Sample App improvements (#227) -Version 4.0.0-alpha07 *(2020-08-19)* ----------------------------- +## [4.0.0-alpha07] (2020-08-19) + **New Features** + * Add `StoreResult.NoNewData` to represent when a fetcher didn't return data. (#194) * Move `Fetcher`-factories into `Companion` of `Fetcher` interface (#168) **Bug Fixes and Stability Improvements** + * Fix a leak of non-global coroutine contexts. (#199) * Update to Kotlin 1.4.0 and Coroutines 1.3.9 (#195) -* Update to Coroutines 1.3.5 and remove `@FlowPreview` and `@ExperimentalCoroutinesApi` annotations. (#166) +* Update to Coroutines 1.3.5 and remove `@FlowPreview` and `@ExperimentalCoroutinesApi` + annotations. (#166) + +## [4.0.0-alpha06] (2020-04-29) -Version 4.0.0-alpha06 *(2020-04-29)* ----------------------------- **Major API change!** (#123) -This release introduces a major change to `StoreBuilder`'s API. This should be the LAST major API change to store before we'll move to beta. +This release introduces a major change to `StoreBuilder`'s API. This should be the LAST major API +change to store before +we'll move to beta. + * The typealias `Fetcher` was added to standardize the input type for a `StoreBuilder` * `SourceOfTruth` in now a top level interface and part of `Store`'s public API * `StoreBuilder` can now only be created using a `Fetcher` and optionally a `SourceOfTruth` -* All the overloads for creating a `StoreBuilder` were moved to `Fetcher` and `SourceOfTruth` as appropriate. +* All the overloads for creating a `StoreBuilder` were moved to `Fetcher` and `SourceOfTruth` as + appropriate. * Rx artifacts were updated accordingly to match main artifacts. -Version 4.0.0-alpha05 *(2020-04-03)* ----------------------------- +## [4.0.0-alpha05] (2020-04-03) **Bug Fixes and Stability Improvements** + * Contain @ExperimentalStdlibApi within relevant scope. (#154) * Use AtomicFu to replace Java's AtomicBoolean and ReentrantLock (#147) * migrate Multicast to Kotlin Test (#146) @@ -88,10 +106,10 @@ Version 4.0.0-alpha05 *(2020-04-03)* * Update AGP version (#143) * Remove some unneeded java.util packages (#141) -Version 4.0.0-alpha04 *(2020-04-03)* ----------------------------- +## [4.0.0-alpha04] (2020-04-03) **New Features** + * Add `asMap` function to Cache for backward compat (#136) * Migrate filesystem library to use kotlin.time APIs (#133) * Rx get fresh bindings (#130) @@ -99,38 +117,38 @@ Version 4.0.0-alpha04 *(2020-04-03)* * Update sample app (#117) **Bug Fixes and Stability Improvements** + * Use Kotlin version of ArrayDeque in ChannelManager (#134) * Kotlin 1.3.70 and other dependencies updates (#125) * Make SharedFlowProducer APIs safe (#121) * Ensure network starts after disk is established (#115) * Update to Gradle 6.2 (#111) -Version 4.0.0-alpha03 *(2020-02-13)* ----------------------------- +## [4.0.0-alpha03] (2020-02-13) **New Features** + * Added Rx bindings, available as store-rx2 artifact (#93) * Bug fixes (#90) * Add ability to delete all entries in the store (#79) -Version 4.0.0-alpha02 *(2020-01-29)* ----------------------------- +## [4.0.0-alpha02] (2020-01-29) **New Features** + * Introduce piggyback only downstreams to multicaster and fix #59 (#75) * Change flow collection util to drain the flow (#64) * Readme improvements (#70, #72) * Avoid illegal cast in RealStore.stream (#69) * Added docs to MemoryPolicy.setMemorySize (#67) (#68) -Version 4.0.0-alpha01 *(2020-01-08)* ----------------------------- +## [4.0.0-alpha01] (2020-01-08) **New Features** + * Store has been rewritten using Kotlin Coroutines instead of RxJava -Version 3.1.0 *(2018-06-07)* ----------------------------- +## [3.1.0] (2018-06-07) **New Features** @@ -146,16 +164,14 @@ Version 3.1.0 *(2018-06-07)* * (#273) Adds comments to the sample app * (#336) Fixes errors in README -Version 3.0.1 *(2018-03-20)* ----------------------------- +## [3.0.1] (2018-03-20) **Bug Fixes and Stability Improvements** * (#311) Update Kotlin & AGP versions * (#314) Fix issues occured from RxJava1 dependency -Version 3.0.0 *(2018-02-01)* ----------------------------- +## [3.0.0] (2018-02-01) **New Features** @@ -163,14 +179,13 @@ Version 3.0.0 *(2018-02-01)* **Bug Fixes and Stability Improvements** -* (#267) Kotlin 1.1.4 for store-kotlin +* (#267) Kotlin 1.1.4 for store-kotlin * (#290) Remove @Experimental from store-kotlin API * (#283) Update build tools to 26.0.2 * (#259, #261, #272, #289, #303) README + documentation updates * (#310) Sample app fixes -Version 3.0.0-beta *(2017-07-26)* ----------------------------- +## [3.0.0-beta] (2017-07-26) **New Features** @@ -187,29 +202,81 @@ Version 3.0.0-beta *(2017-07-26)* * (#246) Update to Moshi 1.5.0 * (#252) Fix stream for a single barcode -Version 3.0.0-alpha *(2017-05-23)* ----------------------------- +## [3.0.0-alpha] (2017-05-23) This is a first alpha release of Store ported to RxJava 2. **New Features** * (#155) Port to RxJava 2 -* (#220) Packages have been renamed to store3 to allow use of this artifact alongside the original Store +* (#220) Packages have been renamed to store3 to allow use of this artifact alongside the original + Store * (#185) Return Single/Maybe where appropriate * (#189) Add lambdas to Store and Filesystem modules * (#214) expireAfterAccess added to MemoryPolicy -* (#214) Deprecate setExpireAfter and getExpireAfter -- use new expireAfterWrite or expireAfterAccess, see #199 for -MemoryPolicy changes +* (#214) Deprecate setExpireAfter and getExpireAfter -- use new expireAfterWrite or + expireAfterAccess, see #199 for + MemoryPolicy changes * (#214) Add Raw to BufferedSource transformer - **Bug Fixes and Stability Improvements** * (#214) Fix networkBeforeStale on cold start with no connectivity * (#214) Add a missing source.close() call -* (#164) FileSystemPersister.persisterIsStale() should return false if record is missing or policy is unspecified +* (#164) FileSystemPersister.persisterIsStale() should return false if record is missing or policy + is unspecified * (#166) Remove apt dependency and use annotationProcessor instead * (#214) Standardize store.stream() to emit only new items * (#214) Fix typos -* (#214) Close source after write to filesystem \ No newline at end of file +* (#214) Close source after write to filesystem + +## [1.x] + +* The change log for Store version 1.x can be + found [here](https://github.com/NYTimes/Store/blob/develop/CHANGELOG.md). + +[Unreleased]: https://github.com/MobileNativeFoundation/Store/compare/v4.0.5...HEAD + +[5.0.0-alpha02]: https://github.com/MobileNativeFoundation/Store/releases/tag/5.0.0-alpha02 + +[5.0.0-alpha1]: https://github.com/MobileNativeFoundation/Store/releases/tag/5.0.0-alpha1 + +[4.0.5]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.5 + +[4.0.4-KT15]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.4-KT15 + +[4.0.3-KT15]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.3-KT15 + +[4.0.2-KT15]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.2-KT15 + +[4.0.1]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.1 + +[4.0.0]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.0 + +[4.0.0-beta]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.0-beta + +[4.0.0-alpha07]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.0-alpha07 + +[4.0.0-alpha06]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.0-alpha06 + +[4.0.0-alpha05]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.0-alpha05 + +[4.0.0-alpha04]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.0-alpha04 + +[4.0.0-alpha03]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.0-alpha03 + +[4.0.0-alpha02]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.0-alpha02 + +[4.0.0-alpha01]: https://github.com/MobileNativeFoundation/Store/releases/tag/4.0.0-alpha01 + +[3.1.0]: https://github.com/MobileNativeFoundation/Store/releases/tag/3.1.0 + +[3.0.1]: https://github.com/MobileNativeFoundation/Store/releases/tag/3.0.1 + +[3.0.0]: https://github.com/MobileNativeFoundation/Store/releases/tag/3.0.0 + +[3.0.0-beta]: https://github.com/MobileNativeFoundation/Store/releases/tag/3.0.0-beta + +[3.0.0-alpha]: https://github.com/MobileNativeFoundation/Store/releases/tag/3.0.0-alpha + +[1.x]: https://github.com/NYTimes/Store/blob/develop/CHANGELOG.md \ No newline at end of file diff --git a/README.md b/README.md index 463b49c..c004806 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,78 @@ -# Store 4 +# Store 5 -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/org.mobilenativefoundation.store/store4/badge.svg)](https://maven-badges.herokuapp.com/maven-central/org.mobilenativefoundation.store/store4/) +## Why We Made Store -[![codecov](https://codecov.io/gh/dropbox/Store/branch/master/graph/badge.svg)](https://codecov.io/gh/dropbox/Store) +- Modern software needs data representations to be fluid and always available. +- Users expect their UI experience to never be compromised (blocked) by new data loads. Whether an + application is social, news or business-to-business, users expect a seamless experience both + online and offline. +- International users expect minimal data downloads as many megabytes of downloaded data can quickly + result in astronomical phone bills. Store is a Kotlin library for loading data from remote and local sources. -### The Problems: - -+ Modern software needs data representations to be fluid and always available. -+ Users expect their UI experience to never be compromised (blocked) by new data loads. Whether an application is social, news or business-to-business, users expect a seamless experience both online and offline. -+ International users expect minimal data downloads as many megabytes of downloaded data can quickly result in astronomical phone bills. - -A Store is a class that simplifies fetching, sharing, storage, and retrieval of data in your application. A Store is similar to the [Repository pattern](https://msdn.microsoft.com/en-us/library/ff649690.aspx) while exposing an API built with [Coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html) that adheres to a unidirectional data flow. - -Store provides a level of abstraction between UI elements and data operations. - ### Overview -A Store is responsible for managing a particular data request. When you create an implementation of a Store, you provide it with a `Fetcher`, a function that defines how data will be fetched over network. You can also define how your Store will cache data in-memory and on-disk. Since Store returns your data as a `Flow`, threading is a breeze! Once a Store is built, it handles the logic around data flow, allowing your views to use the best data source and ensuring that the newest data is always available for later offline use. +A Store is responsible for managing a particular data request. When you create an implementation of +a Store, you provide it with a `Fetcher`, a function that defines how data will be fetched over +network. You can also define how your Store will cache data in-memory and on-disk. Since Store +returns your data as a `Flow`, threading is a breeze! Once a Store is built, it handles the logic +around data flow, allowing your views to use the best data source and ensuring that the newest data +is always available for later offline use. -Store leverages multiple request throttling to prevent excessive calls to the network and disk cache. By utilizing Store, you eliminate the possibility of flooding your network with the same request while adding two layers of caching (memory and disk) as well as ability to add disk as a source of truth where you can modify the disk directly without going through Store (works best with databases that can provide observables sources like [Jetpack Room](https://developer.android.com/jetpack/androidx/releases/room), [SQLDelight](https://github.com/cashapp/sqldelight) or [Realm](https://realm.io/products/realm-database/)) +Store leverages multiple request throttling to prevent excessive calls to the network and disk +cache. By utilizing Store, you eliminate the possibility of flooding your network with the same +request while adding two layers of caching (memory and disk) as well as ability to add disk as a +source of truth where you can modify the disk directly without going through Store (works best with +databases that can provide observables sources +like [Jetpack Room](https://developer.android.com/jetpack/androidx/releases/room) +, [SQLDelight](https://github.com/cashapp/sqldelight) +or [Realm](https://realm.io/products/realm-database/)) ### How to include in your project Artifacts are hosted on **Maven Central**. -###### Latest version: - -```groovy -def store_version = "4.0.7" +```kotlin +STORE_VERSION = "5.0.0-alpha03" ``` -###### Add the dependency to your `build.gradle`: +### Android ```groovy -implementation "org.mobilenativefoundation.store:store4:${store_version}" +implementation "org.mobilenativefoundation.store:store5:$STORE_VERSION" ``` -###### Set the source & target compatibilities to `1.8` +### Multiplatform (Common, JVM, Native, JS) -```groovy -android { - compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 - } - ... +```kotlin +commonMain { + dependencies { + implementation("org.mobilenativefoundation.store:store5:$STORE_VERSION") + } } ``` ### Fully Configured Store -Let's start by looking at what a fully configured Store looks like. We will then walk through simpler examples showing each piece: + +Let's start by looking at what a fully configured Store looks like. We will then walk through +simpler examples showing each piece: ```kotlin StoreBuilder - .from( - fetcher = Fetcher.of { api.fetchSubreddit(it, "10").data.children.map(::toPosts) }, - sourceOfTruth = SourceOfTruth.of( - reader = db.postDao()::loadPosts, - writer = db.postDao()::insertPosts, - delete = db.postDao()::clearFeed, - deleteAll = db.postDao()::clearAllFeeds - ) - ).build() + .from( + fetcher = Fetcher.of { api.fetchSubreddit(it, "10").data.children.map(::toPosts) }, + sourceOfTruth = SourceOfTruth.of( + reader = db.postDao()::loadPosts, + writer = db.postDao()::insertPosts, + delete = db.postDao()::clearFeed, + deleteAll = db.postDao()::clearAllFeeds + ) + ).build() ``` With the above setup you have: + + In-memory caching for rotation + Disk caching for when users are offline + Throttling of API calls when parallel requests are made for the same resource @@ -76,50 +82,62 @@ And now for the details: ### Creating a Store -You create a Store using a builder. The only requirement is to include a `Fetcher` which is just a `typealias` to a function that returns a `Flow>`. - +You create a Store using a builder. The only requirement is to include a `Fetcher` which is just +a `typealias` to a function that returns a `Flow>`. ```kotlin val store = StoreBuilder - .from(Fetcher.ofFlow { articleId -> api.getArticle(articleId) }) // api returns Flow
- .build() + .from(Fetcher.ofFlow { articleId -> api.getArticle(articleId) }) // api returns Flow
+ .build() ``` -Store uses generic keys as identifiers for data. A key can be any value object that properly implements `toString()`, `equals()` and `hashCode()`. When your `Fetcher` function is called, it will be passed a particular `Key` value. Similarly, the key will be used as a primary identifier within caches (Make sure to have a proper `hashCode()`!!). +Store uses generic keys as identifiers for data. A key can be any value object that properly +implements `toString()`, `equals()` and `hashCode()`. When your `Fetcher` function is called, it +will be passed a particular `Key` value. Similarly, the key will be used as a primary identifier +within caches (Make sure to have a proper `hashCode()`!!). -Note: We highly recommend using built-in types that implement `equals` and `hashcode` or Kotlin `data` classes for complex keys. +Note: We highly recommend using built-in types that implement `equals` and `hashcode` or +Kotlin `data` classes for complex keys. ### Public Interface - Stream -The primary function provided by a `Store` instance is the `stream` function which has the following signature: +The primary function provided by a `Store` instance is the `stream` function which has the following +signature: ```kotlin fun stream(request: StoreRequest): Flow> ``` -Each `stream` call receives a `StoreRequest` object, which defines which key to fetch and which data sources to utilize. -The response is a `Flow` of `StoreResponse`. `StoreResponse` is a Kotlin sealed class that can be either + +Each `stream` call receives a `StoreRequest` object, which defines which key to fetch and which data +sources to utilize. +The response is a `Flow` of `StoreResponse`. `StoreResponse` is a Kotlin sealed class that can be +either a `Loading`, `Data` or `Error` instance. Each `StoreResponse` includes an `origin` field which specifies where the event is coming from. -* The `Loading` class only has an `origin` field. This can provide you information like "network is fetching data", which can be a good signal to activate the loading spinner in your UI. +* The `Loading` class only has an `origin` field. This can provide you information like "network is + fetching data", which can be a good signal to activate the loading spinner in your UI. * The `Data` class has a `value` field which includes an instance of the type returned by `Store`. -* The `Error` class includes an `error` field that contains the exception thrown by the given `origin`. +* The `Error` class includes an `error` field that contains the exception thrown by the + given `origin`. -When an error happens, `Store` does not throw an exception, instead, it wraps it in a `StoreResponse.Error` type which allows `Flow` to continue so that it can still receive updates that might be triggered by either changes in your data source or subsequent fetch operations. +When an error happens, `Store` does not throw an exception, instead, it wraps it in +a `StoreResponse.Error` type which allows `Flow` to continue so that it can still receive updates +that might be triggered by either changes in your data source or subsequent fetch operations. ```kotlin viewModelScope.launch { - store.stream(StoreRequest.cached(key = key, refresh=true)).collect { response -> - when(response) { - is StoreResponse.Loading -> showLoadingSpinner() - is StoreResponse.Data -> { - if (response.origin == ResponseOrigin.Fetcher) hideLoadingSpinner() - updateUI(response.value) - } - is StoreResponse.Error -> { - if (response.origin == ResponseOrigin.Fetcher) hideLoadingSpinner() - showError(response.error) - } + store.stream(StoreRequest.cached(key = key, refresh = true)).collect { response -> + when (response) { + is StoreResponse.Loading -> showLoadingSpinner() + is StoreResponse.Data -> { + if (response.origin == ResponseOrigin.Fetcher) hideLoadingSpinner() + updateUI(response.value) + } + is StoreResponse.Error -> { + if (response.origin == ResponseOrigin.Fetcher) hideLoadingSpinner() + showError(response.error) + } } } } @@ -127,8 +145,14 @@ viewModelScope.launch { For convenience, there are `Store.get(key)` and `Store.fresh(key)` extension functions. -* `suspend fun Store.get(key: Key): Value`: This method returns a single value for the given key. If available, it will be returned from the in memory cache or the sourceOfTruth. An error will be thrown if no value is available in either the `cache` or `sourceOfTruth`, and the `fetcher` fails to load the data from the network. -* `suspend fun Store.fresh(key: Key): Value`: This method returns a single value for the given key that is obtained by querying the fetcher. An error will be thrown if the `fetcher` fails to load the data from the network, regardless of whether any value is available in the `cache` or `sourceOfTruth`. +* `suspend fun Store.get(key: Key): Value`: This method returns a single value for the given key. If + available, it will be returned from the in memory cache or the sourceOfTruth. An error will be + thrown if no value is available in either the `cache` or `sourceOfTruth`, and the `fetcher` fails + to load the data from the network. +* `suspend fun Store.fresh(key: Key): Value`: This method returns a single value for the given key + that is obtained by querying the fetcher. An error will be thrown if the `fetcher` fails to load + the data from the network, regardless of whether any value is available in the `cache` + or `sourceOfTruth`. ```kotlin lifecycleScope.launchWhenStarted { @@ -137,70 +161,104 @@ lifecycleScope.launchWhenStarted { } ``` -The first time you call to `suspend store.get(key)`, the response will be stored in an in-memory cache and in the sourceOfTruth, if provided. -All subsequent calls to `store.get(key)` with the same `Key` will retrieve the cached version of the data, minimizing unnecessary data calls. This prevents your app from fetching fresh data over the network (or from another external data source) in situations when doing so would unnecessarily waste bandwidth and battery. A great use case is any time your views are recreated after a rotation, they will be able to request the cached data from your Store. Having this data available can help you avoid the need to retain this in the view layer. - -By default, 100 items will be cached in memory for 24 hours. You may [pass in your own memory policy to override the default policy](#Configuring-In-memory-Cache). +The first time you call to `suspend store.get(key)`, the response will be stored in an in-memory +cache and in the sourceOfTruth, if provided. +All subsequent calls to `store.get(key)` with the same `Key` will retrieve the cached version of the +data, minimizing unnecessary data calls. This prevents your app from fetching fresh data over the +network (or from another external data source) in situations when doing so would unnecessarily waste +bandwidth and battery. A great use case is any time your views are recreated after a rotation, they +will be able to request the cached data from your Store. Having this data available can help you +avoid the need to retain this in the view layer. +By default, 100 items will be cached in memory for 24 hours. You +may [pass in your own memory policy to override the default policy](#Configuring-In-memory-Cache). ### Skipping Memory/Disk -Alternatively, you can call `store.fresh(key)` to get a `suspended result` that skips the memory (and optional disk cache). +Alternatively, you can call `store.fresh(key)` to get a `suspended result` that skips the memory ( +and optional disk cache). - -A good use case is overnight background updates use `fresh()` to make sure that calls to `store.get()` will not have to hit the network during normal usage. Another good use case for `fresh()` is when a user wants to pull to refresh. +A good use case is overnight background updates use `fresh()` to make sure that calls +to `store.get()` will not have to hit the network during normal usage. Another good use case +for `fresh()` is when a user wants to pull to refresh. Calls to both `fresh()` and `get()` emit one value or throw an error. - ### Stream -For real-time updates, you may also call `store.stream()` which returns a `Flow` that emits each time a new item is returned from your store. You can think of stream as a way to create reactive streams that update when you db or memory cache updates + +For real-time updates, you may also call `store.stream()` which returns a `Flow` that emits each +time a new item is returned from your store. You can think of stream as a way to create reactive +streams that update when you db or memory cache updates example calls: + ```kotlin lifecycleScope.launchWhenStarted { - store.stream(StoreRequest.cached(3, refresh = false)) //will get cached value followed by any fresh values, refresh will also trigger network call if set to `true` even if the data is available in cache or disk. - .collect {} - store.stream(StoreRequest.fresh(3)) //skip cache, go directly to fetcher - .collect {} + store.stream( + StoreRequest.cached( + 3, + refresh = false + ) + ) //will get cached value followed by any fresh values, refresh will also trigger network call if set to `true` even if the data is available in cache or disk. + .collect {} + store.stream(StoreRequest.fresh(3)) //skip cache, go directly to fetcher + .collect {} } ``` ### Inflight Debouncer -To prevent duplicate requests for the same data, Store offers an inflight debouncer. If the same request is made as a previous identical request that has not completed, the same response will be returned. This is useful for situations when your app needs to make many async calls for the same data at startup or when users are obsessively pulling to refresh. As an example, The New York Times news app asynchronously calls `ConfigStore.get()` from 12 different places on startup. The first call blocks while all others wait for the data to arrive. We have seen a dramatic decrease in the app's data usage after implementing this inflight logic. +To prevent duplicate requests for the same data, Store offers an inflight debouncer. If the same +request is made as a previous identical request that has not completed, the same response will be +returned. This is useful for situations when your app needs to make many async calls for the same +data at startup or when users are obsessively pulling to refresh. As an example, The New York Times +news app asynchronously calls `ConfigStore.get()` from 12 different places on startup. The first +call blocks while all others wait for the data to arrive. We have seen a dramatic decrease in the +app's data usage after implementing this inflight logic. ### Disk as Cache -Stores can enable disk caching by passing a `SourceOfTruth` into the builder. Whenever a new network request is made, the Store will first write to the disk cache and then read from the disk cache. +Stores can enable disk caching by passing a `SourceOfTruth` into the builder. Whenever a new network +request is made, the Store will first write to the disk cache and then read from the disk cache. ### Disk as Single Source of Truth -Providing `sourceOfTruth` whose `reader` function can return a `Flow` allows you to make Store treat your disk as source of truth. + +Providing `sourceOfTruth` whose `reader` function can return a `Flow` allows you to make +Store treat your disk as source of truth. Any changes made on disk, even if it is not made by Store, will update the active `Store` streams. -This feature, combined with persistence libraries that provide observable queries ([Jetpack Room](https://developer.android.com/jetpack/androidx/releases/room), [SQLDelight](https://github.com/cashapp/sqldelight) or [Realm](https://realm.io/products/realm-database/)) -allows you to create offline first applications that can be used without an active network connection while still providing a great user experience. - - +This feature, combined with persistence libraries that provide observable +queries ([Jetpack Room](https://developer.android.com/jetpack/androidx/releases/room) +, [SQLDelight](https://github.com/cashapp/sqldelight) +or [Realm](https://realm.io/products/realm-database/)) +allows you to create offline first applications that can be used without an active network +connection while still providing a great user experience. ```kotlin StoreBuilder - .from( - fetcher = Fetcher.of { api.fetchSubreddit(it, "10").data.children.map(::toPosts) }, - sourceOfTruth = SourceOfTruth.of( - reader = db.postDao()::loadPosts, - writer = db.postDao()::insertPosts, - delete = db.postDao()::clearFeed, - deleteAll = db.postDao()::clearAllFeeds - ) - ).build() + .from( + fetcher = Fetcher.of { api.fetchSubreddit(it, "10").data.children.map(::toPosts) }, + sourceOfTruth = SourceOfTruth.of( + reader = db.postDao()::loadPosts, + writer = db.postDao()::insertPosts, + delete = db.postDao()::clearFeed, + deleteAll = db.postDao()::clearAllFeeds + ) + ).build() ``` -Stores don’t care how you’re storing or retrieving your data from disk. As a result, you can use Stores with object storage or any database (Realm, SQLite, CouchDB, Firebase etc). Technically, there is nothing stopping you from implementing an in-memory cache for the "sourceOfTruth" implementation and instead have two levels of in-memory caching--one with inflated and one with deflated models, allowing for sharing of the “sourceOfTruth” cache data between stores. +Stores don’t care how you’re storing or retrieving your data from disk. As a result, you can use +Stores with object storage or any database (Realm, SQLite, CouchDB, Firebase etc). Technically, +there is nothing stopping you from implementing an in-memory cache for the "sourceOfTruth" +implementation and instead have two levels of in-memory caching--one with inflated and one with +deflated models, allowing for sharing of the “sourceOfTruth” cache data between stores. -If using SQLite we recommend working with [Room](https://developer.android.com/topic/libraries/architecture/room) which returns a `Flow` from a query +If using SQLite we recommend working +with [Room](https://developer.android.com/topic/libraries/architecture/room) which returns a `Flow` +from a query The above builder is how we recommend working with data on Android. With the above setup you have: + + Memory caching with TTL & Size policies + Disk caching with simple integration with Room + In-flight request management @@ -214,25 +272,30 @@ You can configure in-memory cache with the `MemoryPolicy`: ```kotlin StoreBuilder - .from( - fetcher = Fetcher.of { api.fetchSubreddit(it, "10").data.children.map(::toPosts) }, - sourceOfTruth = SourceOfTruth.of( - reader = db.postDao()::loadPosts, - writer = db.postDao()::insertPosts, - delete = db.postDao()::clearFeed, - deleteAll = db.postDao()::clearAllFeeds - ) - ).cachePolicy( - MemoryPolicy.builder() - .setMaxSize(10) - .setExpireAfterAccess(10.minutes) // or setExpireAfterWrite(10.minutes) - .build() - ).build() + .from( + fetcher = Fetcher.of { api.fetchSubreddit(it, "10").data.children.map(::toPosts) }, + sourceOfTruth = SourceOfTruth.of( + reader = db.postDao()::loadPosts, + writer = db.postDao()::insertPosts, + delete = db.postDao()::clearFeed, + deleteAll = db.postDao()::clearAllFeeds + ) + ).cachePolicy( + MemoryPolicy.builder() + .setMaxSize(10) + .setExpireAfterAccess(10.minutes) // or setExpireAfterWrite(10.minutes) + .build() + ).build() ``` -* `setMaxSize(maxSize: Long)` sets the maximum number of entries to be kept in the cache before starting to evict the least recently used items. -* `setExpireAfterAccess(expireAfterAccess: Duration)` sets the maximum time an entry can live in the cache since the last access, where "access" means reading the cache, adding a new cache entry, and replacing an existing entry with a new one. This duration is also known as **time-to-idle (TTI)**. -* `setExpireAfterWrite(expireAfterWrite: Duration)` sets the maximum time an entry can live in the cache since the last write, where "write" means adding a new cache entry and replacing an existing entry with a new one. This duration is also known as **time-to-live (TTL)**. +* `setMaxSize(maxSize: Long)` sets the maximum number of entries to be kept in the cache before + starting to evict the least recently used items. +* `setExpireAfterAccess(expireAfterAccess: Duration)` sets the maximum time an entry can live in the + cache since the last access, where "access" means reading the cache, adding a new cache entry, and + replacing an existing entry with a new one. This duration is also known as **time-to-idle (TTI)**. +* `setExpireAfterWrite(expireAfterWrite: Duration)` sets the maximum time an entry can live in the + cache since the last write, where "write" means adding a new cache entry and replacing an existing + entry with a new one. This duration is also known as **time-to-live (TTL)**. Note that `setExpireAfterAccess` and `setExpireAfterWrite` **cannot** both be set at the same time. @@ -247,7 +310,7 @@ val store = StoreBuilder .from( fetcher = Fetcher.of { key: String -> api.fetchData(key) - }).build() + }).build() ``` The following will clear the entry associated with the key from the in-memory cache: @@ -264,22 +327,24 @@ store.clearAll() #### Store with sourceOfTruth -When store has a sourceOfTruth, you'll need to provide the `delete` and `deleteAll` functions for `clear(key)` and `clearAll()` to work: +When store has a sourceOfTruth, you'll need to provide the `delete` and `deleteAll` functions +for `clear(key)` and `clearAll()` to work: ```kotlin StoreBuilder - .from( - fetcher = Fetcher.of { api.fetchData(key) }, - sourceOfTruth = SourceOfTruth.of( - reader = dao::loadData, - writer = dao::writeData, - delete = dao::clearDataByKey, - deleteAll = dao::clearAllData - ) - ).build() + .from( + fetcher = Fetcher.of { api.fetchData(key) }, + sourceOfTruth = SourceOfTruth.of( + reader = dao::loadData, + writer = dao::writeData, + delete = dao::clearDataByKey, + deleteAll = dao::clearAllData + ) + ).build() ``` -The following will clear the entry associated with the key from both the in-memory cache and the sourceOfTruth: +The following will clear the entry associated with the key from both the in-memory cache and the +sourceOfTruth: ```kotlin store.clear("myKey") @@ -293,16 +358,9 @@ store.clearAll() ## License - Copyright (c) 2021 Dropbox, Inc. +```text +Copyright (c) 2022 Mobile Native Foundation. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +``` - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/RELEASING.md b/RELEASING.md index 45ad477..4aafdcb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,23 +1,28 @@ Releasing ======== - 1. Change the version in top level `gradle.properties` to a non-SNAPSHOT verson. - 2. Update the `CHANGELOG.md` for the impending release. - 3. Update the `README.md` with the new version. - 4. `git commit -am "Prepare for release X.Y.Z."` (where X.Y.Z is the new version) - 5. `git tag -a X.Y.X -m "Version X.Y.Z"` (where X.Y.Z is the new version) +1. Change the version in top level `gradle.properties` to a non-SNAPSHOT version. +2. Update the `cocoapods` version in `build.gradle.kts` in `:store`. +3. Modify `create_swift_package.yml` workflow to run on `store5` push. + * https://github.com/MobileNativeFoundation/Store/blob/e526400cdf51aa2f78b6b7e9e87f4a6845e6dcea/.github/workflows/create_swift_package.yml +4. Update the `CHANGELOG.md` for the impending release. +5. Update the `README.md` with the new version. +6. `git commit -sam "Prepare for release X.Y.Z."` (where X.Y.Z is the new version) +7. `git tag -a X.Y.X -m "Version X.Y.Z"` (where X.Y.Z is the new version) * Run `git tag` to verify it. - 6. `git push && git push --tags` +8. `git push && git push --tags` * This should be pushed to your fork. - 7. Create a PR with this commit and merge it. - 8. Update the top level `build.gradle` to the next SNAPSHOT version. - 9. `git commit -am "Prepare next development version."` - 10. Create a PR with this commit and merge it. - 11. Login to Sonatype to promote the artifacts https://central.sonatype.org/pages/releasing-the-deployment.html - * This part is automated. If it fails in CI, follow the steps below. - * Click on Staging Repositories under Build Promotion - * Select all the Repositories that contain the content you want to release - * Click on Close and refresh until the Release button is active - * Click Release and submit - 12. Update the sample module's `build.gradle` to point to the newly released version. (It may take ~2 hours for artifact to be available after release) +9. Create a PR with this commit and merge it. +10. Update the top level `build.gradle` to the next SNAPSHOT version. +11. Modify `create_swift_package.yml` workflow to only run manually. + * https://github.com/MobileNativeFoundation/Store/blob/de9ed1764408eeaafe5e58fe602205c875a8b0b0/.github/workflows/create_swift_package.yml +12. `git commit -am "Prepare next development version."` +13. Create a PR with this commit and merge it. +14. Login to Sonatype to promote the artifacts https://central.sonatype.org/pages/releasing-the-deployment.html + * This part is automated. If it fails in CI, follow the steps below. + * Click on Staging Repositories under Build Promotion + * Select all the Repositories that contain the content you want to release + * Click on Close and refresh until the Release button is active + * Click Release and submit +15. Update the sample module's `build.gradle` to point to the newly released version. (It may take ~2 hours for artifact to be available after release) \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index 796b96d..0000000 --- a/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 04ebba6..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,79 +0,0 @@ -plugins { - id 'com.android.application' - id 'kotlin-android' - id 'kotlin-kapt' - id 'org.jetbrains.kotlin.plugin.serialization' -} - -android { - compileSdkVersion versions.compileSdk - buildToolsVersion versions.buildTools - defaultConfig { - applicationId "com.dropbox.android.store.sample" - minSdkVersion 19 - compileSdkVersion versions.compileSdk - targetSdkVersion versions.targetSdk - versionCode 1 - versionName "1.0" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - multiDexEnabled true - } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - lint { - abortOnError false - disable 'InvalidPackage' - } - namespace 'com.dropbox.android.sample' -} - -tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) { - kotlinOptions { - jvmTarget = "1.8" - freeCompilerArgs += [ - '-Xopt-in=kotlin.Experimental', - ] - } -} - -dependencies { - - testImplementation libraries.junit - testImplementation libraries.mockito - testImplementation libraries.coroutinesTest - implementation libraries.recyclerView - implementation libraries.swipeRefreshLayout - implementation libraries.appCompat - implementation libraries.fragment - implementation libraries.lifecycle - implementation libraries.material - implementation libraries.retrofit - implementation libraries.roomRuntime - implementation libraries.coreKtx - implementation libraries.lifecycleExtensions - implementation libraries.navigationFragment - implementation libraries.navigationUi - implementation libraries.constraintLayout - - implementation libraries.cache - implementation libraries.kotlinSerialization - implementation libraries.okHttp - implementation libraries.picasso - implementation libraries.retrofitSerializerConverter - kapt(libraries.roomCompiler) - implementation libraries.store - implementation libraries.filesystem - - implementation libraries.coroutinesCore - implementation libraries.coroutinesAndroid - - debugImplementation libraries.leakcanary -} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro deleted file mode 100644 index a565a62..0000000 --- a/app/proguard-rules.pro +++ /dev/null @@ -1,17 +0,0 @@ -# Add project specific ProGuard rules here. -# By default, the flags in this file are appended to flags specified -# in /Users/206847/Library/Android/sdk/tools/proguard/proguard-android.txt -# You can edit the include path and order by changing the proguardFiles -# directive in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# Add any project specific keep options here: - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml deleted file mode 100644 index f4df580..0000000 --- a/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/java/com/dropbox/android/sample/Graph.kt b/app/src/main/java/com/dropbox/android/sample/Graph.kt deleted file mode 100644 index 9734584..0000000 --- a/app/src/main/java/com/dropbox/android/sample/Graph.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.dropbox.android.sample - -import android.content.Context -import android.text.Html -import androidx.room.Room -import com.dropbox.android.external.fs3.FileSystemPersister -import com.dropbox.android.external.fs3.PathResolver -import com.dropbox.android.external.fs3.Persister -import com.dropbox.android.external.fs3.SourcePersisterFactory -import com.dropbox.android.external.fs3.filesystem.FileSystemFactory -import com.dropbox.android.external.store4.Fetcher -import com.dropbox.android.external.store4.MemoryPolicy -import com.dropbox.android.external.store4.SourceOfTruth -import com.dropbox.android.external.store4.Store -import com.dropbox.android.external.store4.StoreBuilder -import com.dropbox.android.sample.data.model.Children -import com.dropbox.android.sample.data.model.Post -import com.dropbox.android.sample.data.model.RedditDb -import com.dropbox.android.sample.data.remote.Api -import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okio.Buffer -import okio.BufferedSource -import retrofit2.Retrofit -import java.io.File -import java.io.IOException -import java.nio.charset.StandardCharsets -import kotlin.time.ExperimentalTime -import kotlin.time.seconds - -@OptIn( - ExperimentalTime::class, - ExperimentalStdlibApi::class -) -object Graph { - private val serializer = Json { ignoreUnknownKeys = true } - - fun provideRoomStore(context: SampleApp): Store> { - val db = provideRoom(context) - return StoreBuilder - .from( - Fetcher.of { key: String -> - provideRetrofit().fetchSubreddit(key, 10).data.children.map(::toPosts) - }, - sourceOfTruth = SourceOfTruth.of( - reader = db.postDao()::loadPosts, - writer = db.postDao()::insertPosts, - delete = db.postDao()::clearFeedBySubredditName, - deleteAll = db.postDao()::clearAllFeeds - ) - ) - .build() - } - - fun provideRoomStoreMultiParam(context: SampleApp): Store, List> { - val db = provideRoom(context) - return StoreBuilder - .from, List, List>( - Fetcher.of { (query, config) -> - provideRetrofit().fetchSubreddit(query, config.limit) - .data.children.map(::toPosts) - }, - sourceOfTruth = SourceOfTruth.of( - reader = { (query, _) -> db.postDao().loadPosts(query) }, - writer = { (query, _), posts -> db.postDao().insertPosts(query, posts) }, - delete = { (query, _) -> db.postDao().clearFeedBySubredditName(query) }, - deleteAll = db.postDao()::clearAllFeeds - ) - ) - .build() - } - - private fun provideRoom(context: SampleApp): RedditDb { - return Room.inMemoryDatabaseBuilder(context, RedditDb::class.java) - .build() - } - - /** - * Returns a new Persister with the cache as the root. - */ - @Throws(IOException::class) - fun newPersister(cacheDir: File): Persister> { - return SourcePersisterFactory.create(cacheDir) - } - - fun provideConfigStore(context: Context): Store { - val fileSystem = FileSystemFactory.create(context.cacheDir) - val fileSystemPersister = - FileSystemPersister.create( - fileSystem, - object : PathResolver { - override fun resolve(key: Unit) = "config.json" - } - ) - return StoreBuilder - .from( - Fetcher.of { - delay(500) - RedditConfig(10) - }, - sourceOfTruth = SourceOfTruth.of( - nonFlowReader = { - runCatching { - fileSystemPersister.read(Unit) - ?.readString(StandardCharsets.UTF_8) - ?.let { Json.decodeFromString(it) } - }.getOrNull() - }, - writer = { _, config -> - val buffer = Buffer() - withContext(Dispatchers.IO) { - buffer.writeUtf8(Json.encodeToString(config)) - } - fileSystemPersister.write(Unit, buffer) - } - ) - ) - .cachePolicy( - MemoryPolicy.builder().setExpireAfterWrite(10.seconds).build() - ) - .build() - } - - private fun provideRetrofit(): Api { - return Retrofit.Builder() - .baseUrl("https://reddit.com/") - .addConverterFactory(serializer.asConverterFactory(contentType = "application/json".toMediaType())) - .build() - .create(Api::class.java) - } - - private fun toPosts(it: Children): Post { - return it.data.copy( - preview = it.data.preview?.let { - it.copy( - images = it.images.map { image -> - @Suppress("DEPRECATION") - image.copy( - source = image.source.copy( - // preview urls are html encoded, need to escape - url = Html.fromHtml(image.source.url).toString() - ) - ) - } - ) - } - ) - } -} diff --git a/app/src/main/java/com/dropbox/android/sample/MainActivity.kt b/app/src/main/java/com/dropbox/android/sample/MainActivity.kt deleted file mode 100644 index c641393..0000000 --- a/app/src/main/java/com/dropbox/android/sample/MainActivity.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.dropbox.android.sample - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.setupWithNavController -import com.google.android.material.bottomnavigation.BottomNavigationView - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - - val navHostFragment = supportFragmentManager.findFragmentById(R.id.mainNavHostFragment) as NavHostFragment - val navController = navHostFragment.navController - - // Setup bottom navigation with navigation controller - findViewById(R.id.bottomNavigationView).setupWithNavController(navController) - } -} diff --git a/app/src/main/java/com/dropbox/android/sample/RedditConfig.kt b/app/src/main/java/com/dropbox/android/sample/RedditConfig.kt deleted file mode 100644 index b14e969..0000000 --- a/app/src/main/java/com/dropbox/android/sample/RedditConfig.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.dropbox.android.sample - -import kotlinx.serialization.Serializable - -@Serializable -data class RedditConfig(val limit: Int) diff --git a/app/src/main/java/com/dropbox/android/sample/SampleApp.kt b/app/src/main/java/com/dropbox/android/sample/SampleApp.kt deleted file mode 100644 index 28b221f..0000000 --- a/app/src/main/java/com/dropbox/android/sample/SampleApp.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.dropbox.android.sample - -import android.app.Application -import com.dropbox.android.external.fs3.Persister -import com.dropbox.android.external.store4.Store -import com.dropbox.android.sample.data.model.Post -import okio.BufferedSource -import java.io.IOException - -class SampleApp : Application() { - lateinit var roomStore: Store> - - lateinit var storeMultiParam: Store, List> - - lateinit var configStore: Store - - lateinit var persister: Persister> - - override fun onCreate() { - super.onCreate() - initPersister() - roomStore = Graph.provideRoomStore(this) - storeMultiParam = Graph.provideRoomStoreMultiParam(this) - configStore = Graph.provideConfigStore(this) - } - - private fun initPersister() { - try { - persister = Graph.newPersister(this.cacheDir) - } catch (exception: IOException) { - throw RuntimeException(exception) - } - } -} diff --git a/app/src/main/java/com/dropbox/android/sample/data/model/DbModel.kt b/app/src/main/java/com/dropbox/android/sample/data/model/DbModel.kt deleted file mode 100644 index d55fd47..0000000 --- a/app/src/main/java/com/dropbox/android/sample/data/model/DbModel.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.dropbox.android.sample.data.model - -import androidx.room.Dao -import androidx.room.Database -import androidx.room.Embedded -import androidx.room.Entity -import androidx.room.Index -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.RoomDatabase -import androidx.room.Transaction -import androidx.room.TypeConverter -import androidx.room.TypeConverters -import kotlinx.coroutines.flow.Flow -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -/** - * Keeps the association between a Post and a feed - */ -@Entity( - primaryKeys = ["subredditName", "postOrder", "postId"], - indices = [Index("postId", unique = false)] -) -data class FeedEntity( - val subredditName: String, - val postOrder: Int, - val postId: String -) - -// this wrapper usually doesn't make sense but we do here to avoid leaking room into the Model file -@Entity( - primaryKeys = ["id"] -) -data class PostEntity( - @Embedded - val post: Post -) - -class RedditTypeConverters { - @TypeConverter - fun previewToString(preview: Preview?) = preview?.let { - Json.encodeToString(preview) - } - - @TypeConverter - fun stringToPreview(preview: String?) = preview?.let { - Json.decodeFromString(preview) - } -} - -@Dao -abstract class PostDao { - @Transaction - open suspend fun insertPosts(subredditName: String, posts: List) { - // first clear the feed - clearFeedBySubredditName(subredditName) - // convert them into database models - val feedEntities = posts.mapIndexed { index: Int, post: Post -> - FeedEntity( - subredditName = subredditName, - postOrder = index, - postId = post.id - ) - } - val postEntities = posts.map { - PostEntity(it) - } - // save them into the database - insertPosts(feedEntities, postEntities) - // delete posts that are not part of any feed - clearObseletePosts() - } - - @Query("DELETE FROM FeedEntity WHERE subredditName = :subredditName") - abstract suspend fun clearFeedBySubredditName(subredditName: String) - - @Query("DELETE FROM FeedEntity") - abstract suspend fun clearAllFeeds() - - @Insert(onConflict = OnConflictStrategy.REPLACE) - protected abstract suspend fun insertPosts( - feedEntries: List, - posts: List - ) - - @Query("DELETE FROM PostEntity WHERE id NOT IN (SELECT DISTINCT(postId) FROM FeedEntity)") - protected abstract suspend fun clearObseletePosts() - - @Query( - """ - SELECT PostEntity.* FROM FeedEntity - LEFT JOIN PostEntity ON FeedEntity.postId = PostEntity.id - WHERE subredditName = :subredditName - ORDER BY FeedEntity.postOrder ASC - """ - ) - abstract fun loadPosts(subredditName: String): Flow> -} - -@Database( - version = 1, - exportSchema = false, - entities = [PostEntity::class, FeedEntity::class] -) -@TypeConverters(RedditTypeConverters::class) -abstract class RedditDb : RoomDatabase() { - abstract fun postDao(): PostDao -} diff --git a/app/src/main/java/com/dropbox/android/sample/data/model/Model.kt b/app/src/main/java/com/dropbox/android/sample/data/model/Model.kt deleted file mode 100644 index 2e5f4c5..0000000 --- a/app/src/main/java/com/dropbox/android/sample/data/model/Model.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.dropbox.android.sample.data.model - -import kotlinx.serialization.Serializable - -@Serializable -data class RedditData( - val data: Data, - val kind: String -) - -@Serializable -data class Children( - val data: Post -) - -@Serializable -data class Data( - val children: List -) - -@Serializable -data class Post( - val id: String, - val preview: Preview? = null, - val title: String, - val url: String, - val height: Int? = null, - val width: Int? = null, -) { - fun nestedThumbnail(): Image? { - return preview?.images?.getOrNull(0)?.source - } -} - -@Serializable -data class Preview( - val images: List -) - -@Serializable -data class Images( - val source: Image -) - -@Serializable -data class Image( - val url: String, - val height: Int, - val width: Int -) diff --git a/app/src/main/java/com/dropbox/android/sample/data/remote/Api.kt b/app/src/main/java/com/dropbox/android/sample/data/remote/Api.kt deleted file mode 100644 index 2dda439..0000000 --- a/app/src/main/java/com/dropbox/android/sample/data/remote/Api.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.dropbox.android.sample.data.remote - -import com.dropbox.android.sample.data.model.RedditData -import okhttp3.ResponseBody -import retrofit2.http.GET -import retrofit2.http.Path -import retrofit2.http.Query - -interface Api { - - @GET("r/{subredditName}/new/.json") - suspend fun fetchSubreddit( - @Path("subredditName") subredditName: String, - @Query("limit") limit: Int - ): RedditData - - @GET("r/{subredditName}/new/.json") - suspend fun fetchSubredditForPersister( - @Path("subredditName") subredditName: String, - @Query("limit") limit: Int - ): ResponseBody -} diff --git a/app/src/main/java/com/dropbox/android/sample/reddit/PostAdapter.kt b/app/src/main/java/com/dropbox/android/sample/reddit/PostAdapter.kt deleted file mode 100644 index f30596d..0000000 --- a/app/src/main/java/com/dropbox/android/sample/reddit/PostAdapter.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.dropbox.android.sample.reddit - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.dropbox.android.sample.R -import com.dropbox.android.sample.data.model.Post - -class PostAdapter : ListAdapter(PostDiffCallback) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder { - val itemView = LayoutInflater.from( - parent.context - ).inflate(R.layout.article_item, parent, false) - return PostViewHolder(itemView) - } - - override fun onBindViewHolder(holder: PostViewHolder, position: Int) { - holder.onBind(getItem(position)) - } -} - -private object PostDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Post, newItem: Post): Boolean { - return oldItem.url == newItem.url - } - - override fun areContentsTheSame(oldItem: Post, newItem: Post): Boolean { - return oldItem == newItem - } -} diff --git a/app/src/main/java/com/dropbox/android/sample/reddit/PostViewHolder.kt b/app/src/main/java/com/dropbox/android/sample/reddit/PostViewHolder.kt deleted file mode 100644 index 02f5cb9..0000000 --- a/app/src/main/java/com/dropbox/android/sample/reddit/PostViewHolder.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.dropbox.android.sample.reddit - -import android.view.View -import android.widget.ImageView -import android.widget.TextView -import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import com.dropbox.android.sample.R -import com.dropbox.android.sample.data.model.Post -import com.squareup.picasso.Picasso - -class PostViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - - private val title: TextView get() = itemView.findViewById(R.id.title) - - private val thumbnail: ImageView get() = itemView.findViewById(R.id.thumbnail) - - fun onBind(article: Post) { - title.text = article.title - val url = article.nestedThumbnail()?.url - thumbnail.isVisible = url != null - url?.let { showImage(it) } - } - - private fun showImage(url: String) { - Picasso.with(itemView.context) - .load(url) - .placeholder(R.color.gray80) - .into(thumbnail) - } -} diff --git a/app/src/main/java/com/dropbox/android/sample/ui/reddit/RedditFragment.kt b/app/src/main/java/com/dropbox/android/sample/ui/reddit/RedditFragment.kt deleted file mode 100644 index e266dcc..0000000 --- a/app/src/main/java/com/dropbox/android/sample/ui/reddit/RedditFragment.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.dropbox.android.sample.ui.reddit - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.EditText -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Observer -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.dropbox.android.sample.R -import com.dropbox.android.sample.data.model.Post -import com.dropbox.android.sample.reddit.PostAdapter -import com.dropbox.android.sample.utils.Lce -import com.google.android.material.snackbar.Snackbar - -class RedditFragment : Fragment() { - - private val viewModel: RedditViewModel by viewModels() - - private val adapter = PostAdapter() - - private val pullToRefresh: SwipeRefreshLayout get() = requireView().findViewById(R.id.pullToRefresh) - - private val subredditInput: EditText get() = requireView().findViewById(R.id.subredditInput) - - private val fetchButton: Button get() = requireView().findViewById(R.id.fetchButton) - - private val postRecyclerView: RecyclerView get() = requireView().findViewById(R.id.postRecyclerView) - - private val root: CoordinatorLayout get() = requireView().findViewById(R.id.root) - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_room_store, container, false) - } - - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) - - viewModel.liveData.observe( - viewLifecycleOwner, - Observer { lce: Lce> -> - pullToRefresh.isRefreshing = lce is Lce.Loading - when (lce) { - is Lce.Error -> showErrorMessage(lce.message) - is Lce.Success -> updateData(lce.data) - } - } - ) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - pullToRefresh.setOnRefreshListener { - viewModel.refresh(subredditInput.text.toString()) - } - - fetchButton.setOnClickListener { - viewModel.refresh(subredditInput.text.toString()) - } - } - - private fun updateData(data: List) { - // lazily set the adapter when we have data the first time so that RecyclerView can - // restore position - if (postRecyclerView.adapter == null) { - postRecyclerView.adapter = adapter - } - adapter.submitList(data) - } - - private fun showErrorMessage(message: String) { - Snackbar.make(root, message, Snackbar.LENGTH_INDEFINITE).setAction( - "refresh" - ) { - viewModel.refresh(subredditInput.text.toString()) - }.show() - } -} diff --git a/app/src/main/java/com/dropbox/android/sample/ui/reddit/RedditViewModel.kt b/app/src/main/java/com/dropbox/android/sample/ui/reddit/RedditViewModel.kt deleted file mode 100644 index be75362..0000000 --- a/app/src/main/java/com/dropbox/android/sample/ui/reddit/RedditViewModel.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.dropbox.android.sample.ui.reddit - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope -import com.dropbox.android.external.store4.fresh -import com.dropbox.android.external.store4.get -import com.dropbox.android.sample.SampleApp -import com.dropbox.android.sample.data.model.Post -import com.dropbox.android.sample.utils.Lce -import kotlinx.coroutines.launch - -class RedditViewModel( - app: Application -) : AndroidViewModel(app) { - private val store = (app as SampleApp).storeMultiParam - private val configStore = (app as SampleApp).configStore - - val liveData = MutableLiveData>>(Lce.Success(emptyList())) - - fun refresh(key: String) { - liveData.value = Lce.Loading - viewModelScope.launch { - liveData.value = try { - val config = configStore.get(Unit) - val data = store.fresh(key to config) - Lce.Success(data) - } catch (e: Exception) { - Lce.Error(e) - } - } - } -} diff --git a/app/src/main/java/com/dropbox/android/sample/ui/room/RoomFragment.kt b/app/src/main/java/com/dropbox/android/sample/ui/room/RoomFragment.kt deleted file mode 100644 index bdf21f2..0000000 --- a/app/src/main/java/com/dropbox/android/sample/ui/room/RoomFragment.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.dropbox.android.sample.ui.room - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.EditText -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.dropbox.android.external.store4.ResponseOrigin -import com.dropbox.android.external.store4.Store -import com.dropbox.android.external.store4.StoreRequest -import com.dropbox.android.external.store4.StoreResponse -import com.dropbox.android.sample.R -import com.dropbox.android.sample.SampleApp -import com.dropbox.android.sample.reddit.PostAdapter -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.transform -import kotlinx.coroutines.launch - -class RoomFragment : Fragment() { - - private val postRecyclerView: RecyclerView get() = requireView().findViewById(R.id.postRecyclerView) - - private val subredditInput: EditText get() = requireView().findViewById(R.id.subredditInput) - - private val pullToRefresh: SwipeRefreshLayout get() = requireView().findViewById(R.id.pullToRefresh) - - private val root: ConstraintLayout get() = requireView().findViewById(R.id.root) - - private val fetchButton: Button get() = requireView().findViewById(R.id.fetchButton) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_room_store, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - initUI() - } - - private fun initUI() { - val adapter = PostAdapter() - // lazily set the adapter when we have data the first time so that RecyclerView can - // restore position - fun setAdapterIfNotSet() { - if (postRecyclerView.adapter == null) { - postRecyclerView.adapter = adapter - } - } - - val storeState = StoreState((activity?.application as SampleApp).roomStore) - lifecycleScope.launchWhenStarted { - fun refresh() { - launch { - storeState.setKey(subredditInput.text.toString()) - } - } - - pullToRefresh.setOnRefreshListener { - refresh() - } - launch { - storeState.loading.collect { - pullToRefresh.isRefreshing = it - } - } - launch { - storeState.errors.collect { - if (it != "") { - Snackbar.make(root, it, Snackbar.LENGTH_INDEFINITE).setAction( - "refresh" - ) { - refresh() - }.show() - } - } - } - if (subredditInput.text.toString().trim() == "") { - subredditInput.setText("aww") - } - fetchButton.setOnClickListener { - refresh() - } - refresh() - storeState.data.collect { - setAdapterIfNotSet() - adapter.submitList(it) - } - } - } -} - -/** - * This class should possibly be moved to a helper library but needs more API work before that. - */ -internal class StoreState( - private val store: Store -) { - private val keyFlow = Channel(capacity = Channel.CONFLATED) - private val _errors = Channel(capacity = Channel.CONFLATED) - val errors - get() = _errors.consumeAsFlow() - private val _loading = Channel(capacity = Channel.CONFLATED) - val loading - get() = _loading.consumeAsFlow() - - suspend fun setKey(key: Key) { - _errors.send("") - _loading.send(true) - keyFlow.send(key) - } - - val data = keyFlow.consumeAsFlow().flatMapLatest { key -> - store.stream( - StoreRequest.cached( - key = key, - refresh = true - ) - ).onEach { - if (it.origin == ResponseOrigin.Fetcher) { - _loading.send( - it is StoreResponse.Loading - ) - } - when (it) { - is StoreResponse.Error.Exception -> _errors.send(it.error.localizedMessage!!) - is StoreResponse.Error.Message -> _errors.send(it.message) - } - }.transform { - if (it is StoreResponse.Data) { - emit(it.value) - } - } - } -} diff --git a/app/src/main/java/com/dropbox/android/sample/ui/stream/StreamFragment.kt b/app/src/main/java/com/dropbox/android/sample/ui/stream/StreamFragment.kt deleted file mode 100644 index 531dd79..0000000 --- a/app/src/main/java/com/dropbox/android/sample/ui/stream/StreamFragment.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.dropbox.android.sample.ui.stream - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.TextView -import androidx.fragment.app.Fragment -import com.dropbox.android.external.store4.Fetcher -import com.dropbox.android.external.store4.MemoryPolicy -import com.dropbox.android.external.store4.StoreBuilder -import com.dropbox.android.external.store4.StoreRequest -import com.dropbox.android.external.store4.fresh -import com.dropbox.android.external.store4.get -import com.dropbox.android.sample.R -import kotlin.coroutines.CoroutineContext -import kotlin.time.ExperimentalTime -import kotlin.time.seconds -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.flattenMerge -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.launch - -@ExperimentalTime -class StreamFragment : Fragment(), CoroutineScope { - - private val get_1: Button get() = requireView().findViewById(R.id.get_1) - - private val get_2: Button get() = requireView().findViewById(R.id.get_2) - - private val fresh_1: Button get() = requireView().findViewById(R.id.fresh_1) - - private val fresh_2: Button get() = requireView().findViewById(R.id.fresh_2) - - private val stream_1: TextView get() = requireView().findViewById(R.id.stream_1) - - private val stream_2: TextView get() = requireView().findViewById(R.id.stream_2) - - private val stream: TextView get() = requireView().findViewById(R.id.stream) - - override val coroutineContext: CoroutineContext = Job() + Dispatchers.Main - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_stream, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - var counter = 0 - - val store = StoreBuilder - .from( - Fetcher.of { key: Int -> - (key * 1000 + counter++).also { delay(1_000) } - } - ) - .cachePolicy( - MemoryPolicy - .builder() - .setExpireAfterWrite(10.seconds) - .build() - ) - .build() - - get_1.onClick { - store.get(1) - } - - fresh_1.onClick { - store.fresh(1) - } - - get_2.onClick { - store.get(2) - } - - fresh_2.onClick { - store.fresh(2) - } - - launch { - store.stream(StoreRequest.cached(1, refresh = false)).collect { - stream_1.text = "Stream 1 $it" - } - } - launch { - store.stream(StoreRequest.cached(2, refresh = false)).collect { - stream_2.text = "Stream 2 $it" - } - } - launch { - flowOf( - store.stream(StoreRequest.cached(1, refresh = false)), - store.stream(StoreRequest.cached(2, refresh = false)) - ) - .flattenMerge() - .collect { - stream.text = "Stream $it" - } - } - } - - fun View.onClick(f: suspend () -> Unit) { - setOnClickListener { - launch { - f() - } - } - } - - override fun onDestroy() { - super.onDestroy() - coroutineContext.cancel() - } -} diff --git a/app/src/main/java/com/dropbox/android/sample/utils/Lce.kt b/app/src/main/java/com/dropbox/android/sample/utils/Lce.kt deleted file mode 100644 index 568ddbc..0000000 --- a/app/src/main/java/com/dropbox/android/sample/utils/Lce.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.dropbox.android.sample.utils - -sealed class Lce { - - open val data: T? = null - - abstract fun map(f: (T) -> R): Lce - - inline fun doOnData(f: (T) -> Unit) { - if (this is Success) { - f(data) - } - } - - data class Success(override val data: T) : Lce() { - override fun map(f: (T) -> R): Lce = Success(f(data)) - } - - data class Error(val message: String) : Lce() { - constructor(t: Throwable) : this(t.message ?: "") - - override fun map(f: (Nothing) -> R): Lce = this - } - - object Loading : Lce() { - override fun map(f: (Nothing) -> R): Lce = this - } -} diff --git a/app/src/main/res/drawable/ic_distribution.xml b/app/src/main/res/drawable/ic_distribution.xml deleted file mode 100644 index a7e96d5..0000000 --- a/app/src/main/res/drawable/ic_distribution.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_photo.xml b/app/src/main/res/drawable/ic_photo.xml deleted file mode 100644 index 002b5c7..0000000 --- a/app/src/main/res/drawable/ic_photo.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_reddit.xml b/app/src/main/res/drawable/ic_reddit.xml deleted file mode 100644 index bd4e5b7..0000000 --- a/app/src/main/res/drawable/ic_reddit.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index d8a6568..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_store.xml b/app/src/main/res/layout/activity_store.xml deleted file mode 100644 index a80c547..0000000 --- a/app/src/main/res/layout/activity_store.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/article_item.xml b/app/src/main/res/layout/article_item.xml deleted file mode 100644 index 6593d91..0000000 --- a/app/src/main/res/layout/article_item.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_room_store.xml b/app/src/main/res/layout/fragment_room_store.xml deleted file mode 100644 index 394cbf4..0000000 --- a/app/src/main/res/layout/fragment_room_store.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - -