diff --git a/.travis.yml b/.travis.yml index cb0990f0..2d0ff2cc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ android: - tools - platform-tools - build-tools-27.0.3 - - android-25 + - android-27 - extra-android-m2repository licenses: - 'android-sdk-license-.+' @@ -14,6 +14,8 @@ script: - ./gradlew check --stacktrace after_success: - gradle/deploy_snapshot.sh +before_install: +- yes | sdkmanager "platforms;android-27" branches: except: - gh-pages diff --git a/app/build.gradle b/app/build.gradle index eb3599aa..58144657 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' apply plugin: 'com.getkeepsafe.dexcount' - +apply plugin: 'kotlin-kapt' android { compileSdkVersion versions.compileSdk buildToolsVersion versions.buildTools @@ -31,6 +32,7 @@ android { exclude 'META-INF/rxjava.properties' } } +def room_version = "1.1.0" // or, for latest rc, use "1.1.1-rc1" dependencies { @@ -48,9 +50,25 @@ dependencies { annotationProcessor libraries.immutablesValue // <-- for annotation processor compileOnly libraries.immutablesValue // <-- for annotation API compileOnly libraries.immutablesGson // for annotations - implementation 'com.nytimes.android:store3:3.0.1' - implementation 'com.nytimes.android:cache3:3.0.1' - implementation 'com.nytimes.android:middleware3:3.0.1' - implementation 'com.nytimes.android:filesystem3:3.0.1' + implementation project(':store') + implementation project(':cache') + implementation project(':middleware') + implementation project(':filesystem') implementation libraries.rxAndroid2 + compile "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + + implementation "android.arch.persistence.room:runtime:$room_version" + annotationProcessor "android.arch.persistence.room:compiler:$room_version" + kapt "android.arch.persistence.room:compiler:$room_version" + +// androidTestImplementation "android.arch.persistence.room:testing:$room_version" + // optional - RxJava support for Room + implementation "android.arch.persistence.room:rxjava2:$room_version" } +repositories { + mavenCentral() +} + +configurations.all { + resolutionStrategy.force 'com.android.support:support-v4:26.1.0' +} \ No newline at end of file diff --git a/app/src/main/java/com/nytimes/android/sample/SampleApp.java b/app/src/main/java/com/nytimes/android/sample/SampleApp.java deleted file mode 100644 index 08f0dafd..00000000 --- a/app/src/main/java/com/nytimes/android/sample/SampleApp.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.nytimes.android.sample; - -import android.app.Application; -import android.support.annotation.NonNull; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.nytimes.android.external.fs3.SourcePersisterFactory; -import com.nytimes.android.external.store3.base.Persister; -import com.nytimes.android.external.store3.base.impl.BarCode; -import com.nytimes.android.external.store3.base.impl.MemoryPolicy; -import com.nytimes.android.external.store3.base.impl.Store; -import com.nytimes.android.external.store3.base.impl.StoreBuilder; -import com.nytimes.android.external.store3.middleware.GsonParserFactory; -import com.nytimes.android.sample.data.model.GsonAdaptersModel; -import com.nytimes.android.sample.data.model.RedditData; -import com.nytimes.android.sample.data.remote.Api; - -import java.io.IOException; -import java.util.concurrent.TimeUnit; - -import io.reactivex.Single; -import okhttp3.ResponseBody; -import okio.BufferedSource; -import retrofit2.Retrofit; -import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; -import retrofit2.converter.gson.GsonConverterFactory; - -public class SampleApp extends Application { - - private Store nonPersistedStore; - private Store persistedStore; - private Persister persister; - - @Override - public void onCreate() { - super.onCreate(); - - initPersister(); - this.nonPersistedStore = provideRedditStore(); - this.persistedStore = providePersistedRedditStore(); - } - - private void initPersister() { - try { - persister = newPersister(); - } catch (IOException exception) { - throw new RuntimeException(exception); - } - } - - public Store getNonPersistedStore() { - return this.nonPersistedStore; - } - - public Store getPersistedStore() { - return this.persistedStore; - } - - /** - * Provides a Store which only retains RedditData for 10 seconds in memory. - */ - private Store provideRedditStore() { - return StoreBuilder.barcode() - .fetcher(barCode -> provideRetrofit().fetchSubreddit(barCode.getKey(), "10")) - .memoryPolicy( - MemoryPolicy - .builder() - .setExpireAfterWrite(10) - .setExpireAfterTimeUnit(TimeUnit.SECONDS) - .build() - ) - .open(); - } - - /** - * Provides a Store which will persist RedditData to the cache, and use Gson to parse the JSON - * that comes back from the network into RedditData. - */ - private Store providePersistedRedditStore() { - return StoreBuilder.parsedWithKey() - .fetcher(this::fetcher) - .persister(persister) - .parser(GsonParserFactory.createSourceParser(provideGson(), RedditData.class)) - .open(); - } - - /** - * Returns a new Persister with the cache as the root. - */ - private Persister newPersister() throws IOException { - return SourcePersisterFactory.create(getApplicationContext().getCacheDir()); - } - - /** - * Returns a "fetcher" which will retrieve new data from the network. - */ - @NonNull - private Single fetcher(BarCode barCode) { - return provideRetrofit().fetchSubredditForPersister(barCode.getKey(), "10") - .map(ResponseBody::source); - } - - private Api provideRetrofit() { - return new Retrofit.Builder() - .baseUrl("http://reddit.com/") - .addConverterFactory(GsonConverterFactory.create(provideGson())) - .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) - .validateEagerly(BuildConfig.DEBUG) // Fail early: check Retrofit configuration at creation time in Debug build. - .build() - .create(Api.class); - } - - Gson provideGson() { - return new GsonBuilder() - .registerTypeAdapterFactory(new GsonAdaptersModel()) - .create(); - } -} diff --git a/app/src/main/java/com/nytimes/android/sample/SampleApp.kt b/app/src/main/java/com/nytimes/android/sample/SampleApp.kt new file mode 100644 index 00000000..8005d984 --- /dev/null +++ b/app/src/main/java/com/nytimes/android/sample/SampleApp.kt @@ -0,0 +1,133 @@ +package com.nytimes.android.sample + +import android.app.Application +import android.content.Context +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.nytimes.android.external.fs3.SourcePersisterFactory +import com.nytimes.android.external.store3.base.Persister +import com.nytimes.android.external.store3.base.impl.BarCode +import com.nytimes.android.external.store3.base.impl.MemoryPolicy +import com.nytimes.android.external.store3.base.impl.Store +import com.nytimes.android.external.store3.base.impl.StoreBuilder +import com.nytimes.android.external.store3.middleware.GsonParserFactory +import com.nytimes.android.sample.data.model.GsonAdaptersModel +import com.nytimes.android.sample.data.model.RedditData +import com.nytimes.android.sample.data.remote.Api +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import okio.BufferedSource +import retrofit2.Retrofit +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory +import retrofit2.converter.gson.GsonConverterFactory +import java.io.IOException +import java.util.concurrent.TimeUnit + +class SampleApp : Application() { + + var nonPersistedStore: Store? = null + var persistedStore: Store? =null + private var persister: Persister? =null + private val sampleRoomStore=SampleRoomStore(this) + + override fun onCreate() { + super.onCreate() + appContext = this + initPersister(); + nonPersistedStore = provideRedditStore(); + persistedStore=providePersistedRedditStore(); + RoomSample() + } + + private fun RoomSample() { + var foo = sampleRoomStore.store.get("") + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ strings1 -> val success = strings1 != null }) { throwable -> throwable.stackTrace } + + foo = Observable.timer(15, TimeUnit.SECONDS) + .subscribe { makeFetchRequest() } + } + + private fun makeFetchRequest() { + val bar = sampleRoomStore.store.fetch("") + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ strings1 -> val success = strings1 != null }) { throwable -> throwable.stackTrace } + } + + private fun initPersister() { + try { + persister = newPersister() + } catch (exception: IOException) { + throw RuntimeException(exception) + } + + } + + /** + * Provides a Store which only retains RedditData for 10 seconds in memory. + */ + private fun provideRedditStore(): Store { + return StoreBuilder.barcode() + .fetcher { barCode -> provideRetrofit().fetchSubreddit(barCode.key, "10") } + .memoryPolicy( + MemoryPolicy + .builder() + .setExpireAfterWrite(10) + .setExpireAfterTimeUnit(TimeUnit.SECONDS) + .build() + ) + .open() + } + + /** + * Provides a Store which will persist RedditData to the cache, and use Gson to parse the JSON + * that comes back from the network into RedditData. + */ + private fun providePersistedRedditStore(): Store { + return StoreBuilder.parsedWithKey() + .fetcher({ this.fetcher(it) }) + .persister(newPersister()) + .parser(GsonParserFactory.createSourceParser(provideGson(), RedditData::class.java)) + .open() + } + + /** + * Returns a new Persister with the cache as the root. + */ + @Throws(IOException::class) + private fun newPersister(): Persister { + return SourcePersisterFactory.create(this.cacheDir) + } + + /** + * Returns a "fetcher" which will retrieve new data from the network. + */ + private fun fetcher(barCode: BarCode): Single { + return provideRetrofit().fetchSubredditForPersister(barCode.key, "10") + .map({ it.source() }) + } + + private fun provideRetrofit(): Api { + return Retrofit.Builder() + .baseUrl("http://reddit.com/") + .addConverterFactory(GsonConverterFactory.create(provideGson())) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .validateEagerly(BuildConfig.DEBUG) // Fail early: check Retrofit configuration at creation time in Debug build. + .build() + .create(Api::class.java) + } + + internal fun provideGson(): Gson { + return GsonBuilder() + .registerTypeAdapterFactory(GsonAdaptersModel()) + .create() + } + + companion object { + var appContext: Context? = null + } +} diff --git a/app/src/main/java/com/nytimes/android/sample/SampleRoomStore.kt b/app/src/main/java/com/nytimes/android/sample/SampleRoomStore.kt new file mode 100644 index 00000000..f3bf00f9 --- /dev/null +++ b/app/src/main/java/com/nytimes/android/sample/SampleRoomStore.kt @@ -0,0 +1,60 @@ +package com.nytimes.android.sample + +import android.arch.persistence.room.Dao +import android.arch.persistence.room.Database +import android.arch.persistence.room.Entity +import android.arch.persistence.room.Insert +import android.arch.persistence.room.PrimaryKey +import android.arch.persistence.room.Query +import android.arch.persistence.room.Room +import android.arch.persistence.room.RoomDatabase +import android.content.Context +import com.nytimes.android.external.store3.base.Fetcher +import com.nytimes.android.external.store3.base.impl.room.StoreRoom +import com.nytimes.android.external.store3.base.room.RoomPersister +import io.reactivex.Flowable +import io.reactivex.Observable +import io.reactivex.Single + +@Entity +data class User( + @PrimaryKey(autoGenerate = true) + var uid: Int = 0, + val name: String) + +@Dao +interface UserDao { + @Query("SELECT name FROM user") + fun loadAll(): Flowable> + + @Insert + fun insertAll(user: User) + +} + +@Database(entities = arrayOf(User::class), version = 1) +abstract class AppDatabase : RoomDatabase() { + abstract fun userDao(): UserDao +} + +class SampleRoomStore(context: Context){ + val db = Room.databaseBuilder(context, AppDatabase::class.java, "db").build() + + val fetcher = Fetcher { Single.just(User(name = "Mike")) } + val persister = object : RoomPersister, String> { + + override fun read(key: String): Observable> { + return db.userDao().loadAll().toObservable() + } + + override fun write(key: String, user: User) { + db.userDao().insertAll(user) + } + } + + val store = StoreRoom.from(fetcher, persister) +} + + + + diff --git a/build.gradle b/build.gradle index 5fe5f11e..9fc42603 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ apply from: 'buildsystem/dependencies.gradle' // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { + ext.kotlin_version = '1.2.41' repositories { mavenLocal() maven { @@ -22,12 +23,13 @@ buildscript { ] dependencies { - classpath 'com.android.tools.build:gradle:3.1.2' + classpath 'com.android.tools.build:gradle:3.2.0-alpha15' classpath 'com.google.gms:google-services:3.0.0' classpath 'com.getkeepsafe.dexcount:dexcount-gradle-plugin:0.5.6' classpath 'net.ltgt.gradle:gradle-errorprone-plugin:0.0.11' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$rootProject.ext.versions.kotlin" classpath 'org.jetbrains.dokka:dokka-gradle-plugin:0.9.14' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -54,7 +56,7 @@ allprojects { ext { // POM file GROUP = "com.nytimes.android" - VERSION_NAME = "3.0.2-SNAPSHOT" + VERSION_NAME = "3.1.0-SNAPSHOT" POM_PACKAGING = "pom" POM_DESCRIPTION = "Store3 is built with RxJava2" diff --git a/buildsystem/dependencies.gradle b/buildsystem/dependencies.gradle index bc4fa42a..f10c12f5 100644 --- a/buildsystem/dependencies.gradle +++ b/buildsystem/dependencies.gradle @@ -15,13 +15,13 @@ allprojects { ext.versions = [ minSdk : 16, - targetSdk : 25, - compileSdk : 25, + targetSdk : 27, + compileSdk : 27, buildTools : '27.0.3', kotlin : '1.1.2-5', // UI libs. - supportLibs : '25.1.1', + supportLibs : '27.1.0', picasso : '2.5.2', butterKnife : '7.0.1', diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0fcbb13d..b5e6f608 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Thu Mar 29 16:46:38 EEST 2018 +#Thu May 24 12:01:07 EDT 2018 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip diff --git a/store/gradle.properties b/store/gradle.properties index 6e7128dd..edef291d 100644 --- a/store/gradle.properties +++ b/store/gradle.properties @@ -1,3 +1,4 @@ POM_NAME=com.nytimes.android POM_ARTIFACT_ID=store3 POM_PACKAGING=aar +android.enableAapt2=false \ No newline at end of file diff --git a/store/src/main/java/com/nytimes/android/external/store3/base/BasePersister.java b/store/src/main/java/com/nytimes/android/external/store3/base/BasePersister.java new file mode 100644 index 00000000..89bfd866 --- /dev/null +++ b/store/src/main/java/com/nytimes/android/external/store3/base/BasePersister.java @@ -0,0 +1,8 @@ +package com.nytimes.android.external.store3.base; + + + + +public interface BasePersister { + +} diff --git a/store/src/main/java/com/nytimes/android/external/store3/base/Persister.java b/store/src/main/java/com/nytimes/android/external/store3/base/Persister.java index d8274943..e404b991 100644 --- a/store/src/main/java/com/nytimes/android/external/store3/base/Persister.java +++ b/store/src/main/java/com/nytimes/android/external/store3/base/Persister.java @@ -11,12 +11,12 @@ * * @param data type before parsing */ -public interface Persister extends DiskRead, DiskWrite { +public interface Persister extends DiskRead, DiskWrite, BasePersister { /** * @param key to use to get data from persister - * If data is not available implementer needs to - * either return Observable.empty or throw an exception + * If data is not available implementer needs to + * either return Observable.empty or throw an exception */ @Override @Nonnull @@ -24,7 +24,7 @@ public interface Persister extends DiskRead, DiskWrite Cache> createCache(MemoryPolicy memoryPolicy) { - if (memoryPolicy == null) { - return CacheBuilder - .newBuilder() - .maximumSize(StoreDefaults.getCacheSize()) - .expireAfterWrite(StoreDefaults.getCacheTTL(), StoreDefaults.getCacheTTLTimeUnit()) - .build(); - } else { - if (memoryPolicy.getExpireAfterAccess() == memoryPolicy.DEFAULT_POLICY) { - return CacheBuilder - .newBuilder() - .maximumSize(memoryPolicy.getMaxSize()) - .expireAfterWrite(memoryPolicy.getExpireAfterWrite(), memoryPolicy.getExpireAfterTimeUnit()) - .build(); - } else { - return CacheBuilder - .newBuilder() - .maximumSize(memoryPolicy.getMaxSize()) - .expireAfterAccess(memoryPolicy.getExpireAfterAccess(), memoryPolicy.getExpireAfterTimeUnit()) - .build(); - } - } + return createBaseCache(memoryPolicy); } static Cache> createInflighter(MemoryPolicy memoryPolicy) { + return createBaseInFlighter(memoryPolicy); + } + + public static Cache> createRoomCache(MemoryPolicy memoryPolicy) { + return createBaseCache(memoryPolicy); + } + + + + public static Cache> createRoomInflighter(MemoryPolicy memoryPolicy) { + return createBaseInFlighter(memoryPolicy); + } + + + private static Cache createBaseInFlighter(MemoryPolicy memoryPolicy) { long expireAfterToSeconds = memoryPolicy == null ? StoreDefaults.getCacheTTLTimeUnit() .toSeconds(StoreDefaults.getCacheTTL()) : memoryPolicy.getExpireAfterTimeUnit().toSeconds(memoryPolicy.getExpireAfterWrite()); @@ -58,4 +54,30 @@ static Cache> createInflighter(MemoryPolicy me .build(); } } + + + private static Cache createBaseCache(MemoryPolicy memoryPolicy){ + if (memoryPolicy == null) { + return CacheBuilder + .newBuilder() + .maximumSize(StoreDefaults.getCacheSize()) + .expireAfterWrite(StoreDefaults.getCacheTTL(), StoreDefaults.getCacheTTLTimeUnit()) + .build(); + } else { + if (memoryPolicy.getExpireAfterAccess() == memoryPolicy.DEFAULT_POLICY) { + return CacheBuilder + .newBuilder() + .maximumSize(memoryPolicy.getMaxSize()) + .expireAfterWrite(memoryPolicy.getExpireAfterWrite(), memoryPolicy.getExpireAfterTimeUnit()) + .build(); + } else { + return CacheBuilder + .newBuilder() + .maximumSize(memoryPolicy.getMaxSize()) + .expireAfterAccess(memoryPolicy.getExpireAfterAccess(), memoryPolicy.getExpireAfterTimeUnit()) + .build(); + } + } + } + } diff --git a/store/src/main/java/com/nytimes/android/external/store3/base/impl/StoreUtil.java b/store/src/main/java/com/nytimes/android/external/store3/base/impl/StoreUtil.java index b03801d1..639827d0 100644 --- a/store/src/main/java/com/nytimes/android/external/store3/base/impl/StoreUtil.java +++ b/store/src/main/java/com/nytimes/android/external/store3/base/impl/StoreUtil.java @@ -1,7 +1,7 @@ package com.nytimes.android.external.store3.base.impl; +import com.nytimes.android.external.store3.base.BasePersister; import com.nytimes.android.external.store3.base.Clearable; -import com.nytimes.android.external.store3.base.Persister; import com.nytimes.android.external.store3.base.RecordProvider; import com.nytimes.android.external.store3.base.RecordState; @@ -13,24 +13,24 @@ import static com.nytimes.android.external.store3.base.RecordState.STALE; -final class StoreUtil { +public final class StoreUtil { private StoreUtil() { } @Nonnull - static ObservableTransformer + public static ObservableTransformer repeatWhenSubjectEmits(PublishSubject refreshSubject, @Nonnull final Key keyForRepeat) { Observable filter = refreshSubject.filter(key -> key.equals(keyForRepeat)); return RepeatWhenEmits.from(filter); } - static boolean shouldReturnNetworkBeforeStale( - Persister persister, StalePolicy stalePolicy, Key key) { + public static boolean shouldReturnNetworkBeforeStale( + BasePersister persister, StalePolicy stalePolicy, Key key) { return stalePolicy == StalePolicy.NETWORK_BEFORE_STALE && persisterIsStale(key, persister); } - static boolean persisterIsStale(@Nonnull Key key, Persister persister) { + public static boolean persisterIsStale(@Nonnull Key key, BasePersister persister) { if (persister instanceof RecordProvider) { RecordProvider provider = (RecordProvider) persister; RecordState recordState = provider.getRecordState(key); @@ -39,7 +39,7 @@ static boolean persisterIsStale(@Nonnull Key key, Persister return false; } - static void clearPersister(Persister persister, @Nonnull Key key) { + public static void clearPersister(BasePersister persister, @Nonnull Key key) { boolean isPersisterClearable = persister instanceof Clearable; if (isPersisterClearable) { diff --git a/store/src/main/java/com/nytimes/android/external/store3/base/impl/room/RealStoreRoom.java b/store/src/main/java/com/nytimes/android/external/store3/base/impl/room/RealStoreRoom.java new file mode 100644 index 00000000..8c93cd2b --- /dev/null +++ b/store/src/main/java/com/nytimes/android/external/store3/base/impl/room/RealStoreRoom.java @@ -0,0 +1,225 @@ +package com.nytimes.android.external.store3.base.impl.room; + +import com.nytimes.android.external.cache3.Cache; +import com.nytimes.android.external.store3.annotations.Experimental; +import com.nytimes.android.external.store3.base.Fetcher; +import com.nytimes.android.external.store3.base.impl.CacheFactory; +import com.nytimes.android.external.store3.base.impl.MemoryPolicy; +import com.nytimes.android.external.store3.base.impl.StalePolicy; +import com.nytimes.android.external.store3.base.impl.StoreUtil; +import com.nytimes.android.external.store3.base.room.RoomPersister; + +import java.util.Collection; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import io.reactivex.Observable; + +/** + * Store to be used for loading an object from different data sources + * + * @param data type before parsing, usually a String, Reader or BufferedSource + * @param data type after parsing + *

+ */ +@Experimental +class RealStoreRoom extends StoreRoom { + private final Fetcher fetcher; + private final RoomPersister persister; + private final Cache> memCache; + private final StalePolicy stalePolicy; + private final Cache> inFlightRequests; + + + RealStoreRoom(Fetcher fetcher, + RoomPersister persister) { + this(fetcher, persister, null, StalePolicy.UNSPECIFIED); + } + + RealStoreRoom(Fetcher fetcher, + RoomPersister persister, + StalePolicy stalePolicy) { + this(fetcher, persister, null, stalePolicy); + } + + RealStoreRoom(Fetcher fetcher, + RoomPersister persister, + MemoryPolicy memoryPolicy, + StalePolicy stalePolicy) { + this.fetcher = fetcher; + this.persister = persister; + this.stalePolicy = stalePolicy; + this.memCache = CacheFactory.createRoomCache(memoryPolicy); + this.inFlightRequests = CacheFactory.createRoomInflighter(memoryPolicy); + } + + /** + * @param key + * @return an observable from the first data source that is available + */ + @Nonnull + @Override + public Observable get(@Nonnull final Key key) { + return lazyCache(key).switchIfEmpty(fetch(key)); + } + + /** + * @return data from memory + */ + private Observable lazyCache(@Nonnull final Key key) { + return Observable.defer(() -> cache(key)).onErrorResumeNext(Observable.empty()); + } + + Observable cache(@Nonnull final Key key) { + try { + return memCache.get(key, () -> disk(key)); + } catch (ExecutionException e) { + return Observable.empty(); + } + } + + @Nonnull + public Observable memory(@Nonnull Key key) { + Observable cachedValue = memCache.getIfPresent(key); + return cachedValue == null ? Observable.empty() : cachedValue; + } + + /** + * Fetch data from persister and update memory after. If an error occurs, emit an empty observable + * so that the concat call in {@link #get(Key)} moves on to {@link #fetch(Key)} + * + * @param key + * @return + */ + @Nonnull + public Observable disk(@Nonnull final Key key) { + if (StoreUtil.shouldReturnNetworkBeforeStale(persister, stalePolicy, key)) { + return Observable.empty(); + } + return readDisk(key); + } + + Observable readDisk(@Nonnull final Key key) { + return persister() + .read(key) + .doOnNext(this::guardAgainstEmptyCollection) + .onErrorResumeNext( + Observable.empty()) + .doOnNext(parsed -> { + updateMemory(key, parsed); + if (stalePolicy == StalePolicy.REFRESH_ON_STALE + && StoreUtil.persisterIsStale(key, persister)) { + backfillCache(key); + } + }).cache(); + } + + @SuppressWarnings("CheckReturnValue") + void backfillCache(@Nonnull Key key) { + fetch(key).subscribe(it -> { + }, it -> { + }); + } + + + /** + * Will check to see if there exists an in flight observable and return it before + * going to network + * + * @return data from fetch and store it in memory and persister + */ + @Nonnull + @Override + public Observable fetch(@Nonnull final Key key) { + return Observable.defer(() -> fetchAndPersist(key)); + } + + + /** + * There should only be one fetch request in flight at any give time. + *

+ * Return cached request in the form of a Behavior Subject which will emit to its subscribers + * the last value it gets. Subject/Observable is cached in a {@link ConcurrentMap} to maintain + * thread safety. + * + * @param key resource identifier + * @return observable that emits a {@link Parsed} value + */ + @Nullable + Observable fetchAndPersist(@Nonnull final Key key) { + try { + return inFlightRequests.get(key, () -> response(key)); + } catch (ExecutionException e) { + return Observable.error(e); + } + } + + @Nonnull + Observable response(@Nonnull final Key key) { + return fetcher() + .fetch(key) + .doOnSuccess(it -> persister().write(key, it)) + .flatMapObservable(it -> readDisk(key)) + .onErrorResumeNext(throwable -> { + if (stalePolicy == StalePolicy.NETWORK_BEFORE_STALE) { + return readDisk(key).switchIfEmpty(Observable.error(throwable)); + } + return Observable.error(throwable); + }) + .doAfterTerminate(() -> inFlightRequests.invalidate(key)) + .cache(); + } + + + /** + * Only update memory after persister has been successfully updated + * + * @param key + * @param data + */ + void updateMemory(@Nonnull final Key key, final Parsed data) { + memCache.put(key, Observable.just(data)); + } + + + @Override + //need to create a clearable override to clear all + // since room knows what table associated with data + public void clear() { + for (Key cachedKey : memCache.asMap().keySet()) { + clear(cachedKey); + } + } + + @Override + public void clear(@Nonnull Key key) { + inFlightRequests.invalidate(key); + memCache.invalidate(key); + StoreUtil.clearPersister(persister(), key); + } + + + /** + * @return DiskDAO that stores and stores data + */ + RoomPersister persister() { + return persister; + } + + /** + * + */ + Fetcher fetcher() { + return fetcher; + } + + private void guardAgainstEmptyCollection(Parsed v) { + if (v instanceof Collection && ((Collection) v).isEmpty()) { + throw new IllegalStateException("empty result set"); + } + } +} + diff --git a/store/src/main/java/com/nytimes/android/external/store3/base/impl/room/StoreRoom.java b/store/src/main/java/com/nytimes/android/external/store3/base/impl/room/StoreRoom.java new file mode 100644 index 00000000..4c28017a --- /dev/null +++ b/store/src/main/java/com/nytimes/android/external/store3/base/impl/room/StoreRoom.java @@ -0,0 +1,70 @@ +package com.nytimes.android.external.store3.base.impl.room; + +import com.nytimes.android.external.store3.annotations.Experimental; +import com.nytimes.android.external.store3.base.Fetcher; +import com.nytimes.android.external.store3.base.impl.MemoryPolicy; +import com.nytimes.android.external.store3.base.impl.StalePolicy; +import com.nytimes.android.external.store3.base.impl.StoreBuilder; +import com.nytimes.android.external.store3.base.room.RoomPersister; + +import javax.annotation.Nonnull; + +import io.reactivex.Observable; + +/** + * a {@link StoreBuilder StoreBuilder} + * will return an instance of a store + *

+ * A {@link StoreRoom Store} can + * {@link StoreRoom#get(V) Store.get() } cached data or + * force a call to {@link StoreRoom#fetch(V) Store.fetch() } + * (skipping cache) + */ +@Experimental +public abstract class StoreRoom { + + /** + * Return an Observable of T for request Barcode + * Data will be returned from oldest non expired source + * Sources are Memory Cache, Disk Cache, Inflight, Network Response + */ + @Nonnull + public abstract Observable get(@Nonnull V key); + + /** + * Return an Observable of T for requested Barcode skipping Memory & Disk Cache + */ + @Nonnull + public abstract Observable fetch(@Nonnull V key); + + /** + * purges all entries from memory and disk cache + * Persister will only be cleared if they implements Clearable + */ + public abstract void clear(); + + /** + * Purge a particular entry from memory and disk cache. + * Persister will only be cleared if they implements Clearable + */ + public abstract void clear(@Nonnull V key); + + + public static StoreRoom from + (Fetcher fetcher, RoomPersister persister) { + return new RealStoreRoom<>(fetcher, persister); + } + + public static StoreRoom from( + Fetcher fetcher, + RoomPersister persister, + StalePolicy policy) { + return new RealStoreRoom<>(fetcher, persister, policy); + } + + public static StoreRoom from + (Fetcher fetcher, RoomPersister persister, + StalePolicy stalePolicy, MemoryPolicy memoryPolicy) { + return new RealStoreRoom<>(fetcher, persister, memoryPolicy, stalePolicy); + } +} diff --git a/store/src/main/java/com/nytimes/android/external/store3/base/room/RoomDiskRead.java b/store/src/main/java/com/nytimes/android/external/store3/base/room/RoomDiskRead.java new file mode 100644 index 00000000..92b5d570 --- /dev/null +++ b/store/src/main/java/com/nytimes/android/external/store3/base/room/RoomDiskRead.java @@ -0,0 +1,13 @@ +package com.nytimes.android.external.store3.base.room; + +import com.nytimes.android.external.store3.annotations.Experimental; + +import javax.annotation.Nonnull; + +import io.reactivex.Observable; + + @Experimental +public interface RoomDiskRead { + @Nonnull + Observable read(@Nonnull Key key); +} diff --git a/store/src/main/java/com/nytimes/android/external/store3/base/room/RoomDiskWrite.java b/store/src/main/java/com/nytimes/android/external/store3/base/room/RoomDiskWrite.java new file mode 100644 index 00000000..aa8bb304 --- /dev/null +++ b/store/src/main/java/com/nytimes/android/external/store3/base/room/RoomDiskWrite.java @@ -0,0 +1,16 @@ +package com.nytimes.android.external.store3.base.room; + +import com.nytimes.android.external.store3.annotations.Experimental; + +import javax.annotation.Nonnull; + +@Experimental +public interface RoomDiskWrite { + /** + * @param key to use to get data from persister + * If data is not available implementer needs to + * either return Observable.empty or throw an exception + */ + @Nonnull + void write(@Nonnull Key key, @Nonnull Raw raw); +} diff --git a/store/src/main/java/com/nytimes/android/external/store3/base/room/RoomPersister.java b/store/src/main/java/com/nytimes/android/external/store3/base/room/RoomPersister.java new file mode 100644 index 00000000..fc6bfbdb --- /dev/null +++ b/store/src/main/java/com/nytimes/android/external/store3/base/room/RoomPersister.java @@ -0,0 +1,36 @@ +package com.nytimes.android.external.store3.base.room; + +import com.nytimes.android.external.store3.annotations.Experimental; +import com.nytimes.android.external.store3.base.BasePersister; + +import javax.annotation.Nonnull; + +import io.reactivex.Observable; + +/** + * Interface for fetching data from persister + * when implementing also think about implementing PathResolver to ease in creating primary keys + * + * @param data type before parsing + */ +@Experimental +public interface RoomPersister extends + RoomDiskRead, RoomDiskWrite, BasePersister { + + /** + * @param key to use to get data from persister + * If data is not available implementer needs to + * either return Observable.empty or throw an exception + */ + @Override + @Nonnull + Observable read(@Nonnull final Key key); + + /** + * @param key to use to store data to persister + * @param raw raw string to be stored + */ + @Override + @Nonnull + void write(@Nonnull final Key key, @Nonnull final Raw raw); +} diff --git a/store/src/test/java/com/nytimes/android/external/store3/room/ClearStoreRoomTest.java b/store/src/test/java/com/nytimes/android/external/store3/room/ClearStoreRoomTest.java new file mode 100644 index 00000000..e398845e --- /dev/null +++ b/store/src/test/java/com/nytimes/android/external/store3/room/ClearStoreRoomTest.java @@ -0,0 +1,112 @@ +package com.nytimes.android.external.store3.room; + +import com.nytimes.android.external.store3.base.Clearable; +import com.nytimes.android.external.store3.base.impl.BarCode; +import com.nytimes.android.external.store3.base.impl.StalePolicy; +import com.nytimes.android.external.store3.base.impl.room.StoreRoom; +import com.nytimes.android.external.store3.base.room.RoomPersister; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import java.util.concurrent.atomic.AtomicInteger; + +import javax.annotation.Nonnull; + +import io.reactivex.Observable; +import io.reactivex.Single; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ClearStoreRoomTest { + @Mock + RoomClearingPersister persister; + private AtomicInteger networkCalls; + private StoreRoom store; + + @Before + public void setUp() { + networkCalls = new AtomicInteger(0); + store = StoreRoom.from( + barCode -> Single.fromCallable(() -> networkCalls.incrementAndGet()), + persister, + StalePolicy.UNSPECIFIED); + } + + @Test + public void testClearSingleBarCode() { + // one request should produce one call + BarCode barcode = new BarCode("type", "key"); + + when(persister.read(barcode)) + .thenReturn(Observable.empty()) //read from disk on get + .thenReturn(Observable.just(1)) //read from disk after fetching from network + .thenReturn(Observable.empty()) //read from disk after clearing + .thenReturn(Observable.just(1)); //read from disk after making additional network call + + store.get(barcode).test().awaitTerminalEvent(); + assertThat(networkCalls.intValue()).isEqualTo(1); + + // after clearing the memory another call should be made + store.clear(barcode); + store.get(barcode).test().awaitTerminalEvent(); + verify(persister).clear(barcode); + assertThat(networkCalls.intValue()).isEqualTo(2); + } + + @Test + public void testClearAllBarCodes() { + BarCode barcode1 = new BarCode("type1", "key1"); + BarCode barcode2 = new BarCode("type2", "key2"); + + when(persister.read(barcode1)) + .thenReturn(Observable.empty()) //read from disk + .thenReturn(Observable.just(1)) //read from disk after fetching from network + .thenReturn(Observable.empty()) //read from disk after clearing disk cache + .thenReturn(Observable.just(1)); //read from disk after making additional network call + + when(persister.read(barcode2)) + .thenReturn(Observable.empty()) //read from disk + .thenReturn(Observable.just(1)) //read from disk after fetching from network + .thenReturn(Observable.empty()) //read from disk after clearing disk cache + .thenReturn(Observable.just(1)); //read from disk after making additional network call + + + // each request should produce one call + store.get(barcode1).test().awaitTerminalEvent(); + store.get(barcode2).test().awaitTerminalEvent(); + assertThat(networkCalls.intValue()).isEqualTo(2); + + store.clear(); + + // after everything is cleared each request should produce another 2 calls + store.get(barcode1).test().awaitTerminalEvent(); + store.get(barcode2).test().awaitTerminalEvent(); + assertThat(networkCalls.intValue()).isEqualTo(4); + } + + //everything will be mocked + static class RoomClearingPersister implements RoomPersister, Clearable { + @Override + public void clear(@Nonnull BarCode key) { + throw new RuntimeException(); + } + + @Nonnull + @Override + public Observable read(@Nonnull BarCode barCode) { + throw new RuntimeException(); + } + + @Override + public void write(@Nonnull BarCode barCode, @Nonnull Integer integer) { + //noop + } + } +} diff --git a/store/src/test/java/com/nytimes/android/external/store3/room/StoreRoomTest.java b/store/src/test/java/com/nytimes/android/external/store3/room/StoreRoomTest.java new file mode 100644 index 00000000..3f2e6e70 --- /dev/null +++ b/store/src/test/java/com/nytimes/android/external/store3/room/StoreRoomTest.java @@ -0,0 +1,98 @@ +package com.nytimes.android.external.store3.room; + +import com.nytimes.android.external.store3.base.Fetcher; +import com.nytimes.android.external.store3.base.impl.BarCode; +import com.nytimes.android.external.store3.base.impl.StalePolicy; +import com.nytimes.android.external.store3.base.impl.room.StoreRoom; +import com.nytimes.android.external.store3.base.room.RoomPersister; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.reactivex.Observable; +import io.reactivex.Single; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class StoreRoomTest { + + private static final String DISK = "disk"; + private static final String NETWORK = "fetch"; + final AtomicInteger counter = new AtomicInteger(0); + @Mock + Fetcher fetcher; + @Mock + RoomPersister persister; + private final BarCode barCode = new BarCode("key", "value"); + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void testSimple() { + + StoreRoom simpleStore = StoreRoom.from( + fetcher, + persister, + StalePolicy.UNSPECIFIED + ); + + + when(fetcher.fetch(barCode)) + .thenReturn(Single.just(NETWORK)); + + when(persister.read(barCode)) + .thenReturn(Observable.empty()) + .thenReturn(Observable.just(DISK)); + + + String value = simpleStore.get(barCode).blockingFirst(); + + assertThat(value).isEqualTo(DISK); + value = simpleStore.get(barCode).blockingFirst(); + assertThat(value).isEqualTo(DISK); + verify(fetcher, times(1)).fetch(barCode); + } + + + @Test + public void testDoubleTap() { + StoreRoom simpleStore = StoreRoom.from( + fetcher, + persister, + StalePolicy.UNSPECIFIED + ); + + Single networkSingle = + Single.create(emitter -> { + if (counter.incrementAndGet() == 1) { + emitter.onSuccess(NETWORK); + } else { + emitter.onError(new RuntimeException("Yo Dawg your inflight is broken")); + } + }); + + when(fetcher.fetch(barCode)) + .thenReturn(networkSingle); + + when(persister.read(barCode)) + .thenReturn(Observable.empty()) + .thenReturn(Observable.just(DISK)); + + + String response = simpleStore.get(barCode) + .zipWith(simpleStore.get(barCode), (s, s2) -> "hello") + .blockingFirst(); + assertThat(response).isEqualTo("hello"); + verify(fetcher, times(1)).fetch(barCode); + } +}