diff --git a/android/Omnivore/app/build.gradle.kts b/android/Omnivore/app/build.gradle.kts
index f32c807ae..8ef784415 100644
--- a/android/Omnivore/app/build.gradle.kts
+++ b/android/Omnivore/app/build.gradle.kts
@@ -168,6 +168,9 @@ dependencies {
implementation(libs.androidx.core.splashscreen)
+ implementation(libs.work.runtime.ktx)
+ implementation(libs.hilt.work)
+ ksp(libs.hilt.work.compiler)
}
apollo {
diff --git a/android/Omnivore/app/src/main/AndroidManifest.xml b/android/Omnivore/app/src/main/AndroidManifest.xml
index cb7c7584f..5d3936eb1 100644
--- a/android/Omnivore/app/src/main/AndroidManifest.xml
+++ b/android/Omnivore/app/src/main/AndroidManifest.xml
@@ -55,5 +55,11 @@
android:exported="true"
android:theme="@style/Theme.Omnivore"/>
+
+
+
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/OmnivoreApplication.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/OmnivoreApplication.kt
index 3add9d375..3f97b3a4f 100644
--- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/OmnivoreApplication.kt
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/OmnivoreApplication.kt
@@ -1,11 +1,23 @@
package app.omnivore.omnivore
import android.app.Application
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import io.intercom.android.sdk.Intercom
+import javax.inject.Inject
@HiltAndroidApp
-class OmnivoreApplication: Application() {
+class OmnivoreApplication: Application(), Configuration.Provider {
+
+ @Inject
+ lateinit var workerFactory: HiltWorkerFactory
+
+ override val workManagerConfiguration
+ get() = Configuration.Builder()
+ .setWorkerFactory(workerFactory)
+ .build()
+
override fun onCreate() {
super.onCreate()
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/LibraryRepository.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/LibraryRepository.kt
index 5b335a358..632f7c913 100644
--- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/LibraryRepository.kt
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/LibraryRepository.kt
@@ -7,6 +7,7 @@ import app.omnivore.omnivore.core.data.model.LibraryQuery
import app.omnivore.omnivore.core.database.entities.HighlightChange
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
+import app.omnivore.omnivore.core.database.entities.TypeaheadCardData
import kotlinx.coroutines.flow.Flow
interface LibraryRepository {
@@ -46,4 +47,6 @@ interface LibraryRepository {
suspend fun syncHighlightChange(highlightChange: HighlightChange): Boolean
suspend fun sync(context: Context, since: String, cursor: String?, limit: Int = 20): SavedItemSyncResult
+
+ suspend fun getTypeaheadData(query: String): List
}
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt
index 5e4fbc1be..9830dcd69 100644
--- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt
@@ -20,6 +20,7 @@ import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
+import app.omnivore.omnivore.core.database.entities.TypeaheadCardData
import app.omnivore.omnivore.core.database.entities.highlightChangeToHighlight
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.ReadingProgressParams
@@ -47,8 +48,10 @@ import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
import javax.inject.Inject
class LibraryRepositoryImpl @Inject constructor(
@@ -94,6 +97,12 @@ class LibraryRepositoryImpl @Inject constructor(
}
}
+ override suspend fun getTypeaheadData(query: String): List {
+ return withContext(Dispatchers.IO) {
+ savedItemDao.getTypeaheadData(query)
+ }
+ }
+
override suspend fun updateReadingProgress(
itemId: String,
readingProgressPercentage: Double,
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemDao.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemDao.kt
index 650cedde4..ca400bdfe 100644
--- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemDao.kt
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemDao.kt
@@ -8,6 +8,7 @@ import androidx.room.Update
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemQueryConstants
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
+import app.omnivore.omnivore.core.database.entities.TypeaheadCardData
import kotlinx.coroutines.flow.Flow
@Dao
@@ -19,6 +20,9 @@ interface SavedItemDao {
@Query("SELECT * FROM savedItem WHERE savedItemId = :itemID")
suspend fun findById(itemID: String): SavedItem?
+ @Query("SELECT savedItemId, slug, title, isArchived FROM saveditem WHERE title LIKE '%' || :query || '%'")
+ suspend fun getTypeaheadData(query: String): List
+
@Query("SELECT * FROM savedItem WHERE serverSyncStatus != 0")
fun getUnSynced(): List
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt
index c43ecd15b..bc13591b6 100644
--- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt
@@ -22,7 +22,6 @@ import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
import com.google.android.gms.common.GoogleApiAvailability
-import kotlinx.coroutines.launch
@Composable
fun WelcomeScreen(viewModel: LoginViewModel) {
@@ -40,11 +39,10 @@ fun WelcomeScreen(viewModel: LoginViewModel) {
@Composable
fun WelcomeScreenContent(viewModel: LoginViewModel) {
val registrationState: RegistrationState by viewModel.registrationStateLiveData.observeAsState(
- RegistrationState.SocialLogin
- )
+ RegistrationState.SocialLogin
+ )
val snackBarHostState = remember { SnackbarHostState() }
- val coroutineScope = rememberCoroutineScope()
Column(
verticalArrangement = Arrangement.SpaceAround,
@@ -101,19 +99,18 @@ fun WelcomeScreenContent(viewModel: LoginViewModel) {
Spacer(modifier = Modifier.weight(1.0F))
}
- if (viewModel.errorMessage != null) {
- coroutineScope.launch {
- val result = snackBarHostState.showSnackbar(
- viewModel.errorMessage!!,
- actionLabel = "Dismiss",
- duration = SnackbarDuration.Indefinite
- )
- when (result) {
- SnackbarResult.ActionPerformed -> viewModel.resetErrorMessage()
- else -> {}
- }
+ LaunchedEffect(viewModel.errorMessage) {
+ val result = snackBarHostState.showSnackbar(
+ viewModel.errorMessage!!,
+ actionLabel = "Dismiss",
+ duration = SnackbarDuration.Indefinite
+ )
+ when (result) {
+ SnackbarResult.ActionPerformed -> viewModel.resetErrorMessage()
+ else -> {}
}
-
+ }
+ if (viewModel.errorMessage != null) {
SnackbarHost(hostState = snackBarHostState)
}
}
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt
index 0ad2fe432..5cb8ca50e 100644
--- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt
@@ -8,10 +8,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
@@ -43,12 +43,10 @@ internal fun FollowingScreen(
) {
val snackbarHostState = remember { SnackbarHostState() }
- val coroutineScope = rememberCoroutineScope()
-
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- viewModel.snackbarMessage?.let {
- coroutineScope.launch {
+ LaunchedEffect(viewModel.snackbarMessage) {
+ viewModel.snackbarMessage?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearSnackbarMessage()
}
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibrarySyncWorker.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibrarySyncWorker.kt
new file mode 100644
index 000000000..85ba6e69b
--- /dev/null
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibrarySyncWorker.kt
@@ -0,0 +1,100 @@
+package app.omnivore.omnivore.feature.library
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import app.omnivore.omnivore.core.data.repository.LibraryRepository
+import app.omnivore.omnivore.core.datastore.DatastoreRepository
+import app.omnivore.omnivore.core.datastore.libraryLastSyncTimestamp
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import java.time.Instant
+
+@HiltWorker
+class LibrarySyncWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted workerParams: WorkerParameters,
+ private val libraryRepository: LibraryRepository,
+ private val datastoreRepository: DatastoreRepository,
+) : CoroutineWorker(appContext, workerParams) {
+
+ override suspend fun doWork(): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ performItemSync()
+ loadUsingSearchAPI()
+ Result.success()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ Result.failure()
+ }
+ }
+ }
+
+ private suspend fun performItemSync(
+ cursor: String? = null,
+ since: String = getLastSyncTime()?.toString() ?: Instant.MIN.toString(),
+ count: Int = 0,
+ syncStart: String = Instant.now().toString(),
+ ) {
+ libraryRepository.syncOfflineItemsWithServerIfNeeded()
+
+ val result = libraryRepository.sync(
+ context = applicationContext,
+ since = since,
+ cursor = cursor,
+ limit = 10
+ )
+ val totalCount = count + result.count
+
+ if (result.hasError) {
+ result.errorString?.let { errorString ->
+ println("SYNC ERROR: $errorString")
+ }
+ }
+
+ if (!result.hasError && result.hasMoreItems && result.cursor != null) {
+ performItemSync(
+ cursor = result.cursor,
+ since = since,
+ count = totalCount,
+ syncStart = syncStart
+ )
+ } else {
+ datastoreRepository.putString(libraryLastSyncTimestamp, syncStart)
+ }
+ }
+
+ private suspend fun loadUsingSearchAPI() {
+ val result = libraryRepository.librarySearch(
+ context = applicationContext,
+ cursor = null,
+ query = "${SavedItemFilter.INBOX.queryString} ${SavedItemSortFilter.NEWEST.queryString}"
+ )
+ result.savedItems.map {
+ val isSavedInDB =
+ libraryRepository.isSavedItemContentStoredInDB(
+ applicationContext,
+ it.savedItem.slug
+ )
+
+ if (!isSavedInDB) {
+ libraryRepository.fetchSavedItemContent(applicationContext, it.savedItem.slug)
+ }
+ }
+ }
+
+ private fun getLastSyncTime(): Instant? = runBlocking {
+ datastoreRepository.getString(libraryLastSyncTimestamp)?.let {
+ try {
+ return@let Instant.parse(it)
+ } catch (e: Exception) {
+ return@let null
+ }
+ }
+ }
+}
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt
index 4357371bd..f619d0dbf 100644
--- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt
@@ -1,12 +1,11 @@
package app.omnivore.omnivore.feature.library
import android.content.Intent
-import android.content.pm.PackageManager
import android.util.Log
-import android.widget.Toast
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FloatTweenSpec
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -58,8 +57,6 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
-import androidx.core.app.ActivityCompat
-import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
@@ -80,7 +77,6 @@ import app.omnivore.omnivore.navigation.TopLevelDestination
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
-import java.util.jar.Manifest
@Composable
internal fun LibraryView(
@@ -91,12 +87,11 @@ internal fun LibraryView(
viewModel: LibraryViewModel = hiltViewModel()
) {
val snackbarHostState = remember { SnackbarHostState() }
- val coroutineScope = rememberCoroutineScope()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- viewModel.snackbarMessage?.let {
- coroutineScope.launch {
+ LaunchedEffect(viewModel.snackbarMessage) {
+ viewModel.snackbarMessage?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearSnackbarMessage()
}
@@ -135,7 +130,8 @@ internal fun LibraryView(
}
LibraryBottomSheetState.EDIT -> {
- EditBottomSheet(editInfoViewModel,
+ EditBottomSheet(
+ editInfoViewModel,
deleteCurrentItem = { viewModel.currentItem.value = null },
{ viewModel.refresh() },
viewModel.currentSavedItemUnderEdit()
@@ -318,13 +314,15 @@ fun EditBottomSheet(
}
-@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class,
+ ExperimentalFoundationApi::class
+)
@Composable
fun LibraryViewContent(
itemsFilter: SavedItemFilter,
activeLabels: List,
sortFilter: SavedItemSortFilter,
- updateSavedItemFilter:(SavedItemFilter) -> Unit,
+ updateSavedItemFilter: (SavedItemFilter) -> Unit,
updateSavedItemSortFilter: (SavedItemSortFilter) -> Unit,
setBottomSheetState: (LibraryBottomSheetState) -> Unit,
updateAppliedLabels: (List) -> Unit,
@@ -415,6 +413,7 @@ fun LibraryViewContent(
true
})
SwipeToDismiss(
+ modifier = Modifier.animateItemPlacement(),
state = swipeState,
directions = setOf(
DismissDirection.StartToEnd, DismissDirection.EndToStart
@@ -535,7 +534,7 @@ fun InfiniteListHandler(
LaunchedEffect(loadMore) {
snapshotFlow { loadMore.value }.distinctUntilChanged().collect {
- onLoadMore()
- }
+ onLoadMore()
+ }
}
}
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt
index 9a77a9cca..5b18aae4f 100644
--- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt
@@ -1,6 +1,7 @@
package app.omnivore.omnivore.feature.library
import android.content.Intent
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
@@ -26,6 +27,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
@@ -35,10 +37,10 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
-import app.omnivore.omnivore.core.database.entities.TypeaheadCardData
import app.omnivore.omnivore.feature.reader.PDFReaderActivity
import app.omnivore.omnivore.feature.reader.WebReaderLoadingContainerActivity
import app.omnivore.omnivore.feature.savedItemViews.SavedItemCard
@@ -50,9 +52,9 @@ fun SearchView(
navController: NavHostController,
viewModel: SearchViewModel = hiltViewModel()
) {
- val isRefreshing: Boolean by viewModel.isRefreshing.observeAsState(false)
- val typeaheadMode: Boolean by viewModel.typeaheadMode.observeAsState(true)
- val searchText: String by viewModel.searchTextLiveData.observeAsState("")
+ val isRefreshing: Boolean by viewModel.isRefreshing.collectAsStateWithLifecycle()
+ val typeaheadMode: Boolean by viewModel.typeaheadMode.collectAsStateWithLifecycle()
+ val searchText: String by viewModel.searchTextFlow.collectAsState("")
val actionsMenuItem: SavedItemWithLabelsAndHighlights? by viewModel.actionsMenuItemLiveData.observeAsState(
null
)
@@ -151,14 +153,13 @@ fun SearchView(
}
}
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TypeaheadSearchViewContent(viewModel: SearchViewModel, modifier: Modifier) {
val context = LocalContext.current
val listState = rememberLazyListState()
- val searchedCardsData: List by viewModel.searchItemsLiveData.observeAsState(
- listOf()
- )
+ val searchedCardsData by viewModel.searchItemsFlow.collectAsStateWithLifecycle()
LazyColumn(
state = listState,
@@ -169,8 +170,9 @@ fun TypeaheadSearchViewContent(viewModel: SearchViewModel, modifier: Modifier) {
.fillMaxSize()
) {
- items(searchedCardsData) { cardData ->
+ items(searchedCardsData, key = { it.savedItemId } ) { cardData ->
TypeaheadSearchCard(
+ modifier = Modifier.animateItemPlacement(),
cardData = cardData,
onClickHandler = {
// val activityClass = if (cardData.isPDF()) PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java
@@ -190,9 +192,7 @@ fun SearchViewContent(viewModel: SearchViewModel, modifier: Modifier) {
val context = LocalContext.current
val listState = rememberLazyListState()
- val cardsData: List by viewModel.itemsLiveData.observeAsState(
- listOf()
- )
+ val cardsData: List by viewModel.itemsState.collectAsStateWithLifecycle()
LazyColumn(
state = listState,
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt
index fda56889d..fee80bc09 100644
--- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt
@@ -1,7 +1,6 @@
package app.omnivore.omnivore.feature.library
import android.content.Context
-import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -10,68 +9,70 @@ import app.omnivore.omnivore.core.data.archiveSavedItem
import app.omnivore.omnivore.core.data.deleteSavedItem
import app.omnivore.omnivore.core.data.isSavedItemContentStoredInDB
import app.omnivore.omnivore.core.data.librarySearch
+import app.omnivore.omnivore.core.data.repository.LibraryRepository
import app.omnivore.omnivore.core.data.unarchiveSavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.database.entities.TypeaheadCardData
import app.omnivore.omnivore.core.datastore.DatastoreRepository
-import app.omnivore.omnivore.core.network.Networker
-import app.omnivore.omnivore.core.network.typeaheadSearch
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
+@OptIn(FlowPreview::class)
@HiltViewModel
class SearchViewModel @Inject constructor(
- private val networker: Networker,
private val dataService: DataService,
private val datastoreRepo: DatastoreRepository,
+ private val repository: LibraryRepository,
@ApplicationContext private val applicationContext: Context
) : ViewModel(), SavedItemViewModel {
private val contentRequestChannel = Channel(capacity = Channel.UNLIMITED)
- private var cursor: String? = null
private var librarySearchCursor: String? = null
- // These are used to make sure we handle search result
- // responses in the right order
- private var searchIdx = 0
- private var receivedIdx = 0
-
- // Live Data
- var isRefreshing = MutableLiveData(false)
- val typeaheadMode = MutableLiveData(true)
- val searchTextLiveData = MutableLiveData("")
- val searchItemsLiveData = MutableLiveData>(listOf())
- val itemsLiveData = MediatorLiveData>()
+ var isRefreshing = MutableStateFlow(false)
+ val typeaheadMode = MutableStateFlow(true)
+ val searchTextFlow = MutableStateFlow("")
+ val searchItemsFlow = MutableStateFlow>(emptyList())
+ val itemsState = MutableStateFlow>(emptyList())
override val actionsMenuItemLiveData = MutableLiveData(null)
+ init {
+ searchTextFlow
+ .debounce(300)
+ .onEach {
+ if (it.isBlank()){
+ searchItemsFlow.update { emptyList() }
+ } else {
+ performTypeaheadSearch()
+ }
+ }.launchIn(viewModelScope)
+ }
fun updateSearchText(text: String) {
- typeaheadMode.postValue(true)
- searchTextLiveData.value = text
-
- if (text == "") {
- searchItemsLiveData.value = listOf()
- } else {
- viewModelScope.launch {
- performTypeaheadSearch(true)
- }
- }
+ typeaheadMode.update { true }
+ searchTextFlow.update { text }
}
fun performSearch() {
// To perform search we just clear the current state, so the LibraryView infinite scroll
// load will update items.
viewModelScope.launch {
- isRefreshing.postValue(true)
- itemsLiveData.postValue(listOf())
- typeaheadMode.postValue(false)
+ isRefreshing.update { true }
+ itemsState.update { (listOf()) }
+ typeaheadMode.update { false }
loadUsingSearchAPI()
}
@@ -79,7 +80,6 @@ class SearchViewModel @Inject constructor(
private fun loadUsingSearchAPI() {
viewModelScope.launch {
- val context = applicationContext
withContext(Dispatchers.IO) {
val result = dataService.librarySearch(
context = applicationContext,
@@ -122,40 +122,24 @@ class SearchViewModel @Inject constructor(
}
*/
- itemsLiveData.value?.let {
- itemsLiveData.postValue(newItems + it)
- } ?: run {
- itemsLiveData.postValue(newItems)
+ itemsState.update {
+ it + newItems
}
- isRefreshing.postValue(false)
+ isRefreshing.update { false }
}
}
}
- private suspend fun performTypeaheadSearch(clearPreviousSearch: Boolean) {
- if (clearPreviousSearch) {
- cursor = null
- }
-
- val thisSearchIdx = searchIdx
- searchIdx += 1
+ private suspend fun performTypeaheadSearch() {
+ isRefreshing.update { true }
// Execute the search
- val searchResult = networker.typeaheadSearch(searchTextLiveData.value ?: "")
+ val searchResult = repository.getTypeaheadData(searchTextFlow.value)
- // Search results aren't guaranteed to return in order so this
- // will discard old results that are returned while a user is typing.
- // For example if a user types 'Canucks', often the search results
- // for 'C' are returned after 'Canucks' because it takes the backend
- // much longer to compute.
- if (thisSearchIdx in 1..receivedIdx) {
- return
- }
+ searchItemsFlow.update { searchResult }
- searchItemsLiveData.postValue(searchResult.cardsData)
-
- isRefreshing.postValue(false)
+ isRefreshing.update { false }
}
override fun handleSavedItemAction(itemId: String, action: SavedItemAction) {
@@ -219,14 +203,5 @@ class SearchViewModel @Inject constructor(
// }
}
- private fun searchQueryString(): String {
- var query = ""
- val searchText = searchTextLiveData.value ?: ""
-
- if (searchText.isNotEmpty()) {
- query += " $searchText"
- }
-
- return query
- }
+ private fun searchQueryString() = " ${searchTextFlow.value.ifBlank { "" }}"
}
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt
index 5a7c39493..cd81bb79e 100644
--- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt
@@ -20,7 +20,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -98,11 +98,10 @@ fun RootView(
startDestination = startDestination,
loginViewModel = loginViewModel
)
- DisposableEffect(hasAuthToken) {
+ LaunchedEffect(hasAuthToken) {
if (hasAuthToken) {
loginViewModel.registerUser()
}
- onDispose {}
}
}
}
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt
index f1e6dbf1e..9a2c99ae1 100644
--- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt
@@ -5,7 +5,6 @@ import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
-import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -13,178 +12,220 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
-import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment.Companion.TopCenter
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
+import androidx.work.Constraints
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import app.omnivore.omnivore.feature.library.LibrarySyncWorker
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
-
-// Not sure why we need this class, but directly opening SaveSheetActivity
-// causes the app to crash.
-class SaveSheetActivity : SaveSheetActivityBase()
+import kotlin.time.toJavaDuration
@AndroidEntryPoint
@OptIn(ExperimentalMaterialApi::class)
-abstract class SaveSheetActivityBase : AppCompatActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
+class SaveSheetActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
- val viewModel: SaveViewModel by viewModels()
- var extractedText: String?
+ var extractedText: String? = null
+ var saveState: SaveState by mutableStateOf(SaveState.DEFAULT)
+ val workManager = WorkManager.getInstance(applicationContext)
- when (intent?.action) {
- Intent.ACTION_SEND -> {
- if (intent.type?.startsWith("text/plain") == true) {
- intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
- extractedText = it
- viewModel.saveURL(it)
- Log.d(ContentValues.TAG, "Extracted text: $extractedText")
- }
+ when (intent?.action) {
+ Intent.ACTION_SEND -> {
+ if (intent.type?.startsWith("text/plain") == true) {
+ intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
+ extractedText = it
+ workManager.enqueueSaveWorker(it)
+ Log.d(ContentValues.TAG, "Extracted text: $extractedText")
+ }
+ }
+ if (intent.type?.startsWith("text/html") == true) {
+ intent.getStringExtra(Intent.EXTRA_HTML_TEXT)?.let {
+ extractedText = it
+ }
+ }
+ }
+
+ else -> {
+ // Handle other intents, such as being started from the home screen
+ }
}
- if (intent.type?.startsWith("text/html") == true) {
- intent.getStringExtra(Intent.EXTRA_HTML_TEXT)?.let {
- extractedText = it
- }
- }
- }
- else -> {
- // Handle other intents, such as being started from the home screen
- }
+ setContent {
+ LaunchedEffect(extractedText) {
+ extractedText?.let { url ->
+ workManager.getWorkInfosByTagFlow(url).map {
+ saveState = when (it.firstOrNull()?.state) {
+ WorkInfo.State.RUNNING -> SaveState.SAVING
+ WorkInfo.State.SUCCEEDED -> SaveState.SAVED
+ WorkInfo.State.FAILED -> SaveState.ERROR
+ else -> SaveState.SAVING
+ }
+ }.collect()
+ }
+ }
+
+ val scaffoldState: ScaffoldState = rememberScaffoldState()
+
+
+ val message = when (saveState) {
+ SaveState.DEFAULT -> ""
+ SaveState.SAVING -> "Saved to Omnivore"
+ SaveState.ERROR -> "Error Saving Article"
+ SaveState.SAVED -> "Saved to Omnivore"
+ }
+
+ Scaffold(
+ modifier = Modifier.clickable {
+ Log.d("debug", "DISMISS SCAFFOLD")
+ exit()
+ },
+ scaffoldState = scaffoldState,
+ backgroundColor = Color.Transparent,
+
+ // TODO: In future versions we can present Label, Note, Highlight options here
+ bottomBar = {
+
+ androidx.compose.material3.BottomAppBar(
+
+ modifier = Modifier
+ .height(55.dp)
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(topEnd = 5.dp, topStart = 5.dp)),
+ containerColor = MaterialTheme.colors.background,
+ actions = {
+ Spacer(modifier = Modifier.width(25.dp))
+ Text(
+ message,
+ style = androidx.compose.material3.MaterialTheme.typography.titleMedium
+ )
+ },
+ )
+ },
+ ) {
+
+ }
+
+ LaunchedEffect(saveState) {
+ if (saveState == SaveState.SAVED) {
+ delay(1.5.seconds)
+ exit()
+ }
+ }
+ }
}
- setContent {
- val saveState: SaveState by viewModel.state.observeAsState(SaveState.DEFAULT)
- val scaffoldState: ScaffoldState = rememberScaffoldState()
+ private fun WorkManager.enqueueSaveWorker(url: String) {
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ val saveWorkerRequest = OneTimeWorkRequestBuilder()
+ .setConstraints(constraints)
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .setInputData(workDataOf("url" to url))
+ .addTag(url)
+ // Can add other configs like setBackoffCriteria to retry sync if failed
+ .build()
+ val syncWorkerRequest = OneTimeWorkRequestBuilder()
+ .setConstraints(constraints)
+ .addTag(url)
+ .setInitialDelay(5.seconds.toJavaDuration())
+ .build()
- val message = when (saveState) {
- SaveState.DEFAULT -> ""
- SaveState.SAVING -> "Saved to Omnivore"
- SaveState.ERROR -> "Error Saving Article"
- SaveState.SAVED -> "Saved to Omnivore"
- }
-
- Scaffold(
- modifier = Modifier.clickable {
- Log.d("debug", "DISMISS SCAFFOLD")
- exit()
- },
- scaffoldState = scaffoldState,
- backgroundColor = Color.Transparent,
-
- // TODO: In future versions we can present Label, Note, Highlight options here
- bottomBar = {
-
- androidx.compose.material3.BottomAppBar(
+ beginWith(saveWorkerRequest)
+ .then(syncWorkerRequest)
+ .enqueue()
+ }
+ @Composable
+ private fun BottomSheetUI(content: @Composable () -> Unit) {
+ Box(
modifier = Modifier
- .height(55.dp)
- .fillMaxWidth()
- .clip(RoundedCornerShape(topEnd = 5.dp, topStart = 5.dp)),
- containerColor = MaterialTheme.colors.background,
- actions = {
- Spacer(modifier = Modifier.width(25.dp))
- Text(
- message,
- style = androidx.compose.material3.MaterialTheme.typography.titleMedium
- )
- },
- )
- },
- ) {
+ .wrapContentHeight()
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp))
+ .background(Color.White)
+ .statusBarsPadding()
+ ) {
+ content()
- }
-
- LaunchedEffect(saveState) {
- if (saveState == SaveState.SAVED) {
- delay(1.5.seconds)
- exit()
+ Divider(
+ color = Color.Gray,
+ thickness = 5.dp,
+ modifier = Modifier
+ .padding(top = 15.dp)
+ .align(TopCenter)
+ .width(80.dp)
+ .clip(RoundedCornerShape(50.dp))
+ )
}
- }
}
- }
- @Composable
- private fun BottomSheetUI(content: @Composable () -> Unit) {
- Box(
- modifier = Modifier
- .wrapContentHeight()
- .fillMaxWidth()
- .clip(RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp))
- .background(Color.White)
- .statusBarsPadding()
+ // Helper methods
+ private suspend fun handleBottomSheetAtHiddenState(
+ isSheetOpened: MutableState,
+ modalBottomSheetState: ModalBottomSheetState
) {
- content()
-
- Divider(
- color = Color.Gray,
- thickness = 5.dp,
- modifier = Modifier
- .padding(top = 15.dp)
- .align(TopCenter)
- .width(80.dp)
- .clip(RoundedCornerShape(50.dp))
- )
+ when {
+ !isSheetOpened.value -> initializeModalLayout(isSheetOpened, modalBottomSheetState)
+ else -> exit()
+ }
}
- }
- // Helper methods
- private suspend fun handleBottomSheetAtHiddenState(
- isSheetOpened: MutableState,
- modalBottomSheetState: ModalBottomSheetState
- ) {
- when {
- !isSheetOpened.value -> initializeModalLayout(isSheetOpened, modalBottomSheetState)
- else -> exit()
- }
- }
-
- private suspend fun initializeModalLayout(
- isSheetOpened: MutableState,
- modalBottomSheetState: ModalBottomSheetState
- ) {
- isSheetOpened.value = true
- modalBottomSheetState.show()
- }
-
- open fun exit() = finish()
-
- private fun onFinish(
- coroutineScope: CoroutineScope,
- modalBottomSheetState: ModalBottomSheetState,
- withResults: Boolean = false,
- result: Intent? = null
- ) {
- coroutineScope.launch {
- if (withResults) setResult(RESULT_OK)
- result?.let { intent = it }
- modalBottomSheetState.hide() // will trigger the LaunchedEffect
- }
- }
-
- @Composable
- fun ScreenContent(
- viewModel: SaveViewModel,
- modalBottomSheetState: ModalBottomSheetState
- ) {
- Box(
- modifier = Modifier
- .height(300.dp)
- .background(Color.White)
+ private suspend fun initializeModalLayout(
+ isSheetOpened: MutableState,
+ modalBottomSheetState: ModalBottomSheetState
) {
- SaveContent(viewModel, modalBottomSheetState, modifier = Modifier.fillMaxSize())
+ isSheetOpened.value = true
+ modalBottomSheetState.show()
}
- }
- override fun onPause() {
- super.onPause()
- overridePendingTransition(0, 0)
- }
+ open fun exit() = finish()
+
+ private fun onFinish(
+ coroutineScope: CoroutineScope,
+ modalBottomSheetState: ModalBottomSheetState,
+ withResults: Boolean = false,
+ result: Intent? = null
+ ) {
+ coroutineScope.launch {
+ if (withResults) setResult(RESULT_OK)
+ result?.let { intent = it }
+ modalBottomSheetState.hide() // will trigger the LaunchedEffect
+ }
+ }
+
+ @Composable
+ fun ScreenContent(
+ viewModel: SaveViewModel,
+ modalBottomSheetState: ModalBottomSheetState
+ ) {
+ Box(
+ modifier = Modifier
+ .height(300.dp)
+ .background(Color.White)
+ ) {
+ SaveContent(viewModel, modalBottomSheetState, modifier = Modifier.fillMaxSize())
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ overridePendingTransition(0, 0)
+ }
}
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveURLWorker.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveURLWorker.kt
new file mode 100644
index 000000000..b305dd8a9
--- /dev/null
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveURLWorker.kt
@@ -0,0 +1,81 @@
+package app.omnivore.omnivore.feature.save
+
+import android.content.Context
+import androidx.compose.ui.text.intl.Locale
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import app.omnivore.omnivore.core.datastore.DatastoreRepository
+import app.omnivore.omnivore.core.datastore.omnivoreAuthToken
+import app.omnivore.omnivore.graphql.generated.SaveUrlMutation
+import app.omnivore.omnivore.graphql.generated.type.SaveUrlInput
+import app.omnivore.omnivore.utils.Constants
+import com.apollographql.apollo3.ApolloClient
+import com.apollographql.apollo3.api.Optional
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.util.TimeZone
+import java.util.UUID
+import java.util.regex.Pattern
+
+@HiltWorker
+class SaveURLWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted workerParams: WorkerParameters,
+ private val datastoreRepository: DatastoreRepository,
+) : CoroutineWorker(appContext, workerParams) {
+
+ override suspend fun doWork(): Result {
+ return withContext(Dispatchers.IO){
+ val url = inputData.getString("url") ?: return@withContext Result.failure()
+ if (saveURL(url)) Result.success() else Result.failure()
+ }
+ }
+
+ private suspend fun saveURL(url: String): Boolean {
+
+ val authToken = datastoreRepository.getString(omnivoreAuthToken) ?: return false
+
+ val apolloClient = ApolloClient.Builder()
+ .serverUrl("${Constants.apiURL}/api/graphql")
+ .addHttpHeader("Authorization", value = authToken)
+ .build()
+
+ val cleanedUrl = cleanUrl(url) ?: url
+
+ try {
+ val timezone = TimeZone.getDefault().id
+ val locale = Locale.current.toLanguageTag()
+
+ val response = apolloClient.mutation(
+ SaveUrlMutation(
+ SaveUrlInput(
+ clientRequestId = UUID.randomUUID().toString(),
+ source = "android",
+ url = cleanedUrl,
+ timezone = Optional.present(timezone),
+ locale = Optional.present(locale)
+ )
+ )
+ ).execute()
+
+ return (response.data?.saveUrl?.onSaveSuccess?.url != null)
+ } catch (e: Exception) {
+ return false
+ }
+ }
+
+ private fun cleanUrl(text: String): String? {
+ val pattern = Pattern.compile("\\b(?:https?|ftp)://\\S+")
+ val matcher = pattern.matcher(text)
+
+ if (matcher.find()) {
+ return matcher.group()
+ }
+ return null
+ }
+
+
+}
diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/TypeaheadSearchCard.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/TypeaheadSearchCard.kt
index 2eb86e04a..156e49eac 100644
--- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/TypeaheadSearchCard.kt
+++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/TypeaheadSearchCard.kt
@@ -15,9 +15,9 @@ import app.omnivore.omnivore.feature.library.SavedItemAction
@OptIn(ExperimentalFoundationApi::class)
@Composable
-fun TypeaheadSearchCard(cardData: TypeaheadCardData, onClickHandler: () -> Unit, actionHandler: (SavedItemAction) -> Unit) {
+fun TypeaheadSearchCard(modifier: Modifier = Modifier, cardData: TypeaheadCardData, onClickHandler: () -> Unit, actionHandler: (SavedItemAction) -> Unit) {
Column(
- modifier = Modifier
+ modifier = modifier
.combinedClickable(
onClick = onClickHandler,
)
diff --git a/android/Omnivore/gradle/libs.versions.toml b/android/Omnivore/gradle/libs.versions.toml
index 3ab32681d..f875bf6c9 100644
--- a/android/Omnivore/gradle/libs.versions.toml
+++ b/android/Omnivore/gradle/libs.versions.toml
@@ -31,6 +31,8 @@ posthog = "2.0.3"
pspdfkit = "8.9.1"
retrofit = "2.11.0"
room = "2.6.1"
+workManager = "2.9.0"
+hiltWork = "1.2.0"
[libraries]
accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanistFlowLayout" }
@@ -81,6 +83,9 @@ room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "
android-hilt = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" }
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
+work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager"}
+hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltWork"}
+hilt-work-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltWork"}
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }