diff --git a/app/src/main/java/dev/jvmname/acquisitive/network/NetworkModule.kt b/app/src/main/java/dev/jvmname/acquisitive/network/NetworkModule.kt index ff6a2cc..0471197 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/network/NetworkModule.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/network/NetworkModule.kt @@ -8,6 +8,7 @@ import kotlinx.datetime.Instant import logcat.logcat import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Provides +import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -15,6 +16,7 @@ import retrofit2.converter.moshi.MoshiConverterFactory import software.amazon.lastmile.kotlin.inject.anvil.AppScope import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo import software.amazon.lastmile.kotlin.inject.anvil.SingleIn +import java.io.File import kotlin.reflect.KClass @ContributesTo(AppScope::class) diff --git a/app/src/main/java/dev/jvmname/acquisitive/network/model/HnItem.kt b/app/src/main/java/dev/jvmname/acquisitive/network/model/HnItem.kt index c387867..21b5814 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/network/model/HnItem.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/network/model/HnItem.kt @@ -1,5 +1,6 @@ package dev.jvmname.acquisitive.network.model +import android.content.ClipData.Item import android.os.Parcelable import androidx.compose.runtime.Immutable import com.squareup.moshi.JsonClass @@ -8,6 +9,7 @@ import dev.jvmname.acquisitive.util.ItemIdArray import dev.zacsweers.moshix.sealed.annotations.TypeLabel import kotlinx.datetime.Instant import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable @Immutable sealed interface ShadedHnItem { @@ -18,6 +20,7 @@ sealed interface ShadedHnItem { value class Full(val item: HnItem) : ShadedHnItem } + @[JvmInline Parcelize Immutable JsonClass(generateAdapter = false)] value class ItemId(val id: Int) : Parcelable @@ -192,3 +195,9 @@ fun HnItem.copy( fun ItemId.shaded() = ShadedHnItem.Shallow(this) fun HnItem.shaded() = ShadedHnItem.Full(this) + +val ShadedHnItem.id : ItemId + get() = when(this){ + is ShadedHnItem.Full -> item.id + is ShadedHnItem.Shallow -> item + } \ No newline at end of file diff --git a/app/src/main/java/dev/jvmname/acquisitive/repo/HnItemStore.kt b/app/src/main/java/dev/jvmname/acquisitive/repo/HnItemStore.kt index f52adb1..fd705eb 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/repo/HnItemStore.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/repo/HnItemStore.kt @@ -2,249 +2,129 @@ package dev.jvmname.acquisitive.repo -import androidx.compose.runtime.Immutable +import android.util.SparseArray import com.mercury.sqkon.db.KeyValueStorage -import com.mercury.sqkon.db.OrderBy -import com.mercury.sqkon.db.OrderDirection import com.mercury.sqkon.db.Sqkon -import com.mercury.sqkon.db.eq -import dev.drewhamilton.poko.Poko import dev.jvmname.acquisitive.di.AppCrScope import dev.jvmname.acquisitive.network.HnClient import dev.jvmname.acquisitive.network.model.FetchMode -import dev.jvmname.acquisitive.network.model.HnItem import dev.jvmname.acquisitive.network.model.ItemId import dev.jvmname.acquisitive.network.model.ShadedHnItem +import dev.jvmname.acquisitive.network.model.id +import dev.jvmname.acquisitive.network.model.shaded import dev.jvmname.acquisitive.util.ItemIdArray import dev.jvmname.acquisitive.util.fetchAsync import dev.jvmname.acquisitive.util.retry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import logcat.asLog -import logcat.logcat +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable import me.tatarka.inject.annotations.Inject -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse - - -@Immutable -sealed interface StoryItemKey { - @[JvmInline Immutable] - value class Single(val id: ItemId) : StoryItemKey - - @[Poko Immutable] - class All(val fetchMode: FetchMode, val window: Int) : StoryItemKey -} - -sealed interface StoryItemResult { - @[JvmInline Immutable] - value class Single(val item: T) : StoryItemResult - - @[Poko Immutable] - class All(val items: List, val fetchMode: FetchMode) : StoryItemResult -} - -private sealed interface NetworkResult { - @[JvmInline Immutable] - value class Single(val item: T) : NetworkResult - - @[Poko Immutable] - class All( - val full: List, - val shallow: ItemIdArray, - val fetchMode: FetchMode, - ) : NetworkResult -} - - -private typealias NetworkItem = NetworkResult -private typealias OutputItem = StoryItemResult +@[Serializable JvmInline] +private value class ShadedItemList(val list: List) @Inject class HnItemStore( skn: Sqkon, private val client: HnClient, - @AppCrScope scope: CoroutineScope, + @AppCrScope private val scope: CoroutineScope, ) { - private val storage = skn.keyValueStorage( - name = HnItemEntity::class.simpleName!!, + private val storage = skn.keyValueStorage( + name = ShadedItemList::class.simpleName!!, config = KeyValueStorage.Config(dispatcher = Dispatchers.IO) ) - private val store = StoreBuilder - .from( - fetcher = buildFetcher(), - sourceOfTruth = buildSoT(), - converter = buildConverter(), - ) - .scope(scope) -// .cachePolicy( -// MemoryPolicy.MemoryPolicyBuilder() -// .setMaxSize(20_971_520L) // 20 MB -// .setExpireAfterWrite(30.minutes) -// .build() -// ) - .build() - - suspend fun get(key: StoryItemKey): OutputItem { - return store.stream(StoreReadRequest.cached(key, refresh = true)) - .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } - .first() - .requireData() - } - - fun stream(key: StoryItemKey): Flow { - return store.stream(StoreReadRequest.cached(key, refresh = true)) - .mapNotNull { it.dataOrNull() } - } - - /* - * below is the setup methods for the store - */ - private fun buildFetcher() = Fetcher.of { key -> - when (key) { - is StoryItemKey.Single -> { - NetworkResult.Single(retry(3) { client.getItem(key.id) }) - } - - is StoryItemKey.All -> { - val itemsIds = try { - logcat { "***fetching items for $key" } - client.getStories(key.fetchMode) - } catch (e: Exception) { - logcat { "***fetcher internal error: " + e.asLog() } - throw e - } - - val full = itemsIds - .take(key.window) - .fetchAsync { - retry(2) { - client.getItem(it) //todo if this fails return the old ID so I don't lose it - } + suspend fun getItem(mode: FetchMode, itemId: ItemId): ShadedHnItem.Full { + val storedList = coroutineScope { + storage.selectByKey(mode.value) + .firstOrNull() + ?.list.orEmpty() + } + val storedIndex = storedList.indexOfFirst { it.id == itemId } + val shouldCache = storedIndex >= 0 + val networkItem = withContext(Dispatchers.IO) { client.getItem(itemId) } + + return networkItem.shaded().also { + if (shouldCache) { + storedList + .toMutableList() + .apply { + set(storedIndex, ShadedHnItem.Full(networkItem)) } - val shallow = ItemIdArray(itemsIds.size - key.window) { i -> - itemsIds[key.window + i] + scope.launch(Dispatchers.IO) { + storage.update(mode.value, ShadedItemList(storedList)) } - NetworkResult.All( - full = full, - shallow = shallow, - fetchMode = key.fetchMode - - ) } } } - private fun buildSoT(): SourceOfTruth, OutputItem> { - return SourceOfTruth.of( - reader = { key -> - when (key) { - is StoryItemKey.Single -> try { - storage.selectByKey(key.toStringId()) - .map { entity -> - entity?.let { StoryItemResult.Single(it.toShadedItem()) } - } - } catch (e: Exception) { - logcat { "***skn error: " + e.asLog() } - throw e - } + fun stream(mode: FetchMode, window: Int): Flow> { + return storage.selectByKey(mode.value) + .map { it?.list.orEmpty() } + .onStart { emit(getNetworkItems(mode, window)) } + } - is StoryItemKey.All -> try { - storage.select( - where = HnItemEntity::fetchMode eq key.fetchMode.name, - orderBy = listOf(OrderBy(HnItemEntity::index, OrderDirection.ASC)) - ) - .map { list -> - StoryItemResult.All( - list.map(HnItemEntity::toShadedItem), - key.fetchMode - ) - } - } catch (e: Exception) { - logcat { "***skn error: " + e.asLog() } - throw e - } - } - }, - writer = { key, item -> - when (key) { - is StoryItemKey.Single -> { - try { - storage.upsert(key.toStringId(), item.first()) - } catch (e: Exception) { - logcat { "***skn error: " + e.asLog() } - throw e - } - } - is StoryItemKey.All -> try { - storage.upsertAll(item.associateBy { it.id.toString() }) - } catch (e: Exception) { - logcat { "***skn error: " + e.asLog() } - throw e - } - } - }, - delete = { key -> - when (key) { - is StoryItemKey.Single -> storage.deleteByKey(key.toStringId()) - is StoryItemKey.All -> storage.delete(HnItemEntity::fetchMode eq key.fetchMode.name) - } - }, - deleteAll = storage::deleteAll, - ) - } + private suspend fun getNetworkItems(mode: FetchMode, window: Int): List { + val fullThenShallows = fetchNetworkItems(mode, window) - private fun buildConverter() = Converter.Builder, OutputItem>() - .fromNetworkToLocal { networkItem -> - when (networkItem) { - is NetworkResult.Single -> listOfNotNull(networkItem.item.toEntity()) - is NetworkResult.All -> { - val full = networkItem.full - val shallow = networkItem.shallow - buildList(full.size + shallow.size) { - full.forEachIndexed { it, item -> - add(item.toEntity(networkItem.fetchMode, index = it)) - } - shallow.forEachIndexed { it, item -> - add(item.toEntity(networkItem.fetchMode, index = it + full.size)) - } - } - } + val storedList = coroutineScope { + storage.selectByKey(mode.value) + .map { it?.list } + .firstOrNull() + .orEmpty() + } + if (storedList.isEmpty() || fullThenShallows.size > storedList.size) { + scope.launch(Dispatchers.IO) { + storage.update(mode.value, ShadedItemList(fullThenShallows)) } + return fullThenShallows } - .fromOutputToLocal { outputItem -> - when (outputItem) { - is StoryItemResult.Single -> listOf(outputItem.item.toEntity(index = -1)) - is StoryItemResult.All -> outputItem.items.mapIndexed { i, item -> - item.toEntity(outputItem.fetchMode, i) + val mapping = buildMapping(storedList) + + //copy any Full items into the FullThenShallows list + val fullyUpdated = fullThenShallows.map { item -> + if (item is ShadedHnItem.Shallow) { + when (val fromMapping = mapping[item.id.id]) { + null, is ShadedHnItem.Shallow -> item + else -> fromMapping } + } else { + item } } - .build() -} + scope.launch(Dispatchers.IO) { + storage.update(mode.value, ShadedItemList(fullyUpdated)) + } + return fullyUpdated + } + + private suspend fun fetchNetworkItems(mode: FetchMode, window: Int): List = + withContext(Dispatchers.IO) { + val ids = client.getStories(mode) + val fulls = ids.take(window).fetchAsync { retry(3) { client.getItem(it) } } + val shallows = ItemIdArray(ids.size - window) { i -> ids[window + i] } -private fun Fetcher.Companion.fetcherResult(fetch: suspend (K) -> R): Fetcher { - return ofResult { k -> - try { - FetcherResult.Data(fetch(k)) - } catch (e: Exception) { - logcat { "***fetcher error: " + e.asLog() } - FetcherResult.Error.Exception(e) + //equivalent to fulls.map(::Full) + shallows.map(::Shallow), but with fewer allocations + buildList(ids.size) { + fulls.forEach { this.add(ShadedHnItem.Full(it)) } + shallows.forEach { this.add(ShadedHnItem.Shallow(it)) } + } + } + + private fun buildMapping(storedList: List): SparseArray { + val map = SparseArray(storedList.size) + storedList.forEach { item -> + map.append(item.id.id, item) } + return map } -} -private inline fun StoryItemKey.Single.toStringId() = id.id.toString() \ No newline at end of file +} diff --git a/app/src/main/java/dev/jvmname/acquisitive/repo/StoryItemRepo.kt b/app/src/main/java/dev/jvmname/acquisitive/repo/StoryItemRepo.kt index f437424..8bc194a 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/repo/StoryItemRepo.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/repo/StoryItemRepo.kt @@ -1,44 +1,37 @@ package dev.jvmname.acquisitive.repo import dev.jvmname.acquisitive.network.model.FetchMode +import dev.jvmname.acquisitive.network.model.HnItem import dev.jvmname.acquisitive.network.model.ItemId import dev.jvmname.acquisitive.network.model.ShadedHnItem +import dev.jvmname.acquisitive.network.model.shaded import dev.jvmname.acquisitive.ui.screen.mainlist.debugToString1 -import dev.jvmname.acquisitive.ui.screen.mainlist.debugToString2 import dev.jvmname.acquisitive.util.fetchAsync import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import logcat.logcat import me.tatarka.inject.annotations.Inject @Inject class StoryItemRepo(private val store: HnItemStore) { - suspend fun getStory(id: ItemId): ShadedHnItem { - val result = store.get(StoryItemKey.Single(id)) - result as StoryItemResult.Single - return result.item + suspend fun getStory(mode: FetchMode, id: ItemId): HnItem { + return store.getItem(mode, id).item } - suspend fun getStories(storyIds: List): List { + suspend fun getStories(mode: FetchMode, storyIds: List): List { return storyIds.fetchAsync { item -> when (item) { - is ShadedHnItem.Shallow -> getStory(item.item) + is ShadedHnItem.Shallow -> getStory(mode, item.item).shaded() is ShadedHnItem.Full -> item } } } - fun observeStories(fetchMode: FetchMode, window: Int = 5): Flow> { - val key = StoryItemKey.All(fetchMode, window) - return store.stream(key) - .map { - (it as StoryItemResult.All).items.also { - logcat { - "***observeStories produces: " + it.debugToString1() - } - } + fun observeStories(mode: FetchMode, window: Int = 5): Flow> { + return store.stream(mode, window) + .onEach { + logcat { "***observeStories produces: " + it.debugToString1() } } } -} - +} \ No newline at end of file diff --git a/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/presenter.kt b/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/presenter.kt index 71c1695..afb97ca 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/presenter.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/presenter.kt @@ -48,7 +48,7 @@ class MainScreenPresenter( val now = remember { Clock.System.now() } val fetchMode by remember { mutableStateOf(screen.fetchMode) } SideEffect { logcat { "***before repo.observeStories" } } - val storyIds by repo.observeStories(fetchMode) + val storyIds by repo.observeStories(fetchMode, window = INFLATE_WINDOW) .collectAsState(emptyList(), context = Dispatchers.IO) val updatedRange: Pair>? by inflateChannel @@ -60,7 +60,7 @@ class MainScreenPresenter( if (sliced.all { it is ShadedHnItem.Full }) { null // no need to update anything } else { - range to repo.getStories(sliced) + range to repo.getStories(fetchMode, sliced) } } .collectAsState(null, context = Dispatchers.IO) @@ -138,7 +138,7 @@ class MainScreenPresenter( companion object { - private const val INFLATE_WINDOW = 10 + private const val INFLATE_WINDOW = 50 private const val HOT_THRESHOLD_HIGH = 900 private const val HOT_THRESHOLD_NORMAL = 300 diff --git a/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/screen.kt b/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/screen.kt index 24878a0..5d50883 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/screen.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/screen.kt @@ -118,7 +118,7 @@ fun MainListContent(state: MainListScreen.MainListState, modifier: Modifier = Mo val lifecycleOwner = LocalLifecycleOwner.current val lifecycle = remember { lifecycleOwner.lifecycle } - LaunchedEffect(scrollState) { + LaunchedEffect(scrollState, state) { snapshotFlow { scrollState.firstVisibleItemIndex } .filter { topIndex -> logcat { "***saw scroll to $topIndex" }