Merge pull request #4013 from mhss1/main

Android app improvements
This commit is contained in:
Jackson Harper
2024-05-31 17:47:32 +08:00
committed by GitHub
17 changed files with 489 additions and 257 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

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

View File

@ -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<TypeaheadCardData> {
return withContext(Dispatchers.IO) {
savedItemDao.getTypeaheadData(query)
}
}
override suspend fun updateReadingProgress(
itemId: String,
readingProgressPercentage: Double,

View File

@ -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<TypeaheadCardData>
@Query("SELECT * FROM savedItem WHERE serverSyncStatus != 0")
fun getUnSynced(): List<SavedItem>

View File

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

View File

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

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

@ -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<SavedItemLabel>,
sortFilter: SavedItemSortFilter,
updateSavedItemFilter:(SavedItemFilter) -> Unit,
updateSavedItemFilter: (SavedItemFilter) -> Unit,
updateSavedItemSortFilter: (SavedItemSortFilter) -> Unit,
setBottomSheetState: (LibraryBottomSheetState) -> Unit,
updateAppliedLabels: (List<SavedItemLabel>) -> 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()
}
}
}

View File

@ -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<TypeaheadCardData> 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<SavedItemWithLabelsAndHighlights> by viewModel.itemsLiveData.observeAsState(
listOf()
)
val cardsData: List<SavedItemWithLabelsAndHighlights> by viewModel.itemsState.collectAsStateWithLifecycle()
LazyColumn(
state = listState,

View File

@ -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<String>(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<List<TypeaheadCardData>>(listOf())
val itemsLiveData = MediatorLiveData<List<SavedItemWithLabelsAndHighlights>>()
var isRefreshing = MutableStateFlow(false)
val typeaheadMode = MutableStateFlow(true)
val searchTextFlow = MutableStateFlow("")
val searchItemsFlow = MutableStateFlow<List<TypeaheadCardData>>(emptyList())
val itemsState = MutableStateFlow<List<SavedItemWithLabelsAndHighlights>>(emptyList())
override val actionsMenuItemLiveData = MutableLiveData<SavedItemWithLabelsAndHighlights?>(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 { "" }}"
}

View File

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

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,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<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)
.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<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

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

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