Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1c0da31
Support non exception errors from fetcher
eyalgu Feb 29, 2020
0075ada
revert parital changes to store builder to reduce noise
eyalgu Feb 29, 2020
2fe7b7e
finish off diff
eyalgu Mar 11, 2020
9e3801e
Merge remote-tracking branch 'origin/master' into errors
eyalgu Mar 11, 2020
d886e41
Allow to create a FetcherResult.Error without a Throwable. Add tests
eyalgu Mar 12, 2020
47463e6
Add missing funcion and more tests
eyalgu Mar 16, 2020
7279bdf
lint
eyalgu Mar 16, 2020
cae8faa
unflake RxFlowableStoreTest
eyalgu Mar 16, 2020
cea6e71
try to rename FakeFetcher to FakeRxFetcher to (maybe) solve missing c…
eyalgu Mar 16, 2020
2e1b012
Merge remote-tracking branch 'origin/master' into errors
eyalgu Mar 17, 2020
7bd21db
move SourceOfTruth out of impl package
eyalgu Mar 17, 2020
44a5e15
Merge remote-tracking branch 'origin/master' into errors
eyalgu Mar 18, 2020
03768e6
Rename accidental change of RxStoreBuilder.fromMaybe back to formSingle
eyalgu Mar 18, 2020
81548bb
Introduce Fetcher from #139
eyalgu Apr 21, 2020
3981d37
fix Rx artifact
eyalgu Apr 22, 2020
fb7c64d
Merge remote-tracking branch 'origin/master' into errors
eyalgu Apr 22, 2020
738ed50
delete legacy presistor factory
eyalgu Apr 22, 2020
1527bec
fix api file
eyalgu Apr 22, 2020
cb50d9c
move fetcher to be a typealias
eyalgu Apr 22, 2020
ed9d87f
code review comments + clean up documentation
eyalgu Apr 23, 2020
cff04b4
code review comments
eyalgu Apr 27, 2020
3251fbb
Update store/src/main/java/com/dropbox/android/external/store4/Fetche…
eyalgu Apr 28, 2020
5a2df2e
Merge remote-tracking branch 'origin/master' into errors
eyalgu Apr 29, 2020
a0edfcf
Revert "Update sample app's build.gradle to refer to the externally r…
eyalgu Apr 29, 2020
e883e5d
update releasing.md
eyalgu Apr 29, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 49 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,15 @@ Let's start by looking at what a fully configured Store looks like. We will then

```kotlin
StoreBuilder
.fromNonFlow {
api.fetchSubreddit(it, "10").data.children.map(::toPosts)
}.persister(
reader = db.postDao()::loadPosts,
writer = db.postDao()::insertPosts,
delete = db.postDao()::clearFeed,
deleteAll = db.postDao()::clearAllFeeds
).build()
.from(
fetcher = nonFlowValueFetcher { api.fetchSubreddit(it, "10").data.children.map(::toPosts) },
sourceOfTruth = SourceOfTrue.from(
reader = db.postDao()::loadPosts,
writer = db.postDao()::insertPosts,
delete = db.postDao()::clearFeed,
deleteAll = db.postDao()::clearAllFeeds
)
).build()
```

With the above setup you have:
Expand All @@ -75,12 +76,12 @@ And now for the details:

### Creating a Store

You create a Store using a builder. The only requirement is to include a function that returns a `Flow<ReturnType>` or a `suspend` function that returns a `ReturnType`.
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<FetcherResult<ReturnType>>`.


```kotlin
val store = StoreBuilder
.from { articleId -> api.getArticle(articleId) } // api returns Flow<Article>
.from(valueFetcher { articleId -> api.getArticle(articleId) }) // api returns Flow<Article>
.build()
```

Expand Down Expand Up @@ -126,8 +127,8 @@ lifecycleScope.launchWhenStarted {

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 persister. An error will be thrown if no value is available in either the `cache` or `persister`, 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 `persister`.
* `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 {
Expand All @@ -136,7 +137,7 @@ 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 persister, if provided.
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).
Expand Down Expand Up @@ -171,10 +172,10 @@ To prevent duplicate requests for the same data, Store offers an inflight deboun

### Disk as Cache

Stores can enable disk caching by passing a `Persister` 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 `persister` whose `read` function can return a `Flow<Value>` allows you to make Store treat your disk as source of truth.
Providing `sourceOfTruth` whose `reader` function can return a `Flow<Value?>` 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/))
Expand All @@ -184,16 +185,18 @@ allows you to create offline first applications that can be used without an acti

```kotlin
StoreBuilder
.fromNonFlow {
api.fetchSubreddit(it, "10").data.children.map(::toPosts)
}.persister(
reader = db.postDao()::loadPosts,
writer = db.postDao()::insertPosts,
delete = db.postDao()::clearFeed
).build()
.from(
fetcher = nonFlowValueFetcher { api.fetchSubreddit(it, "10").data.children.map(::toPosts) },
sourceOfTruth = SourceOfTrue.from(
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 “persister” implementation and instead have two levels of in-memory caching--one with inflated and one with deflated models, allowing for sharing of the “persister” 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

Expand All @@ -211,18 +214,19 @@ You can configure in-memory cache with the `MemoryPolicy`:

```kotlin
StoreBuilder
.fromNonFlow {
api.fetchSubreddit(it, "10").data.children.map(::toPosts)
}.cachePolicy(
.from(
fetcher = nonFlowValueFetcher { api.fetchSubreddit(it, "10").data.children.map(::toPosts) },
sourceOfTruth = SourceOfTrue.from(
reader = db.postDao()::loadPosts,
writer = db.postDao()::insertPosts,
delete = db.postDao()::clearFeed,
deleteAll = db.postDao()::clearAllFeeds
)
).cachePolicy(
MemoryPolicy.builder()
.setMemorySize(10)
.setExpireAfterAccess(10.minutes) // or setExpireAfterWrite(10.minutes)
.build()
).persister(
reader = db.postDao()::loadPosts,
writer = db.postDao()::insertPosts,
delete = db.postDao()::clearFeed,
deleteAll = db.postDao()::clearAllFeeds
).build()
```

Expand All @@ -236,7 +240,7 @@ Note that `setExpireAfterAccess` and `setExpireAfterWrite` **cannot** both be se

You can delete a specific entry by key from a store, or clear all entries in a store.

#### Store with no persister
#### Store with no sourceOfTruth

```kotlin
val store = StoreBuilder
Expand All @@ -257,29 +261,30 @@ The following will clear all entries from the in-memory cache:
store.clearAll()
```

#### Store with persister
#### Store with sourceOfTruth

When store has a persister (source of truth), 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
.fromNonFlow<String, Int> { key: String ->
api.fetchData(key)
}.persister(
reader = dao::loadData,
writer = dao::writeData,
delete = dao::clearDataByKey,
deleteAll = dao::clearAllData
).build()
.from(
fetcher = nonFlowValueFetcher { api.fetchData(key) },
sourceOfTruth = SourceOfTrue.from(
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 persister (source of truth):
The following will clear the entry associated with the key from both the in-memory cache and the sourceOfTruth:

```kotlin
store.clear("10")
```

The following will clear all entries from both the in-memory cache and the persister (source of truth):
The following will clear all entries from both the in-memory cache and the sourceOfTruth:

```kotlin
store.clearAll()
Expand Down
17 changes: 11 additions & 6 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ Releasing
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)
8. Update the top level `build.gradle` to the next SNAPSHOT version.
9. `git commit -am "Prepare next development version."`
10. `git push && git push --tags`
11. Create a PR with these 2 commits.
6. Update the top level `build.gradle` to the next SNAPSHOT version.
7. `git commit -am "Prepare next development version."`
8. `git push && git push --tags`
9. Create a PR with these 2 commits.
* **IMPORTANT** Add this comment to your PR "This is a release PR, it must be merged as individual commits. Do not squash commits on merge"
* Longer explanation: we release automatically through Travis CI. When Travis builds on master a script is run to send either a new shapshot or a new release version to Maven. If you squash the commits in the PR, Travis will only see what's left at the end, which is your commit to change back to `SNAPSHOT` release. Thus, Travis will not end up sending a release version to Maven. If you land as multiple commits, Travis will build both and send a release build to Maven for the commit where you bumped the version to a new release version.
11. 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)

If step 5 or 6 fails, drop the Sonatype repo, fix the problem, commit, and start again at step 5.

**Note:** We are currently not pinning the sample app to the last version because the API is still fluid while `Store` is in alpha. We will resume pinning the sample app to a released version when we move to beta (see #159).

When we're ready to pin, restore the final step:

10. 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)

11 changes: 3 additions & 8 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) {
}
}

ext.storeRelease = [
version : '4.0.0-alpha05',
group : 'com.dropbox.mobile.store'
]

dependencies {
implementation "${storeRelease.group}:store4:${storeRelease.version}"
implementation "${storeRelease.group}:cache4:${storeRelease.version}"
implementation "${storeRelease.group}:filesystem4:${storeRelease.version}"

testImplementation libraries.junit
testImplementation libraries.mockito
Expand All @@ -74,6 +66,9 @@ dependencies {
implementation libraries.retrofitMoshiConverter
kapt(libraries.moshiCodegen)
kapt(libraries.roomCompiler)
implementation project(':store')
implementation project(':cache')
implementation project(':filesystem')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$versions.kotlin"

implementation libraries.coroutinesCore
Expand Down
81 changes: 47 additions & 34 deletions app/src/main/java/com/dropbox/android/sample/Graph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import com.dropbox.android.external.store4.StoreBuilder
import com.dropbox.android.external.store4.MemoryPolicy
import com.dropbox.android.external.store4.Persister
import com.dropbox.android.external.store4.Store
import com.dropbox.android.external.store4.SourceOfTruth
import com.dropbox.android.external.store4.legacy.BarCode
import com.dropbox.android.external.store4.nonFlowValueFetcher
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
Expand All @@ -31,36 +33,46 @@ import java.io.IOException
import kotlin.time.ExperimentalTime
import kotlin.time.seconds

@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class, ExperimentalTime::class, ExperimentalStdlibApi::class)
@OptIn(
FlowPreview::class,
ExperimentalCoroutinesApi::class,
ExperimentalTime::class,
ExperimentalStdlibApi::class
)
object Graph {
private val moshi = Moshi.Builder().build()

fun provideRoomStore(context: SampleApp): Store<String, List<Post>> {
val db = provideRoom(context)
return StoreBuilder
.fromNonFlow { key: String ->
provideRetrofit().fetchSubreddit(key, 10).data.children.map(::toPosts)
}
.persister(
reader = db.postDao()::loadPosts,
writer = db.postDao()::insertPosts,
delete = db.postDao()::clearFeedBySubredditName,
deleteAll = db.postDao()::clearAllFeeds
.from(
nonFlowValueFetcher { key: String ->
provideRetrofit().fetchSubreddit(key, 10).data.children.map(::toPosts)
},
sourceOfTruth = SourceOfTruth.from(
reader = db.postDao()::loadPosts,
writer = db.postDao()::insertPosts,
delete = db.postDao()::clearFeedBySubredditName,
deleteAll = db.postDao()::clearAllFeeds
)
)
.build()
}

fun provideRoomStoreMultiParam(context: SampleApp): Store<Pair<String, RedditConfig>, List<Post>> {
val db = provideRoom(context)
return StoreBuilder
.fromNonFlow<Pair<String, RedditConfig>, List<Post>> { (query, config) ->
provideRetrofit().fetchSubreddit(query, config.limit)
.data.children.map(::toPosts)
}
.persister(reader = { (query, _) -> db.postDao().loadPosts(query) },
writer = { (query, _), posts -> db.postDao().insertPosts(query, posts) },
delete = { (query, _) -> db.postDao().clearFeedBySubredditName(query) },
deleteAll = db.postDao()::clearAllFeeds
.from<Pair<String, RedditConfig>, List<Post>, List<Post>>(
nonFlowValueFetcher { (query, config) ->
provideRetrofit().fetchSubreddit(query, config.limit)
.data.children.map(::toPosts)
},
sourceOfTruth = SourceOfTruth.from(
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()
}
Expand All @@ -86,25 +98,26 @@ object Graph {
})
val adapter = moshi.adapter<RedditConfig>(RedditConfig::class.java)
return StoreBuilder
.fromNonFlow<Unit, RedditConfig> {
delay(500)
RedditConfig(10)
}
.nonFlowingPersister(
reader = {
runCatching {
val source = fileSystemPersister.read(Unit)
source?.let { adapter.fromJson(it) }
}.getOrNull()
.from<Unit, RedditConfig, RedditConfig>(
nonFlowValueFetcher {
delay(500)
RedditConfig(10)
},
writer = { _, config ->
val buffer = Buffer()
withContext(Dispatchers.IO) {
adapter.toJson(buffer, config)
sourceOfTruth = SourceOfTruth.fromNonFlow(
reader = {
runCatching {
val source = fileSystemPersister.read(Unit)
source?.let { adapter.fromJson(it) }
}.getOrNull()
},
writer = { _, config ->
val buffer = Buffer()
withContext(Dispatchers.IO) {
adapter.toJson(buffer, config)
}
fileSystemPersister.write(Unit, buffer)
}
fileSystemPersister.write(Unit, buffer)
}
)
))
.cachePolicy(
MemoryPolicy.builder().setExpireAfterWrite(10.seconds).build()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,9 @@ internal class StoreState<Key : Any, Output : Any>(
it is StoreResponse.Loading
)
}
if (it is StoreResponse.Error) {
_errors.send(it.error.localizedMessage!!)
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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.external.store4.nonFlowValueFetcher
import com.dropbox.android.sample.R
import kotlinx.android.synthetic.main.fragment_stream.*
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -50,7 +51,9 @@ class StreamFragment : Fragment(), CoroutineScope {
var counter = 0

val store = StoreBuilder
.fromNonFlow { key: Int -> (key * 1000 + counter++).also { delay(1_000) } }
.from(nonFlowValueFetcher { key: Int ->
(key * 1000 + counter++).also { delay(1_000) }
})
.cachePolicy(
MemoryPolicy
.builder()
Expand Down
Loading