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/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/save/SaveSheetActivity.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt
index f1e6dbf1e..aaccf8e19 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,17 +12,27 @@ 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
+import kotlin.time.toJavaDuration
// Not sure why we need this class, but directly opening SaveSheetActivity
// causes the app to crash.
@@ -32,159 +41,196 @@ class SaveSheetActivity : SaveSheetActivityBase()
@AndroidEntryPoint
@OptIn(ExperimentalMaterialApi::class)
abstract class SaveSheetActivityBase : AppCompatActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
+ 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)
+ // delay to make sure the url title and content are fetched
+ .setInitialDelay(10.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/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" }