From 4c70c8621ffd25a08dc3c865874acd68fab4ceb4 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Wed, 29 May 2024 16:22:04 +0300 Subject: [PATCH] Added sync library after saving from share menu --- android/Omnivore/app/build.gradle.kts | 3 + .../Omnivore/app/src/main/AndroidManifest.xml | 6 + .../omnivore/omnivore/OmnivoreApplication.kt | 14 +- .../feature/library/LibrarySyncWorker.kt | 100 ++++++ .../feature/save/SaveSheetActivity.kt | 320 ++++++++++-------- .../omnivore/feature/save/SaveURLWorker.kt | 81 +++++ android/Omnivore/gradle/libs.versions.toml | 5 + 7 files changed, 391 insertions(+), 138 deletions(-) create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibrarySyncWorker.kt create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveURLWorker.kt 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" }