Added sync library after saving from share menu

This commit is contained in:
Mohamed
2024-05-29 16:22:04 +03:00
parent 0634d2549e
commit 4c70c8621f
7 changed files with 391 additions and 138 deletions

View File

@ -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 {

View File

@ -55,5 +55,11 @@
android:exported="true"
android:theme="@style/Theme.Omnivore"/>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove">
</provider>
</application>
</manifest>

View File

@ -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()

View File

@ -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
}
}
}
}

View File

@ -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<SaveURLWorker>()
.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<LibrarySyncWorker>()
.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<Boolean>,
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<Boolean>,
modalBottomSheetState: ModalBottomSheetState
) {
when {
!isSheetOpened.value -> initializeModalLayout(isSheetOpened, modalBottomSheetState)
else -> exit()
}
}
private suspend fun initializeModalLayout(
isSheetOpened: MutableState<Boolean>,
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<Boolean>,
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)
}
}

View File

@ -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
}
}

View File

@ -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" }