Merge all changes from main, update theming of Discover

This commit is contained in:
Thomas Rogers
2024-03-07 17:39:57 +01:00
691 changed files with 82404 additions and 41037 deletions

View File

@ -33,6 +33,7 @@
}
],
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-return": "warn"
"@typescript-eslint/no-unsafe-return": "warn",
"@typescript-eslint/no-unsafe-argument": "warn"
}
}

View File

@ -0,0 +1,32 @@
name: Run tests
on:
push:
branches:
- main
paths-ignore:
- 'apple/**'
- 'android/**'
jobs:
build-docker-images:
name: Build docker images
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: 'Login to GitHub container registry'
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{secrets.GITHUB_TOKEN}}
- name: Build the backend docker image
run: |
docker build . --file packages/api/Dockerfile --tag "ghcr.io/omnivore-app/backend:${GITHUB_SHA}" --tag ghcr.io/omnivore-app/backend:latest
docker push ghcr.io/omnivore-app/backend:${GITHUB_SHA}
- name: Build the content-fetch docker image
run: |
docker build --file packages/content-fetch/Dockerfile . --tag "ghcr.io/omnivore-app/content-fetch:${GITHUB_SHA}" --tag ghcr.io/omnivore-app/content-fetch:latest
docker push ghcr.io/omnivore-app/content-fetch:${GITHUB_SHA}

View File

@ -5,11 +5,13 @@ on:
- main
paths-ignore:
- 'apple/**'
- 'android/**'
pull_request:
branches:
- main
paths-ignore:
- 'apple/**'
- 'android/**'
env:
NEXT_PUBLIC_APP_ENV: prod
@ -93,11 +95,13 @@ jobs:
PG_DB: omnivore_test
PG_LOGGER: debug
REDIS_URL: redis://localhost:${{ job.services.redis.ports[6379] }}
MQ_REDIS_URL: redis://localhost:${{ job.services.redis.ports[6379] }}
build-docker-images:
name: Build docker images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Build the API docker image

View File

@ -50,13 +50,13 @@ Omnivore takes advantage of some great open source software:
- [Stitches](https://stitches.dev/) - We use Stitches on the frontend to style our components.
- [Mozilla Readability](https://github.com/mozilla/readability) - We use Mozilla's Readability library to make pages easier to read.
- [Swift GraphQL](https://www.swift-graphql.com/) - We generate our GraphQL queries on iOS using Swift GraphQL.
- [Apollo GraphQL](https://www.apollographql.com/) - We generate our GraphQL queries on Android using Apollo GraphQL.
- [Radix](https://www.radix-ui.com/) - We use Radix UI's components on our frontend.
- And many more awesome libraries, just checkout our package files to see what we are using.
## Importing Libraries
If you have a library you'd like to import, [@davidohlin](https://github.com/davidohlin) has created
a tool that imports a list of CSV URLs: [omnivore-import](https://github.com/davidohlin/instapaper-to-omnivore-import)
Check out our [docs](https://docs.omnivore.app/using/importing.html) for information on importing your data from other apps.
## How to setup local development :computer:
@ -66,7 +66,7 @@ The easiest way to get started with local development is to use `docker compose
Omnivore is written in TypeScript and JavaScript.
- [Node](https://nodejs.org/) -- currently we are using Node.js v14.18
- [Node](https://nodejs.org/) -- currently we are using Node.js v18.16
- [Chromium](https://www.chromium.org/chromium-projects/) -- see below for installation info
### Running the web and API services

View File

@ -1,172 +0,0 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
id 'com.apollographql.apollo3' version '3.7.2'
}
def keystorePropertiesFile = rootProject.file("app/external/keystore.properties");
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
compileSdk 33
defaultConfig {
applicationId "app.omnivore.omnivore"
minSdk 26
targetSdk 33
versionCode 158
versionName "0.0.158"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}
signingConfigs {
release {
keyAlias 'key0'
storeFile file('external/omnivore-prod.keystore')
storePassword keystoreProperties['prodStorePassword']
keyPassword keystoreProperties['prodKeyPassword']
}
debug {
if (keystoreProperties['demoStorePassword'] && keystoreProperties['demoKeyPassword']) {
keyAlias 'androiddebugkey'
storeFile file('external/omnivore-demo.keystore')
storePassword keystoreProperties['demoStorePassword']
keyPassword keystoreProperties['demoKeyPassword']
}
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
buildConfigField("String", "OMNIVORE_API_URL", "\"https://api-demo.omnivore.app\"")
buildConfigField("String", "OMNIVORE_WEB_URL", "\"https://demo.omnivore.app\"")
buildConfigField("String", "OMNIVORE_GAUTH_SERVER_CLIENT_ID", "\"267918240109-eu2ar09unac3lqqigluknhk7t0021b54.apps.googleusercontent.com\"")
}
release {
minifyEnabled false
signingConfig signingConfigs.release
buildConfigField("String", "OMNIVORE_API_URL", "\"https://api-prod.omnivore.app\"")
buildConfigField("String", "OMNIVORE_WEB_URL", "\"https://omnivore.app\"")
buildConfigField("String", "OMNIVORE_GAUTH_SERVER_CLIENT_ID", "\"687911924401-lq8j1e97n0sv3khhb8g8n368lk4dqkbp.apps.googleusercontent.com\"")
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = '1.3.1'
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
namespace 'app.omnivore.omnivore'
}
dependencies {
def nav_version = "2.5.3"
implementation 'androidx.core:core-ktx:1.9.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
implementation 'androidx.activity:activity-compose:1.6.1'
implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'com.google.android.gms:play-services-base:18.1.0'
implementation "androidx.navigation:navigation-compose:$nav_version"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
// Jetpack Lifecycle deps
// ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version")
// ViewModel utilities for Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version")
// LiveData
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version")
implementation("androidx.compose.runtime:runtime-livedata:1.3.2")
// Saved state module for ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version")
// Annotation processor
implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycle_version")
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
// coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
implementation "androidx.security:security-crypto:1.0.0"
implementation "androidx.datastore:datastore-preferences:1.0.0"
//Dagger - Hilt
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
implementation 'com.apollographql.apollo3:apollo-runtime:3.7.2'
implementation 'androidx.compose.material3:material3:1.1.2'
implementation 'androidx.compose.material3:material3-window-size-class:1.1.2'
implementation 'com.google.android.gms:play-services-auth:20.4.0'
implementation "com.google.accompanist:accompanist-systemuicontroller:0.25.1"
implementation "com.google.accompanist:accompanist-flowlayout:0.25.1"
implementation 'io.coil-kt:coil-compose:2.3.0'
implementation 'com.google.code.gson:gson:2.9.0'
implementation 'com.pspdfkit:pspdfkit:8.9.1'
implementation 'com.posthog.android:posthog:2.0.3'
implementation 'io.intercom.android:intercom-sdk:15.1.0'
// Room Deps
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-ktx:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation 'com.github.jeziellago:compose-markdown:0.3.3'
implementation "io.github.dokar3:chiptextfield:0.4.7"
}
apollo {
packageName.set 'app.omnivore.omnivore.graphql.generated'
}
task printVersion {
doLast {
println "omnivoreVersion: ${android.defaultConfig.versionName}"
}
}

View File

@ -0,0 +1,186 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
kotlin("android")
id("dagger.hilt.android.plugin")
alias(libs.plugins.ksp)
alias(libs.plugins.apollo)
}
val keystorePropertiesFile = rootProject.file("app/external/keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
FileInputStream(keystorePropertiesFile).use { input ->
keystoreProperties.load(input)
}
}
android {
namespace = "app.omnivore.omnivore"
compileSdk = 34
defaultConfig {
applicationId = "app.omnivore.omnivore"
minSdk = 26
targetSdk = 34
versionCode = 194001
versionName = "0.195.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
signingConfigs {
create("release") {
keyAlias = "key0"
storeFile = file("external/omnivore-prod.keystore")
storePassword = keystoreProperties["prodStorePassword"] as String?
keyPassword = keystoreProperties["prodKeyPassword"] as String?
}/* debug {
if (keystoreProperties["demoStorePassword"] && keystoreProperties["demoKeyPassword"]) {
keyAlias = "androiddebugkey"
storeFile = file("external/omnivore-demo.keystore")
storePassword = keystoreProperties["demoStorePassword"] as String
keyPassword = keystoreProperties["demoKeyPassword"] as String
}
}*/
}
buildTypes {
debug {
signingConfig = signingConfigs.getByName("debug")
applicationIdSuffix = ".debug"
buildConfigField("String", "OMNIVORE_API_URL", "\"https://api-demo.omnivore.app\"")
buildConfigField("String", "OMNIVORE_WEB_URL", "\"https://demo.omnivore.app\"")
buildConfigField(
"String",
"OMNIVORE_GAUTH_SERVER_CLIENT_ID",
"\"267918240109-eu2ar09unac3lqqigluknhk7t0021b54.apps.googleusercontent.com\""
)
}
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
buildConfigField("String", "OMNIVORE_API_URL", "\"https://api-prod.omnivore.app\"")
buildConfigField("String", "OMNIVORE_WEB_URL", "\"https://omnivore.app\"")
buildConfigField(
"String",
"OMNIVORE_GAUTH_SERVER_CLIENT_ID",
"\"687911924401-lq8j1e97n0sv3khhb8g8n368lk4dqkbp.apps.googleusercontent.com\""
)
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get()
}
packaging {
resources {
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}")
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.gms.playServicesBase)
implementation(libs.gms.playServicesAuth)
val bom = platform(libs.androidx.compose.bom)
implementation(bom)
androidTestImplementation(bom)
implementation(libs.androidx.compose.material)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.ui.util)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
androidTestImplementation(libs.androidx.compose.ui.test)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.testManifest)
testImplementation(libs.junit4)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.test.espresso.core)
implementation(libs.androidx.lifecycle.viewModelKtx)
implementation(libs.androidx.lifecycle.viewModelCompose)
implementation(libs.androidx.lifecycle.viewmodelSavedstate)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.compose.runtime.livedata)
implementation(libs.retrofit.core)
implementation(libs.retrofit.converter.gson)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.dataStore.preferences)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.apollo.runtime)
implementation(libs.accompanist.flowlayout)
implementation(libs.coil.kt.compose)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
annotationProcessor(libs.room.compiler)
ksp(libs.room.compiler)
implementation(libs.gson)
implementation(libs.pspdfkit)
implementation(libs.posthog)
implementation(libs.intercom)
implementation(libs.compose.markdown)
implementation(libs.chiptextfield.m3)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.core.splashscreen)
}
apollo {
service("service") {
outputDirConnection {
connectToKotlinSourceSet("main")
}
packageName.set("app.omnivore.omnivore.graphql.generated")
}
}
tasks.register("printVersion") {
doLast {
println("omnivoreVersion: ${android.defaultConfig.versionName}")
}
}

View File

@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
@ -18,4 +18,4 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Dev - Omnivore</string>
</resources>

View File

@ -15,14 +15,15 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Omnivore"
android:largeHeap="true"
android:theme="@style/Theme.AppCompat.Translucent"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Omnivore">
android:theme="@style/Theme.Omnivore.Splash"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@ -30,9 +31,8 @@
</activity>
<activity
android:name=".ui.save.SaveSheetActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Translucent">
android:name=".feature.save.SaveSheetActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@ -46,12 +46,12 @@
android:windowSoftInputMode="adjustNothing" />
<activity
android:name=".ui.reader.PDFReaderActivity"
android:name=".feature.reader.PDFReaderActivity"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:windowSoftInputMode="adjustNothing" />
<activity
android:name=".ui.reader.WebReaderLoadingContainerActivity"
android:name=".feature.reader.WebReaderLoadingContainerActivity"
android:exported="true"
android:theme="@style/Theme.Omnivore"/>

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,10 @@
query UpdatesSince($after: String, $first: Int, $since: Date!) {
updatesSince(after: $after, first: $first, since: $since) {
query UpdatesSince(
$folder: String
$after: String
$first: Int
$since: Date!
) {
updatesSince(after: $after, first: $first, folder: $folder, since: $since) {
... on UpdatesSinceSuccess {
edges {
cursor

View File

@ -69,6 +69,7 @@ type Article {
contentReader: ContentReader!
createdAt: Date!
description: String
folder: String!
hasContent: Boolean
hash: String!
highlights(input: ArticleHighlightsInput): [Highlight!]!
@ -154,6 +155,7 @@ union ArticleSavingRequestResult = ArticleSavingRequestError | ArticleSavingRequ
enum ArticleSavingRequestStatus {
ARCHIVED
CONTENT_NOT_FETCHED
DELETED
FAILED
PROCESSING
@ -203,6 +205,7 @@ enum BulkActionType {
ARCHIVE
DELETE
MARK_AS_READ
MOVE_TO_FOLDER
}
enum ContentReader {
@ -227,8 +230,12 @@ enum CreateArticleErrorCode {
input CreateArticleInput {
articleSavingRequestId: ID
folder: String
labels: [CreateLabelInput!]
preparedDocument: PreparedDocumentInput
publishedAt: Date
rssFeedUrl: String
savedAt: Date
skipParsing: Boolean
source: String
state: ArticleSavingRequestStatus
@ -377,6 +384,12 @@ enum CreateNewsletterEmailErrorCode {
UNAUTHORIZED
}
input CreateNewsletterEmailInput {
description: String
folder: String
name: String
}
union CreateNewsletterEmailResult = CreateNewsletterEmailError | CreateNewsletterEmailSuccess
type CreateNewsletterEmailSuccess {
@ -631,6 +644,20 @@ type DeviceTokensSuccess {
deviceTokens: [DeviceToken!]!
}
type EmptyTrashError {
errorCodes: [EmptyTrashErrorCode!]!
}
enum EmptyTrashErrorCode {
UNAUTHORIZED
}
union EmptyTrashResult = EmptyTrashError | EmptyTrashSuccess
type EmptyTrashSuccess {
success: Boolean
}
type Feature {
createdAt: Date!
expiresAt: Date
@ -641,6 +668,19 @@ type Feature {
updatedAt: Date
}
type Feed {
author: String
createdAt: Date
description: String
id: ID
image: String
publishedAt: Date
title: String!
type: String
updatedAt: Date
url: String!
}
type FeedArticle {
annotationsCount: Int
article: Article!
@ -674,12 +714,56 @@ type FeedArticlesSuccess {
pageInfo: PageInfo!
}
type FeedEdge {
cursor: String!
node: Feed!
}
type FeedsError {
errorCodes: [FeedsErrorCode!]!
}
enum FeedsErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
input FeedsInput {
after: String
first: Int
query: String
sort: SortParams
}
union FeedsResult = FeedsError | FeedsSuccess
type FeedsSuccess {
edges: [FeedEdge!]!
pageInfo: PageInfo!
}
type FetchContentError {
errorCodes: [FetchContentErrorCode!]!
}
enum FetchContentErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
union FetchContentResult = FetchContentError | FetchContentSuccess
type FetchContentSuccess {
success: Boolean!
}
type Filter {
category: String!
category: String
createdAt: Date!
defaultFilter: Boolean
description: String
filter: String!
folder: String
id: ID!
name: String!
position: Int!
@ -901,6 +985,8 @@ type IntegrationsSuccess {
integrations: [Integration!]!
}
scalar JSON
type JoinGroupError {
errorCodes: [JoinGroupErrorCode!]!
}
@ -925,6 +1011,7 @@ type Label {
internal: Boolean
name: String!
position: Int
source: String
}
type LabelsError {
@ -1107,15 +1194,31 @@ type MoveLabelSuccess {
label: Label!
}
type MoveToFolderError {
errorCodes: [MoveToFolderErrorCode!]!
}
enum MoveToFolderErrorCode {
ALREADY_EXISTS
BAD_REQUEST
UNAUTHORIZED
}
union MoveToFolderResult = MoveToFolderError | MoveToFolderSuccess
type MoveToFolderSuccess {
success: Boolean!
}
type Mutation {
addPopularRead(name: String!): AddPopularReadResult!
bulkAction(action: BulkActionType!, async: Boolean, expectedCount: Int, labelIds: [ID!], query: String!): BulkActionResult!
bulkAction(action: BulkActionType!, arguments: JSON, async: Boolean, expectedCount: Int, labelIds: [ID!], query: String!): BulkActionResult!
createArticle(input: CreateArticleInput!): CreateArticleResult!
createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult!
createGroup(input: CreateGroupInput!): CreateGroupResult!
createHighlight(input: CreateHighlightInput!): CreateHighlightResult!
createLabel(input: CreateLabelInput!): CreateLabelResult!
createNewsletterEmail: CreateNewsletterEmailResult!
createNewsletterEmail(input: CreateNewsletterEmailInput): CreateNewsletterEmailResult!
deleteAccount(userID: ID!): DeleteAccountResult!
deleteFilter(id: ID!): DeleteFilterResult!
deleteHighlight(highlightId: ID!): DeleteHighlightResult!
@ -1124,6 +1227,8 @@ type Mutation {
deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult!
deleteRule(id: ID!): DeleteRuleResult!
deleteWebhook(id: ID!): DeleteWebhookResult!
emptyTrash: EmptyTrashResult!
fetchContent(id: ID!): FetchContentResult!
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
googleLogin(input: GoogleLoginInput!): LoginResult!
googleSignup(input: GoogleSignupInput!): GoogleSignupResult!
@ -1135,6 +1240,7 @@ type Mutation {
mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult!
moveFilter(input: MoveFilterInput!): MoveFilterResult!
moveLabel(input: MoveLabelInput!): MoveLabelResult!
moveToFolder(folder: String!, id: ID!): MoveToFolderResult!
optInFeature(input: OptInFeatureInput!): OptInFeatureResult!
recommend(input: RecommendInput!): RecommendResult!
recommendHighlights(input: RecommendHighlightsInput!): RecommendHighlightsResult!
@ -1161,6 +1267,7 @@ type Mutation {
updateFilter(input: UpdateFilterInput!): UpdateFilterResult!
updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult!
updateLabel(input: UpdateLabelInput!): UpdateLabelResult!
updateNewsletterEmail(input: UpdateNewsletterEmailInput!): UpdateNewsletterEmailResult!
updatePage(input: UpdatePageInput!): UpdatePageResult!
updateSubscription(input: UpdateSubscriptionInput!): UpdateSubscriptionResult!
updateUser(input: UpdateUserInput!): UpdateUserResult!
@ -1173,7 +1280,10 @@ type NewsletterEmail {
address: String!
confirmationCode: String
createdAt: Date!
description: String
folder: String!
id: ID!
name: String
subscriptionCount: Int!
}
@ -1292,6 +1402,7 @@ type Query {
article(format: String, slug: String!, username: String!): ArticleResult!
articleSavingRequest(id: ID, url: String): ArticleSavingRequestResult!
deviceTokens: DeviceTokensResult!
feeds(input: FeedsInput!): FeedsResult!
filters: FiltersResult!
getUserPersonalization: GetUserPersonalizationResult!
groups: GroupsResult!
@ -1303,11 +1414,12 @@ type Query {
recentEmails: RecentEmailsResult!
recentSearches: RecentSearchesResult!
rules(enabled: Boolean): RulesResult!
scanFeeds(input: ScanFeedsInput!): ScanFeedsResult!
search(after: String, first: Int, format: String, includeContent: Boolean, query: String): SearchResult!
sendInstallInstructions: SendInstallInstructionsResult!
subscriptions(sort: SortParams, type: SubscriptionType): SubscriptionsResult!
typeaheadSearch(first: Int, query: String!): TypeaheadSearchResult!
updatesSince(after: String, first: Int, since: Date!, sort: SortParams): UpdatesSinceResult!
updatesSince(after: String, first: Int, folder: String, since: Date!, sort: SortParams): UpdatesSinceResult!
user(userId: ID, username: String): UserResult!
users: UsersResult!
validateUsername(username: String!): Boolean!
@ -1604,6 +1716,7 @@ enum SaveErrorCode {
input SaveFileInput {
clientRequestId: ID!
folder: String
labels: [CreateLabelInput!]
source: String!
state: ArticleSavingRequestStatus
@ -1625,6 +1738,7 @@ input SaveFilterInput {
category: String
description: String
filter: String!
folder: String
name: String!
position: Int
}
@ -1637,6 +1751,7 @@ type SaveFilterSuccess {
input SavePageInput {
clientRequestId: ID!
folder: String
labels: [CreateLabelInput!]
originalContent: String!
parseResult: ParseResult
@ -1658,6 +1773,7 @@ type SaveSuccess {
input SaveUrlInput {
clientRequestId: ID!
folder: String
labels: [CreateLabelInput!]
locale: String
publishedAt: Date
@ -1668,6 +1784,25 @@ input SaveUrlInput {
url: String!
}
type ScanFeedsError {
errorCodes: [ScanFeedsErrorCode!]!
}
enum ScanFeedsErrorCode {
BAD_REQUEST
}
input ScanFeedsInput {
opml: String
url: String
}
union ScanFeedsResult = ScanFeedsError | ScanFeedsSuccess
type ScanFeedsSuccess {
feeds: [Feed!]!
}
type SearchError {
errorCodes: [SearchErrorCode!]!
}
@ -1686,16 +1821,20 @@ type SearchItem {
contentReader: ContentReader!
createdAt: Date!
description: String
folder: String!
highlights: [Highlight!]
id: ID!
image: String
isArchived: Boolean!
labels: [Label!]
language: String
links: JSON
originalArticleUrl: String
ownedByViewer: Boolean
pageId: ID
pageType: PageType!
previewContent: String
previewContentType: String
publishedAt: Date
quote: String
readAt: Date
@ -1844,6 +1983,7 @@ input SetIntegrationInput {
importItemState: ImportItemState
name: String!
syncedAt: Date
taskName: String
token: String!
type: IntegrationType
}
@ -1874,6 +2014,7 @@ input SetLabelsInput {
labelIds: [ID!]
labels: [CreateLabelInput!]
pageId: ID!
source: String
}
union SetLabelsResult = SetLabelsError | SetLabelsSuccess
@ -1963,6 +2104,7 @@ enum SetUserPersonalizationErrorCode {
}
input SetUserPersonalizationInput {
fields: JSON
fontFamily: String
fontSize: Int
libraryLayoutType: String
@ -2068,6 +2210,10 @@ enum SubscribeErrorCode {
}
input SubscribeInput {
autoAddToLibrary: Boolean
fetchContent: Boolean
folder: String
isPrivate: Boolean
subscriptionType: SubscriptionType
url: String!
}
@ -2079,11 +2225,15 @@ type SubscribeSuccess {
}
type Subscription {
autoAddToLibrary: Boolean
count: Int!
createdAt: Date!
description: String
fetchContent: Boolean!
folder: String!
icon: String
id: ID!
isPrivate: Boolean
lastFetchedAt: Date
name: String!
newsletterEmail: String
@ -2203,6 +2353,7 @@ input UpdateFilterInput {
category: String
description: String
filter: String
folder: String
id: String!
name: String
position: Int
@ -2307,6 +2458,28 @@ type UpdateLinkShareInfoSuccess {
message: String!
}
type UpdateNewsletterEmailError {
errorCodes: [UpdateNewsletterEmailErrorCode!]!
}
enum UpdateNewsletterEmailErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
input UpdateNewsletterEmailInput {
description: String
folder: String
id: ID!
name: String
}
union UpdateNewsletterEmailResult = UpdateNewsletterEmailError | UpdateNewsletterEmailSuccess
type UpdateNewsletterEmailSuccess {
newsletterEmail: NewsletterEmail!
}
type UpdatePageError {
errorCodes: [UpdatePageErrorCode!]!
}
@ -2397,8 +2570,12 @@ enum UpdateSubscriptionErrorCode {
}
input UpdateSubscriptionInput {
autoAddToLibrary: Boolean
description: String
fetchContent: Boolean
folder: String
id: ID!
isPrivate: Boolean
lastFetchedAt: Date
lastFetchedChecksum: String
name: String
@ -2557,6 +2734,7 @@ enum UserErrorCode {
}
type UserPersonalization {
fields: JSON
fontFamily: String
fontSize: Int
id: ID

View File

@ -1,45 +0,0 @@
package app.omnivore.omnivore
import android.content.Context
import com.posthog.android.PostHog
import com.posthog.android.Properties
import io.intercom.android.sdk.Intercom
import io.intercom.android.sdk.identity.Registration
import org.json.JSONObject
import javax.inject.Inject
class EventTracker @Inject constructor(val app: Context) {
private val posthog: PostHog
init {
val posthogClientKey = app.getString(R.string.posthog_client_key)
val posthogInstanceAddress = app.getString(R.string.posthog_instance_address)
posthog = PostHog.Builder(app, posthogClientKey, posthogInstanceAddress)
.captureApplicationLifecycleEvents()
.collectDeviceId(false)
.build()
PostHog.setSingletonInstance(posthog)
}
fun registerUser(userID: String, intercomHash: String?, isDebug: Boolean) {
posthog.identify(userID)
if (!isDebug) {
Intercom.client().loginIdentifiedUser(Registration.create().withUserId(userID))
intercomHash?.let { intercomHash ->
Intercom.client().setUserHash(intercomHash)
}
}
}
fun track(eventName: String, properties: Properties = Properties()) {
posthog.capture(eventName, properties)
}
fun logout() {
posthog.reset()
}
}

View File

@ -4,81 +4,83 @@ import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import app.omnivore.omnivore.ui.auth.LoginViewModel
import app.omnivore.omnivore.ui.components.LabelsViewModel
import app.omnivore.omnivore.ui.editinfo.EditInfoViewModel
import app.omnivore.omnivore.ui.library.LibraryViewModel
import app.omnivore.omnivore.ui.library.SearchViewModel
import app.omnivore.omnivore.ui.root.RootView
import app.omnivore.omnivore.ui.save.SaveViewModel
import app.omnivore.omnivore.ui.settings.SettingsViewModel
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
import app.omnivore.omnivore.feature.auth.LoginViewModel
import app.omnivore.omnivore.feature.components.LabelsViewModel
import app.omnivore.omnivore.feature.editinfo.EditInfoViewModel
import app.omnivore.omnivore.feature.library.SearchViewModel
import app.omnivore.omnivore.feature.root.RootView
import app.omnivore.omnivore.feature.save.SaveViewModel
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
import com.pspdfkit.PSPDFKit
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@OptIn(DelicateCoroutinesApi::class)
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
override fun onCreate(savedInstanceState: Bundle?) {
val loginViewModel: LoginViewModel by viewModels()
val libraryViewModel: LibraryViewModel by viewModels()
val settingsViewModel: SettingsViewModel by viewModels()
val searchViewModel: SearchViewModel by viewModels()
val labelsViewModel: LabelsViewModel by viewModels()
val saveViewModel: SaveViewModel by viewModels()
val editInfoViewModel: EditInfoViewModel by viewModels()
installSplashScreen()
val context = this
super.onCreate(savedInstanceState)
GlobalScope.launch(Dispatchers.IO) {
val licenseKey = getString(R.string.pspdfkit_license_key)
val loginViewModel: LoginViewModel by viewModels()
val searchViewModel: SearchViewModel by viewModels()
val labelsViewModel: LabelsViewModel by viewModels()
val saveViewModel: SaveViewModel by viewModels()
val editInfoViewModel: EditInfoViewModel by viewModels()
if (licenseKey.length > 30) {
PSPDFKit.initialize(context, licenseKey)
} else {
PSPDFKit.initialize(context, null)
}
}
val context = this
setContent {
OmnivoreTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Black)
) {
RootView(
loginViewModel,
searchViewModel,
libraryViewModel,
settingsViewModel,
labelsViewModel,
saveViewModel,
editInfoViewModel)
GlobalScope.launch(Dispatchers.IO) {
val licenseKey = getString(R.string.pspdfkit_license_key)
if (licenseKey.length > 30) {
PSPDFKit.initialize(context, licenseKey)
} else {
PSPDFKit.initialize(context, null)
}
}
}
}
// animate the view up when keyboard appears
WindowCompat.setDecorFitsSystemWindows(window, false)
val rootView = findViewById<View>(android.R.id.content).rootView
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
rootView.setPadding(0, 0, 0, imeHeight)
insets
enableEdgeToEdge()
setContent {
OmnivoreTheme {
Box(
modifier = Modifier
.fillMaxSize()
) {
RootView(
loginViewModel,
searchViewModel,
labelsViewModel,
saveViewModel,
editInfoViewModel
)
}
}
}
// animate the view up when keyboard appears
WindowCompat.setDecorFitsSystemWindows(window, false)
val rootView = findViewById<View>(android.R.id.content).rootView
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
rootView.setPadding(0, 0, 0, imeHeight)
insets
}
}
}
}

View File

@ -1,11 +0,0 @@
package app.omnivore.omnivore
sealed class Routes(val route: String) {
object Library : Routes("Library")
object Settings: Routes("Settings")
object Search: Routes("Search")
object Documentation: Routes("Documentation")
object PrivacyPolicy: Routes("PrivacyPolicy")
object TermsAndConditions: Routes("TermsAndConditions")
object Notebook: Routes("Notebook")
}

View File

@ -0,0 +1,45 @@
package app.omnivore.omnivore.core.analytics
import android.content.Context
import app.omnivore.omnivore.R
import com.posthog.android.PostHog
import com.posthog.android.Properties
import io.intercom.android.sdk.Intercom
import io.intercom.android.sdk.identity.Registration
import javax.inject.Inject
class EventTracker @Inject constructor(val app: Context) {
private val posthog: PostHog
init {
val posthogClientKey = app.getString(R.string.posthog_client_key)
val posthogInstanceAddress = app.getString(R.string.posthog_instance_address)
posthog = PostHog.Builder(app, posthogClientKey, posthogInstanceAddress)
.captureApplicationLifecycleEvents()
.collectDeviceId(false)
.build()
PostHog.setSingletonInstance(posthog)
}
fun registerUser(userID: String, intercomHash: String?, isDebug: Boolean) {
posthog.identify(userID)
if (!isDebug) {
Intercom.client().loginIdentifiedUser(Registration.create().withUserId(userID))
intercomHash?.let { intercomHash ->
Intercom.client().setUserHash(intercomHash)
}
}
}
fun track(eventName: String, properties: Properties = Properties()) {
posthog.capture(eventName, properties)
}
fun logout() {
posthog.reset()
}
}

View File

@ -0,0 +1,31 @@
package app.omnivore.omnivore.core.data
import app.omnivore.omnivore.core.database.OmnivoreDatabase
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.network.Networker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import javax.inject.Inject
class DataService @Inject constructor(
val networker: Networker,
omnivoreDatabase: OmnivoreDatabase
) {
val savedItemSyncChannel = Channel<SavedItem>(capacity = Channel.UNLIMITED)
val db = omnivoreDatabase
init {
CoroutineScope(Dispatchers.IO).launch {
startSyncChannels()
}
}
fun clearDatabase() {
CoroutineScope(Dispatchers.IO).launch {
db.clearAllTables()
}
}
}

View File

@ -0,0 +1,185 @@
package app.omnivore.omnivore.core.data
import android.util.Log
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItemAndHighlightCrossRef
import app.omnivore.omnivore.core.database.entities.saveHighlightChange
import app.omnivore.omnivore.core.network.CreateHighlightParams
import app.omnivore.omnivore.core.network.DeleteHighlightParams
import app.omnivore.omnivore.core.network.MergeHighlightsParams
import app.omnivore.omnivore.core.network.UpdateHighlightParams
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.UUID
suspend fun DataService.createWebHighlight(jsonString: String, colorName: String?) {
val createHighlightInput =
Gson().fromJson(jsonString, CreateHighlightParams::class.java).asCreateHighlightInput()
withContext(Dispatchers.IO) {
val highlight = Highlight(
type = "HIGHLIGHT",
highlightId = createHighlightInput.id,
shortId = createHighlightInput.shortId,
quote = createHighlightInput.quote.getOrNull(),
prefix = null,
suffix = null,
patch = createHighlightInput.patch.getOrNull(),
annotation = createHighlightInput.annotation.getOrNull(),
createdAt = null,
updatedAt = null,
createdByMe = false,
color = colorName ?: createHighlightInput.color.getOrNull(),
highlightPositionPercent = createHighlightInput.highlightPositionPercent.getOrNull()
?: 0.0,
highlightPositionAnchorIndex = createHighlightInput.highlightPositionAnchorIndex.getOrNull()
?: 0
)
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_CREATION.rawValue
val highlightChange =
saveHighlightChange(db.highlightChangesDao(), createHighlightInput.articleId, highlight)
val crossRef = SavedItemAndHighlightCrossRef(
highlightId = createHighlightInput.id, savedItemId = createHighlightInput.articleId
)
db.highlightDao().insertAll(listOf(highlight))
db.savedItemAndHighlightCrossRefDao().insertAll(listOf(crossRef))
performHighlightChange(highlightChange)
}
}
suspend fun DataService.createNoteHighlight(savedItemId: String, note: String): String {
val shortId = NanoId.generate(size = 14)
val createHighlightId = UUID.randomUUID().toString()
withContext(Dispatchers.IO) {
val highlight = Highlight(
type = "NOTE",
highlightId = createHighlightId,
shortId = shortId,
quote = null,
prefix = null,
suffix = null,
patch = null,
annotation = note,
createdAt = null,
updatedAt = null,
createdByMe = true,
color = null,
highlightPositionAnchorIndex = 0,
highlightPositionPercent = 0.0
)
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_CREATION.rawValue
val highlightChange = saveHighlightChange(db.highlightChangesDao(), savedItemId, highlight)
val crossRef = SavedItemAndHighlightCrossRef(
highlightId = createHighlightId, savedItemId = savedItemId
)
db.highlightDao().insertAll(listOf(highlight))
db.savedItemAndHighlightCrossRefDao().insertAll(listOf(crossRef))
performHighlightChange(highlightChange)
}
return createHighlightId
}
suspend fun DataService.mergeWebHighlights(jsonString: String) {
val mergeHighlightInput =
Gson().fromJson(jsonString, MergeHighlightsParams::class.java).asMergeHighlightInput()
Log.d("sync", "mergeHighlightInput: " + mergeHighlightInput.id + ": " + mergeHighlightInput)
withContext(Dispatchers.IO) {
val highlight = Highlight(
type = "HIGHLIGHT",
highlightId = mergeHighlightInput.id,
shortId = mergeHighlightInput.shortId,
quote = mergeHighlightInput.quote,
prefix = null,
suffix = null,
patch = mergeHighlightInput.patch,
annotation = mergeHighlightInput.annotation.getOrNull(),
createdAt = null,
updatedAt = null,
createdByMe = false,
color = mergeHighlightInput.color.getOrNull(),
highlightPositionPercent = mergeHighlightInput.highlightPositionPercent.getOrNull()
?: 0.0,
highlightPositionAnchorIndex = mergeHighlightInput.highlightPositionAnchorIndex.getOrNull()
?: 0
)
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_MERGE.rawValue
val highlightChange = saveHighlightChange(
db.highlightChangesDao(),
mergeHighlightInput.articleId,
highlight,
html = mergeHighlightInput.html.getOrNull(),
overlappingIDs = mergeHighlightInput.overlapHighlightIdList
)
val crossRef = SavedItemAndHighlightCrossRef(
highlightId = mergeHighlightInput.id, savedItemId = mergeHighlightInput.articleId
)
db.highlightDao().insertAll(listOf(highlight))
db.savedItemAndHighlightCrossRefDao().insertAll(listOf(crossRef))
Log.d("sync", "Setting up highlight merge")
performHighlightChange(highlightChange)
}
}
suspend fun DataService.updateWebHighlight(jsonString: String) {
val updateHighlightParams = Gson().fromJson(jsonString, UpdateHighlightParams::class.java)
if (updateHighlightParams.highlightId == null || updateHighlightParams.libraryItemId == null) {
Log.d("error", "ERROR INVALID HIGHLIGHT DATA")
return
}
withContext(Dispatchers.IO) {
val highlight =
db.highlightDao().findById(highlightId = updateHighlightParams.highlightId)
?: return@withContext
highlight.annotation = updateHighlightParams.annotation
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
db.highlightDao().update(highlight)
val highlightChange = saveHighlightChange(
db.highlightChangesDao(), updateHighlightParams.libraryItemId, highlight
)
performHighlightChange(highlightChange)
}
}
suspend fun DataService.deleteHighlightFromJSON(jsonString: String) {
val deleteHighlightParams = Gson().fromJson(jsonString, DeleteHighlightParams::class.java)
deleteHighlight(deleteHighlightParams.libraryItemId, deleteHighlightParams.highlightId)
}
private suspend fun DataService.deleteHighlight(savedItemId: String, highlightID: String) {
withContext(Dispatchers.IO) {
val highlight = db.highlightDao().findById(highlightId = highlightID)
highlight?.let {
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue
db.highlightDao().update(highlight)
val highlightChange =
saveHighlightChange(db.highlightChangesDao(), savedItemId, highlight)
performHighlightChange(highlightChange)
}
}
}

View File

@ -1,9 +1,14 @@
package app.omnivore.omnivore.dataService
package app.omnivore.omnivore.core.data
import android.util.Log
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.*
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.network.savedItem
import app.omnivore.omnivore.core.network.savedItemUpdates
import app.omnivore.omnivore.core.network.search
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
suspend fun DataService.librarySearch(cursor: String?, query: String): SearchResult {
val searchResult = networker.search(cursor = cursor, limit = 10, query = query)

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.dataService
package app.omnivore.omnivore.core.data
import java.security.SecureRandom

View File

@ -0,0 +1,34 @@
package app.omnivore.omnivore.core.data
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.dao.SavedItemDao
import app.omnivore.omnivore.core.network.ReadingProgressParams
import app.omnivore.omnivore.core.network.updateReadingProgress
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun DataService.updateWebReadingProgress(
jsonString: String,
savedItemDao: SavedItemDao
) {
val readingProgressParams = Gson().fromJson(jsonString, ReadingProgressParams::class.java)
val savedItemId = readingProgressParams.id ?: return
withContext(Dispatchers.IO) {
val savedItem = savedItemDao.findById(savedItemId) ?: return@withContext
val updatedItem = savedItem.copy(
readingProgress = readingProgressParams.readingProgressPercent ?: 0.0,
readingProgressAnchor = readingProgressParams.readingProgressAnchorIndex ?: 0,
serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
)
savedItemDao.update(updatedItem)
val isUpdatedOnServer = networker.updateReadingProgress(readingProgressParams)
if (isUpdatedOnServer) {
updatedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
savedItemDao.update(updatedItem)
}
}
}

View File

@ -1,6 +1,6 @@
package app.omnivore.omnivore.dataService
package app.omnivore.omnivore.core.data
import app.omnivore.omnivore.networking.savedItemLabels
import app.omnivore.omnivore.core.network.savedItemLabels
suspend fun DataService.syncLabels() {
val fetchedLabels = networker.savedItemLabels()

View File

@ -0,0 +1,57 @@
package app.omnivore.omnivore.core.data
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.network.archiveSavedItem
import app.omnivore.omnivore.core.network.deleteSavedItem
import app.omnivore.omnivore.core.network.unarchiveSavedItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun DataService.deleteSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.deleteSavedItem(itemID)
if (isUpdatedOnServer) {
db.savedItemDao().deleteById(itemID)
}
}
}
suspend fun DataService.archiveSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = true
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.archiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}
suspend fun DataService.unarchiveSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = false
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.unarchiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}

View File

@ -0,0 +1,218 @@
package app.omnivore.omnivore.core.data
import android.util.Log
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.entities.HighlightChange
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.highlightChangeToHighlight
import app.omnivore.omnivore.core.network.ReadingProgressParams
import app.omnivore.omnivore.core.network.createHighlight
import app.omnivore.omnivore.core.network.deleteHighlights
import app.omnivore.omnivore.core.network.deleteSavedItem
import app.omnivore.omnivore.core.network.mergeHighlights
import app.omnivore.omnivore.core.network.updateArchiveStatusSavedItem
import app.omnivore.omnivore.core.network.updateHighlight
import app.omnivore.omnivore.core.network.updateReadingProgress
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.HighlightType
import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import com.apollographql.apollo3.api.Optional
import kotlinx.coroutines.delay
suspend fun DataService.startSyncChannels() {
Log.d("sync", "Starting sync channels")
for (savedItem in savedItemSyncChannel) {
syncSavedItem(savedItem)
}
}
suspend fun DataService.performHighlightChange(highlightChange: HighlightChange) {
val highlight = highlightChangeToHighlight(highlightChange)
if (syncHighlightChange(highlightChange)) {
db.highlightChangesDao().deleteById(highlight.highlightId)
}
}
suspend fun DataService.syncOfflineItemsWithServerIfNeeded() {
val unSyncedSavedItems = db.savedItemDao().getUnSynced()
val unSyncedHighlights = db.highlightChangesDao().getUnSynced()
for (savedItem in unSyncedSavedItems) {
delay(250)
savedItemSyncChannel.send(savedItem)
}
for (change in unSyncedHighlights) {
performHighlightChange(change)
}
}
private suspend fun DataService.syncSavedItem(item: SavedItem) {
suspend fun updateSyncStatus(status: ServerSyncStatus) {
item.serverSyncStatus = status.rawValue
db.savedItemDao().update(item)
}
when (item.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteSavedItem(item.savedItemId)
if (isDeletedOnServer) {
db.savedItemDao().deleteById(item.savedItemId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isArchiveServerSynced = networker.updateArchiveStatusSavedItem(itemID = item.savedItemId, setAsArchived = item.isArchived)
val isReadingProgressSynced = networker.updateReadingProgress(
ReadingProgressParams(
id = item.savedItemId,
force = item.contentReader == "PDF",
readingProgressPercent = item.readingProgress,
readingProgressAnchorIndex = item.readingProgressAnchor
)
)
if (isArchiveServerSynced && isReadingProgressSynced) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
// TODO: implement when we are able to create content on device
// updateSyncStatus(ServerSyncStatus.IS_SYNCING)
// send update to server
// update db
}
else -> return
}
}
private suspend fun DataService.syncHighlightChange(highlightChange: HighlightChange): Boolean {
val highlight = highlightChangeToHighlight(highlightChange)
fun updateSyncStatus(status: ServerSyncStatus) {
highlight.serverSyncStatus = status.rawValue
db.highlightDao().update(highlight)
}
when (highlight.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteHighlights(listOf(highlight.highlightId))
if (isDeletedOnServer) {
db.highlightDao().deleteById(highlight.highlightId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
return isDeletedOnServer != null
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isUpdatedOnServer = networker.updateHighlight(
UpdateHighlightInput(
annotation = Optional.presentIfNotNull(highlight.annotation),
highlightId = highlight.highlightId,
sharedAt = Optional.absent()
)
)
if (isUpdatedOnServer) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
return isUpdatedOnServer != null
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val input = CreateHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
type = Optional.presentIfNotNull(HighlightType.safeValueOf(highlight.type)),
annotation = Optional.presentIfNotNull(highlight.annotation),
patch = Optional.presentIfNotNull(highlight.patch),
quote = Optional.presentIfNotNull(highlight.quote),
)
Log.d("sync", "Creating highlight from input: ${input}")
val createResult = networker.createHighlight(
input
)
if (createResult.newHighlight != null || createResult.alreadyExists) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
return true
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
return false
}
}
ServerSyncStatus.NEEDS_MERGE.rawValue -> {
Log.d("sync", "NEEDS MERGE: ${highlightChange}")
val mergeHighlightInput = MergeHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
annotation = Optional.presentIfNotNull(highlight.annotation),
color = Optional.presentIfNotNull(highlight.color),
highlightPositionAnchorIndex = Optional.presentIfNotNull(highlight.highlightPositionAnchorIndex),
highlightPositionPercent = Optional.presentIfNotNull(highlight.highlightPositionPercent),
html = Optional.presentIfNotNull(highlightChange.html),
overlapHighlightIdList = highlightChange.overlappingIDs ?: emptyList(),
patch = highlight.patch ?: "",
prefix = Optional.presentIfNotNull(highlight.prefix),
quote = highlight.quote ?: "",
suffix = Optional.presentIfNotNull(highlight.suffix)
)
val isUpdatedOnServer = networker.mergeHighlights(mergeHighlightInput)
if (!isUpdatedOnServer) {
Log.d("sync", "FAILED TO MERGE HIGHLIGHT")
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_MERGE.rawValue
return false
}
for (highlightID in mergeHighlightInput.overlapHighlightIdList) {
Log.d("sync", "DELETING MERGED HIGHLIGHT: ${highlightID}")
val deleteChange = HighlightChange(
highlightId = highlightID,
savedItemId = highlightChange.savedItemId,
type = "",
shortId = "",
annotation = null,
createdAt = null,
patch = null,
prefix = null,
quote = null,
serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue,
html = null,
suffix = null,
updatedAt = null,
color = null,
highlightPositionPercent = null,
highlightPositionAnchorIndex = null,
overlappingIDs = null
)
performHighlightChange(deleteChange)
}
return true
}
else -> return false
}
}

View File

@ -0,0 +1,9 @@
package app.omnivore.omnivore.core.data.model
data class LibraryQuery(
val allowedArchiveStates: List<Int>,
val sortKey: String,
val requiredLabels: List<String>,
val excludedLabels: List<String>,
val allowedContentReaders: List<String>
)

View File

@ -0,0 +1,10 @@
package app.omnivore.omnivore.core.data.model
enum class ServerSyncStatus(val rawValue: Int) {
IS_SYNCED(0),
IS_SYNCING(1),
NEEDS_DELETION(2),
NEEDS_CREATION(3),
NEEDS_UPDATE(4),
NEEDS_MERGE(5)
}

View File

@ -0,0 +1,16 @@
package app.omnivore.omnivore.core.data.repository
import app.omnivore.omnivore.core.data.model.LibraryQuery
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import kotlinx.coroutines.flow.Flow
interface LibraryRepository {
fun getSavedItems(query: LibraryQuery): Flow<List<SavedItemWithLabelsAndHighlights>>
suspend fun updateReadingProgress(
itemId: String,
readingProgressPercentage: Double,
readingProgressAnchorIndex: Int
)
}

View File

@ -0,0 +1,66 @@
package app.omnivore.omnivore.core.data.repository.impl
import app.omnivore.omnivore.core.data.model.LibraryQuery
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.data.repository.LibraryRepository
import app.omnivore.omnivore.core.database.dao.SavedItemDao
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.ReadingProgressParams
import app.omnivore.omnivore.core.network.updateReadingProgress
import com.google.gson.Gson
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class LibraryRepositoryImpl @Inject constructor(
private val savedItemDao: SavedItemDao,
private val networker: Networker
): LibraryRepository {
override fun getSavedItems(query: LibraryQuery): Flow<List<SavedItemWithLabelsAndHighlights>> =
savedItemDao.filteredLibraryData(
query.allowedArchiveStates,
query.sortKey,
hasRequiredLabels = query.requiredLabels.size,
hasExcludedLabels = query.excludedLabels.size,
query.requiredLabels,
query.excludedLabels,
query.allowedContentReaders
)
override suspend fun updateReadingProgress(
itemId: String,
readingProgressPercentage: Double,
readingProgressAnchorIndex: Int
) {
val jsonString = Gson().toJson(
mapOf(
"id" to itemId,
"readingProgressPercent" to readingProgressPercentage,
"readingProgressAnchorIndex" to readingProgressAnchorIndex,
"force" to true
)
)
val readingProgressParams = Gson().fromJson(jsonString, ReadingProgressParams::class.java)
val savedItemId = readingProgressParams.id ?: return
val savedItem = savedItemDao.findById(savedItemId)
val updatedItem = savedItem?.copy(
readingProgress = readingProgressParams.readingProgressPercent ?: 0.0,
readingProgressAnchor = readingProgressParams.readingProgressAnchorIndex ?: 0,
serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
)
updatedItem?.let { savedItemDao.update(updatedItem) }
val isUpdatedOnServer = networker.updateReadingProgress(readingProgressParams)
if (isUpdatedOnServer) {
updatedItem?.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
updatedItem?.let { savedItemDao.update(updatedItem) }
}
}
}

View File

@ -0,0 +1,42 @@
package app.omnivore.omnivore.core.database
import androidx.room.Database
import androidx.room.RoomDatabase
import app.omnivore.omnivore.core.database.dao.SavedItemDao
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.HighlightChange
import app.omnivore.omnivore.core.database.entities.HighlightChangesDao
import app.omnivore.omnivore.core.database.entities.HighlightDao
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemAndHighlightCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemAndHighlightCrossRefDao
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRefDao
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemLabelDao
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlightsDao
import app.omnivore.omnivore.core.database.entities.Viewer
import app.omnivore.omnivore.core.database.entities.ViewerDao
@Database(
entities = [
Viewer::class,
SavedItem::class,
SavedItemLabel::class,
Highlight::class,
HighlightChange::class,
SavedItemAndSavedItemLabelCrossRef::class,
SavedItemAndHighlightCrossRef::class],
version = 24,
exportSchema = true
)
abstract class OmnivoreDatabase : RoomDatabase() {
abstract fun viewerDao(): ViewerDao
abstract fun savedItemDao(): SavedItemDao
abstract fun highlightDao(): HighlightDao
abstract fun highlightChangesDao(): HighlightChangesDao
abstract fun savedItemLabelDao(): SavedItemLabelDao
abstract fun savedItemWithLabelsAndHighlightsDao(): SavedItemWithLabelsAndHighlightsDao
abstract fun savedItemAndSavedItemLabelCrossRefDao(): SavedItemAndSavedItemLabelCrossRefDao
abstract fun savedItemAndHighlightCrossRefDao(): SavedItemAndHighlightCrossRefDao
}

View File

@ -0,0 +1,103 @@
package app.omnivore.omnivore.core.database.dao
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
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 kotlinx.coroutines.flow.Flow
@Dao
interface SavedItemDao {
@Query("SELECT * FROM savedItem")
fun getAll(): Flow<List<SavedItem>>
@Query("SELECT * FROM savedItem WHERE savedItemId = :itemID")
suspend fun findById(itemID: String): SavedItem?
@Query("SELECT * FROM savedItem WHERE serverSyncStatus != 0")
fun getUnSynced(): List<SavedItem>
@Query("SELECT * FROM savedItem WHERE slug = :slug")
fun getSavedItemWithLabelsAndHighlights(slug: String): SavedItemWithLabelsAndHighlights?
@Query("DELETE FROM savedItem WHERE savedItemId = :itemID")
fun deleteById(itemID: String)
@Query("DELETE FROM savedItem WHERE savedItemId in (:itemIDs)")
fun deleteByIds(itemIDs: List<String>)
@Update
suspend fun update(savedItem: SavedItem)
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.libraryColumns} " +
"FROM SavedItem " +
"LEFT OUTER JOIN SavedItemAndSavedItemLabelCrossRef on SavedItem.savedItemId = SavedItemAndSavedItemLabelCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemAndHighlightCrossRef on SavedItem.savedItemId = SavedItemAndHighlightCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemLabel on SavedItemLabel.savedItemLabelId = SavedItemAndSavedItemLabelCrossRef.savedItemLabelId " +
"LEFT OUTER JOIN Highlight on highlight.highlightId = SavedItemAndHighlightCrossRef.highlightId " +
"WHERE SavedItem.savedItemId = :savedItemId " +
"GROUP BY SavedItem.savedItemId "
)
fun getLibraryItemById(savedItemId: String): LiveData<SavedItemWithLabelsAndHighlights>
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.libraryColumns} " +
"FROM SavedItem " +
"LEFT OUTER JOIN SavedItemAndSavedItemLabelCrossRef on SavedItem.savedItemId = SavedItemAndSavedItemLabelCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemAndHighlightCrossRef on SavedItem.savedItemId = SavedItemAndHighlightCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemLabel on SavedItemLabel.savedItemLabelId = SavedItemAndSavedItemLabelCrossRef.savedItemLabelId " +
"LEFT OUTER JOIN Highlight on highlight.highlightId = SavedItemAndHighlightCrossRef.highlightId " +
"WHERE SavedItem.savedItemId = :savedItemId " +
"GROUP BY SavedItem.savedItemId "
)
suspend fun getById(savedItemId: String): SavedItemWithLabelsAndHighlights?
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.libraryColumns} " +
"FROM SavedItem " +
"LEFT OUTER JOIN SavedItemAndSavedItemLabelCrossRef on SavedItem.savedItemId = SavedItemAndSavedItemLabelCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemAndHighlightCrossRef on SavedItem.savedItemId = SavedItemAndHighlightCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemLabel on SavedItemLabel.savedItemLabelId = SavedItemAndSavedItemLabelCrossRef.savedItemLabelId " +
"LEFT OUTER JOIN Highlight on highlight.highlightId = SavedItemAndHighlightCrossRef.highlightId " +
"WHERE SavedItem.serverSyncStatus != 2 " +
"AND SavedItem.isArchived IN (:allowedArchiveStates) " +
"AND SavedItem.contentReader IN (:allowedContentReaders) " +
"AND CASE WHEN :hasRequiredLabels THEN SavedItemLabel.name in (:requiredLabels) ELSE 1 END " +
"AND CASE WHEN :hasExcludedLabels THEN SavedItemLabel.name is NULL OR SavedItemLabel.name not in (:excludedLabels) ELSE 1 END " +
"GROUP BY SavedItem.savedItemId " +
"ORDER BY \n" +
"CASE WHEN :sortKey = 'newest' THEN SavedItem.savedAt END DESC,\n" +
"CASE WHEN :sortKey = 'oldest' THEN SavedItem.savedAt END ASC,\n" +
"CASE WHEN :sortKey = 'recentlyRead' THEN SavedItem.readAt END DESC,\n" +
"CASE WHEN :sortKey = 'recentlyPublished' THEN SavedItem.publishDate END DESC"
)
fun filteredLibraryData(
allowedArchiveStates: List<Int>,
sortKey: String,
hasRequiredLabels: Int,
hasExcludedLabels: Int,
requiredLabels: List<String>,
excludedLabels: List<String>,
allowedContentReaders: List<String>
): Flow<List<SavedItemWithLabelsAndHighlights>>
}

View File

@ -1,7 +1,7 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.*
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import com.google.gson.annotations.SerializedName
@ -73,7 +73,20 @@ data class SavedItemWithLabelsAndHighlights(
associateBy = Junction(SavedItemAndHighlightCrossRef::class)
)
val highlights: List<Highlight>
)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SavedItemWithLabelsAndHighlights
return savedItem.savedItemId == other.savedItem.savedItemId
}
override fun hashCode(): Int {
return savedItem.savedItemId.hashCode()
}
}
@Dao
interface HighlightDao {

View File

@ -0,0 +1,125 @@
package app.omnivore.omnivore.core.database.entities
import android.util.Log
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
@Entity
@TypeConverters(StringListTypeConverter::class)
data class HighlightChange(
@PrimaryKey val highlightId: String,
val savedItemId: String,
val type: String,
var annotation: String?,
val createdAt: String?,
val createdByMe: Boolean = true,
val markedForDeletion: Boolean = false,
var patch: String?,
var prefix: String?,
var quote: String?,
var serverSyncStatus: Int = ServerSyncStatus.IS_SYNCED.rawValue,
val html: String?,
var shortId: String,
val suffix: String?,
val updatedAt: String?,
val color: String?,
val highlightPositionPercent: Double?,
val highlightPositionAnchorIndex: Int?,
val overlappingIDs: List<String>?
)
class StringListTypeConverter {
@TypeConverter
fun listToString(data: List<String>?): String? {
data?.let {
return Gson().toJson(data)
}
return null
}
@TypeConverter
fun stringToList(jsonString: String?): List<String>? {
return if (jsonString.isNullOrEmpty()) {
null
} else {
val itemType = object : TypeToken<List<String>>() {}.type
return Gson().fromJson<List<String>>(jsonString, itemType)
}
}
}
fun saveHighlightChange(
dao: HighlightChangesDao,
savedItemId: String,
highlight: Highlight,
html: String? = null,
overlappingIDs: List<String>? = null): HighlightChange {
Log.d("sync", "saving highlight change: " + highlight.serverSyncStatus + ", " + highlight.type)
val change = HighlightChange(
savedItemId = savedItemId,
highlightId = highlight.highlightId,
type = highlight.type,
shortId = highlight.shortId,
quote = highlight.quote,
prefix = highlight.prefix,
suffix = highlight.suffix,
patch = highlight.patch,
html = html,
annotation = highlight.annotation,
createdAt = highlight.createdAt,
updatedAt = highlight.updatedAt,
createdByMe = highlight.createdByMe,
color =highlight.color,
highlightPositionPercent = highlight.highlightPositionPercent,
highlightPositionAnchorIndex = highlight.highlightPositionAnchorIndex,
serverSyncStatus = highlight.serverSyncStatus,
overlappingIDs = overlappingIDs
)
dao.insertAll(listOf(change))
return change
}
fun highlightChangeToHighlight(change: HighlightChange): Highlight {
return Highlight(
highlightId = change.highlightId,
type = change.type,
shortId = change.shortId,
quote = change.quote,
prefix = change.prefix,
suffix = change.suffix,
patch = change.patch,
annotation = change.annotation,
createdAt = change.createdAt,
updatedAt = change.updatedAt,
createdByMe = change.createdByMe,
color = change.color,
highlightPositionPercent = change.highlightPositionPercent,
highlightPositionAnchorIndex = change.highlightPositionAnchorIndex,
serverSyncStatus = change.serverSyncStatus
)
}
@Dao
interface HighlightChangesDao {
@Query("SELECT * FROM highlightChange WHERE serverSyncStatus != 0 ORDER BY updatedAt ASC")
fun getUnSynced(): List<HighlightChange>
@Query("DELETE FROM highlightChange WHERE highlightId = :highlightId")
fun deleteById(highlightId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<HighlightChange>)
}

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey

View File

@ -0,0 +1,165 @@
package app.omnivore.omnivore.core.database.entities
import androidx.core.net.toUri
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Transaction
@Entity
data class SavedItem(
@PrimaryKey val savedItemId: String,
val title: String,
val createdAt: String,
val savedAt: String,
val readAt: String?,
val updatedAt: String?,
var readingProgress: Double,
var readingProgressAnchor: Int,
val imageURLString: String?,
val pageURLString: String,
val descriptionText: String?,
val publisherURLString: String?,
val siteName: String?,
val author: String?,
val publishDate: String?,
val slug: String,
var isArchived: Boolean,
val contentReader: String? = null,
val content: String? = null,
val createdId: String? = null,
val htmlContent: String? = null,
val language: String? = null,
val listenPositionIndex: Int? = null,
val listenPositionOffset: Double? = null,
val listenPositionTime: Double? = null,
val localPDF: String? = null,
val onDeviceImageURLString: String? = null,
val originalHtml: String? = null,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) val pdfData: ByteArray? = null,
var serverSyncStatus: Int = 0,
val tempPDFURL: String? = null,
val wordsCount: Int? = null,
val localPDFPath: String? = null
// hasMany highlights
// hasMany labels
// has Many recommendations (rec has one savedItem)
) {
fun publisherDisplayName(): String? {
return publisherURLString?.toUri()?.host
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SavedItem
return savedItemId == other.savedItemId
}
override fun hashCode(): Int {
return savedItemId.hashCode()
}
}
data class TypeaheadCardData(
val savedItemId: String,
val slug: String,
val title: String,
val isArchived: Boolean,
)
@Dao
abstract class SavedItemWithLabelsAndHighlightsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertSavedItems(items: List<SavedItem>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertLabelCrossRefs(items: List<SavedItemAndSavedItemLabelCrossRef>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertLabels(items: List<SavedItemLabel>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertHighlights(items: List<Highlight>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertHighlightCrossRefs(items: List<SavedItemAndHighlightCrossRef>)
@Transaction
open fun insertAll(savedItems: List<SavedItemWithLabelsAndHighlights>) {
insertSavedItems(savedItems.map { it.savedItem })
val labels: MutableList<SavedItemLabel> = mutableListOf()
val highlights: MutableList<Highlight> = mutableListOf()
val labelCrossRefs: MutableList<SavedItemAndSavedItemLabelCrossRef> = mutableListOf()
val highlightCrossRefs: MutableList<SavedItemAndHighlightCrossRef> = mutableListOf()
for (searchItem in savedItems) {
labels.addAll(searchItem.labels)
highlights.addAll(searchItem.highlights)
val newLabelCrossRefs = searchItem.labels.map {
SavedItemAndSavedItemLabelCrossRef(
savedItemLabelId = it.savedItemLabelId,
savedItemId = searchItem.savedItem.savedItemId
)
}
val newHighlightCrossRefs = searchItem.highlights.map {
SavedItemAndHighlightCrossRef(
highlightId = it.highlightId,
savedItemId = searchItem.savedItem.savedItemId
)
}
labelCrossRefs.addAll(newLabelCrossRefs)
highlightCrossRefs.addAll(newHighlightCrossRefs)
}
insertLabels(labels)
insertLabelCrossRefs(labelCrossRefs)
insertHighlights(highlights)
insertHighlightCrossRefs(highlightCrossRefs)
}
}
object SavedItemQueryConstants {
const val columns =
"savedItemId, slug, publisherURLString, title, author, descriptionText, imageURLString, isArchived, pageURLString, contentReader, savedAt, readingProgress, wordsCount"
const val libraryColumns = "SavedItem.savedItemId, " +
"SavedItem.slug, " +
"SavedItem.createdAt, " +
"SavedItem.publisherURLString, " +
"SavedItem.title, " +
"SavedItem.author, " +
"SavedItem.descriptionText, " +
"SavedItem.imageURLString, " +
"SavedItem.isArchived, " +
"SavedItem.pageURLString, " +
"SavedItem.contentReader, " +
"SavedItem.savedAt, " +
"SavedItem.readingProgress, " +
"SavedItem.readingProgressAnchor, " +
"SavedItem.serverSyncStatus, " +
"SavedItem.wordsCount, " +
"SavedItemLabel.savedItemLabelId, " +
"SavedItemLabel.name, " +
"SavedItemLabel.color, " +
"Highlight.highlightId, " +
"Highlight.shortId, " +
"Highlight.createdByMe "
}

View File

@ -0,0 +1,71 @@
package app.omnivore.omnivore.core.database.entities
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Transaction
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
@Entity
data class SavedItemLabel(
@PrimaryKey val savedItemLabelId: String,
val name: String,
val color: String,
val createdAt: String?,
val labelDescription: String?,
val serverSyncStatus: Int = 0
)
@Dao
interface SavedItemLabelDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<SavedItemLabel>)
@Transaction
@Query("SELECT * FROM SavedItemLabel WHERE serverSyncStatus != 2 ORDER BY name ASC")
fun getSavedItemLabelsLiveData(): LiveData<List<SavedItemLabel>>
@Transaction
@Query("UPDATE SavedItemLabel set savedItemLabelId = :permanentId, serverSyncStatus = :status WHERE savedItemLabelId = :tempId")
fun updateTempLabel(
tempId: String, permanentId: String, status: ServerSyncStatus = ServerSyncStatus.IS_SYNCED
)
@Transaction
@Query("SELECT * FROM SavedItemLabel WHERE name in (:names) ORDER BY name ASC")
fun namedLabels(names: List<String>): List<SavedItemLabel>
}
@Entity(
primaryKeys = ["savedItemLabelId", "savedItemId"], foreignKeys = [ForeignKey(
entity = SavedItem::class,
parentColumns = arrayOf("savedItemId"),
childColumns = arrayOf("savedItemId"),
onDelete = ForeignKey.CASCADE
), ForeignKey(
entity = SavedItemLabel::class,
parentColumns = arrayOf("savedItemLabelId"),
childColumns = arrayOf("savedItemLabelId")
)]
)
data class SavedItemAndSavedItemLabelCrossRef(
val savedItemLabelId: String, val savedItemId: String
)
@Dao
interface SavedItemAndSavedItemLabelCrossRefDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<SavedItemAndSavedItemLabelCrossRef>)
@Query("DELETE FROM savedItemAndSavedItemLabelCrossRef WHERE savedItemId = :savedItemId")
fun deleteRefsBySavedItemId(savedItemId: String)
}
// has many highlights
// has many savedItems

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey

View File

@ -1,7 +1,6 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.*
import app.omnivore.omnivore.persistence.BaseDao
@Entity
data class Viewer(

View File

@ -1,9 +1,11 @@
package app.omnivore.omnivore
package app.omnivore.omnivore.core.datastore
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map

View File

@ -1,15 +1,16 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import android.util.Log
import app.omnivore.omnivore.graphql.generated.CreateHighlightMutation
import app.omnivore.omnivore.graphql.generated.DeleteHighlightMutation
import app.omnivore.omnivore.graphql.generated.MergeHighlightMutation
import app.omnivore.omnivore.graphql.generated.UpdateHighlightMutation
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightErrorCode
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.HighlightType
import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.core.database.entities.Highlight
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson
@ -39,6 +40,7 @@ data class CreateHighlightParams(
data class UpdateHighlightParams(
val highlightId: String?,
val libraryItemId: String?,
val `annotation`: String?,
val sharedAt: String?,
) {
@ -73,9 +75,10 @@ data class MergeHighlightsParams(
}
data class DeleteHighlightParams(
val highlightId: String?
val highlightId: String,
val libraryItemId: String
) {
fun asIdList() = listOf(highlightId ?: "")
fun asIdList() = listOf(highlightId)
}
suspend fun Networker.deleteHighlight(jsonString: String): Boolean {
@ -107,7 +110,6 @@ suspend fun Networker.updateWebHighlight(jsonString: String): Boolean {
suspend fun Networker.updateHighlight(input: UpdateHighlightInput): Boolean {
return try {
val result = authenticatedApolloClient().mutation(UpdateHighlightMutation(input)).execute()
Log.d("Network", "update highlight result: $result")
result.data?.updateHighlight?.onUpdateHighlightSuccess?.highlight != null
} catch (e: java.lang.Exception) {
false
@ -134,18 +136,22 @@ suspend fun Networker.createWebHighlight(jsonString: String): Boolean {
return createHighlight(input) != null
}
suspend fun Networker.createHighlight(input: CreateHighlightInput): Highlight? {
Log.d("Loggo", "created highlight input: $input")
data class CreateHighlightResult(
val failedToCreate: Boolean,
val alreadyExists: Boolean,
val newHighlight: Highlight?
)
suspend fun Networker.createHighlight(input: CreateHighlightInput): CreateHighlightResult {
try {
val result = authenticatedApolloClient().mutation(CreateHighlightMutation(input)).execute()
Log.d("Loggo", "result: ${result.data}")
val createdHighlight = result.data?.createHighlight?.onCreateHighlightSuccess?.highlight
if (createdHighlight != null) {
return Highlight(
return CreateHighlightResult(
failedToCreate = false,
alreadyExists = false,
newHighlight = Highlight(
type = createdHighlight.highlightFields.type.toString(),
highlightId = createdHighlight.highlightFields.id,
shortId = createdHighlight.highlightFields.shortId,
@ -161,10 +167,22 @@ suspend fun Networker.createHighlight(input: CreateHighlightInput): Highlight? {
highlightPositionPercent = createdHighlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = createdHighlight.highlightFields.highlightPositionAnchorIndex
)
)
} else {
return null
if (result.data?.createHighlight?.onCreateHighlightError?.errorCodes?.first() == CreateHighlightErrorCode.ALREADY_EXISTS) {
return CreateHighlightResult(
failedToCreate = false,
alreadyExists = true,
newHighlight = null
)
}
}
} catch (e: java.lang.Exception) {
return null
Log.d("sync", "error creating highlight: " +e)
}
return CreateHighlightResult(
failedToCreate = true,
alreadyExists = false,
newHighlight = null
)
}

View File

@ -0,0 +1,20 @@
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import com.apollographql.apollo3.ApolloClient
import javax.inject.Inject
class Networker @Inject constructor(
private val datastoreRepo: DatastoreRepository
) {
suspend fun baseUrl() =
datastoreRepo.getString(DatastoreKeys.omnivoreSelfHostedAPIServer) ?: Constants.apiURL
private suspend fun serverUrl() = "${baseUrl()}/api/graphql"
private suspend fun authToken() = datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken) ?: ""
suspend fun authenticatedApolloClient() = ApolloClient.Builder().serverUrl(serverUrl())
.addHttpHeader("Authorization", value = authToken()).build()
}

View File

@ -1,6 +1,5 @@
package app.omnivore.omnivore
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.networking.Networker
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.SaveArticleReadingProgressMutation
import app.omnivore.omnivore.graphql.generated.type.SaveArticleReadingProgressInput

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.CreateLabelMutation
import app.omnivore.omnivore.graphql.generated.SetLabelsMutation

View File

@ -1,7 +1,7 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.GetLabelsQuery
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
suspend fun Networker.savedItemLabels(): List<SavedItemLabel> {

View File

@ -0,0 +1,65 @@
package app.omnivore.omnivore.core.network
import android.net.Uri
import androidx.compose.ui.text.intl.Locale
import app.omnivore.omnivore.graphql.generated.SaveUrlMutation
import app.omnivore.omnivore.graphql.generated.SetBookmarkArticleMutation
import app.omnivore.omnivore.graphql.generated.SetLinkArchivedMutation
import app.omnivore.omnivore.graphql.generated.type.ArchiveLinkInput
import app.omnivore.omnivore.graphql.generated.type.SaveUrlInput
import app.omnivore.omnivore.graphql.generated.type.SetBookmarkArticleInput
import com.apollographql.apollo3.api.Optional
import java.util.TimeZone
import java.util.UUID
suspend fun Networker.deleteSavedItem(itemID: String): Boolean {
return try {
val input = SetBookmarkArticleInput(itemID, false)
val result =
authenticatedApolloClient().mutation(SetBookmarkArticleMutation(input)).execute()
result.data?.setBookmarkArticle?.onSetBookmarkArticleSuccess?.bookmarkedArticle?.id != null
} catch (e: java.lang.Exception) {
false
}
}
suspend fun Networker.archiveSavedItem(itemID: String): Boolean {
return updateArchiveStatusSavedItem(itemID, true)
}
suspend fun Networker.unarchiveSavedItem(itemID: String): Boolean {
return updateArchiveStatusSavedItem(itemID, false)
}
suspend fun Networker.updateArchiveStatusSavedItem(
itemID: String,
setAsArchived: Boolean
): Boolean {
return try {
val input = ArchiveLinkInput(setAsArchived, itemID)
val result = authenticatedApolloClient().mutation(SetLinkArchivedMutation(input)).execute()
result.data?.setLinkArchived?.onArchiveLinkSuccess?.linkId != null
} catch (e: java.lang.Exception) {
false
}
}
suspend fun Networker.saveUrl(url: Uri): Boolean {
return try {
val clientRequestId = UUID.randomUUID().toString()
// get locale and timezone from device
val timezone = TimeZone.getDefault().id
val locale = Locale.current.toLanguageTag()
val input = SaveUrlInput(
url = url.toString(),
clientRequestId = clientRequestId,
source = "android",
timezone = Optional.present(timezone),
locale = Optional.present(locale)
)
val result = authenticatedApolloClient().mutation(SaveUrlMutation(input)).execute()
result.data?.saveUrl?.onSaveSuccess?.url != null
} catch (e: java.lang.Exception) {
false
}
}

View File

@ -1,11 +1,11 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import android.util.Log
import app.omnivore.omnivore.graphql.generated.GetArticleQuery
import app.omnivore.omnivore.graphql.generated.type.ContentReader
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.Highlight
import java.io.File
import java.net.URL
import java.nio.file.Files

View File

@ -1,8 +1,7 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.UpdatesSinceQuery
import app.omnivore.omnivore.graphql.generated.type.UpdateReason
import app.omnivore.omnivore.persistence.entities.SavedItem
import com.apollographql.apollo3.api.Optional
data class SavedItemUpdatesQueryResponse(
@ -20,6 +19,7 @@ suspend fun Networker.savedItemUpdates(
try {
val result = authenticatedApolloClient().query(
UpdatesSinceQuery(
folder = Optional.presentIfNotNull("all"),
after = Optional.presentIfNotNull(cursor),
first = Optional.presentIfNotNull(limit),
since = since

View File

@ -1,8 +1,10 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.graphql.generated.SearchQuery
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.persistence.entities.*
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import com.apollographql.apollo3.api.Optional
data class LibrarySearchQueryResponse(

View File

@ -1,7 +1,7 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.TypeaheadSearchQuery
import app.omnivore.omnivore.persistence.entities.TypeaheadCardData
import app.omnivore.omnivore.core.database.entities.TypeaheadCardData
data class SearchQueryResponse(
val cursor: String?,

View File

@ -1,7 +1,7 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.ViewerQuery
import app.omnivore.omnivore.persistence.entities.Viewer
import app.omnivore.omnivore.core.database.entities.Viewer
suspend fun Networker.viewer(): Viewer? {
try {

View File

@ -0,0 +1,33 @@
package app.omnivore.omnivore.core.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
@Composable
fun LinkIcon(
label: String,
icon: ImageVector,
url: String,
modifier: Modifier = Modifier,
) {
val uriHandler = LocalUriHandler.current
IconButton(
modifier = modifier.padding(4.dp),
onClick = { uriHandler.openUri(url) },
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurface
)
}
}

View File

@ -1,38 +0,0 @@
package app.omnivore.omnivore.dataService
import android.content.Context
import androidx.room.Room
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.AppDatabase
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.persistence.entities.SavedItem
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import javax.inject.Inject
class DataService @Inject constructor(
context: Context,
val networker: Networker
) {
val savedItemSyncChannel = Channel<SavedItem>(capacity = Channel.UNLIMITED)
val highlightSyncChannel = Channel<Highlight>(capacity = Channel.UNLIMITED)
val db = Room.databaseBuilder(
context,
AppDatabase::class.java, "omnivore-database"
)
.fallbackToDestructiveMigration()
.build()
init {
CoroutineScope(Dispatchers.IO).launch {
startSyncChannels()
}
}
fun clearDatabase() {
CoroutineScope(Dispatchers.IO).launch {
db.clearAllTables()
}
}
}

View File

@ -1,176 +0,0 @@
package app.omnivore.omnivore.dataService
import app.omnivore.omnivore.graphql.generated.type.HighlightType
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.persistence.entities.SavedItemAndHighlightCrossRef
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.*
suspend fun DataService.createWebHighlight(jsonString: String, colorName: String?) {
val createHighlightInput = Gson().fromJson(jsonString, CreateHighlightParams::class.java).asCreateHighlightInput()
withContext(Dispatchers.IO) {
val highlight = Highlight(
type = "HIGHLIGHT",
highlightId = createHighlightInput.id,
shortId = createHighlightInput.shortId,
quote = createHighlightInput.quote.getOrNull(),
prefix = null,
suffix = null,
patch = createHighlightInput.patch.getOrNull(),
annotation = createHighlightInput.annotation.getOrNull(),
createdAt = null,
updatedAt = null,
createdByMe = false,
color = colorName ?: createHighlightInput.color.getOrNull(),
highlightPositionPercent = createHighlightInput.highlightPositionPercent.getOrNull() ?: 0.0,
highlightPositionAnchorIndex = createHighlightInput.highlightPositionAnchorIndex.getOrNull() ?: 0
)
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_CREATION.rawValue
val crossRef = SavedItemAndHighlightCrossRef(
highlightId = createHighlightInput.id,
savedItemId = createHighlightInput.articleId
)
db.highlightDao().insertAll(listOf(highlight))
db.savedItemAndHighlightCrossRefDao().insertAll(listOf(crossRef))
val newHighlight = networker.createHighlight(createHighlightInput)
newHighlight?.let {
db.highlightDao().update(it)
}
}
}
suspend fun DataService.createNoteHighlight(savedItemId: String, note: String): String {
val shortId = NanoId.generate(size=14)
val createHighlightId = UUID.randomUUID().toString()
withContext(Dispatchers.IO) {
val highlight = Highlight(
type = "NOTE",
highlightId = createHighlightId,
shortId = shortId,
quote = null,
prefix = null,
suffix = null,
patch =null,
annotation = note,
createdAt = null,
updatedAt = null,
createdByMe = true,
color = null,
highlightPositionAnchorIndex = 0,
highlightPositionPercent = 0.0
)
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_CREATION.rawValue
val crossRef = SavedItemAndHighlightCrossRef(
highlightId = createHighlightId,
savedItemId = savedItemId
)
db.highlightDao().insertAll(listOf(highlight))
db.savedItemAndHighlightCrossRefDao().insertAll(listOf(crossRef))
val newHighlight = networker.createHighlight(input = CreateHighlightParams(
type = HighlightType.NOTE,
articleId = savedItemId,
id = createHighlightId,
shortId = shortId,
quote = null,
patch = null,
annotation = note,
highlightPositionAnchorIndex = 0,
highlightPositionPercent = 0.0
).asCreateHighlightInput())
newHighlight?.let {
db.highlightDao().update(it)
}
}
return createHighlightId
}
suspend fun DataService.mergeWebHighlights(jsonString: String) {
val mergeHighlightInput = Gson().fromJson(jsonString, MergeHighlightsParams::class.java).asMergeHighlightInput()
withContext(Dispatchers.IO) {
val highlight = db.highlightDao().findById(highlightId = mergeHighlightInput.id) ?: return@withContext
highlight.shortId = mergeHighlightInput.shortId
highlight.quote = mergeHighlightInput.quote
highlight.patch = mergeHighlightInput.patch
highlight.prefix = mergeHighlightInput.prefix.getOrNull()
highlight.annotation = mergeHighlightInput.annotation.getOrNull()
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
for (highlightID in mergeHighlightInput.overlapHighlightIdList) {
deleteHighlight(highlightID)
}
val crossRef = SavedItemAndHighlightCrossRef(
highlightId = mergeHighlightInput.id,
savedItemId = mergeHighlightInput.articleId
)
db.savedItemAndHighlightCrossRefDao().insertAll(listOf(crossRef))
db.highlightDao().update(highlight)
val isUpdatedOnServer = networker.mergeHighlights(mergeHighlightInput)
if (isUpdatedOnServer) {
highlight.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.highlightDao().update(highlight)
}
}
}
suspend fun DataService.updateWebHighlight(jsonString: String) {
val updateHighlightParams = Gson().fromJson(jsonString, UpdateHighlightParams::class.java).asUpdateHighlightInput()
withContext(Dispatchers.IO) {
val highlight = db.highlightDao().findById(highlightId = updateHighlightParams.highlightId) ?: return@withContext
highlight.annotation = updateHighlightParams.annotation.getOrNull()
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
db.highlightDao().update(highlight)
val isUpdatedOnServer = networker.updateHighlight(updateHighlightParams)
if (isUpdatedOnServer) {
highlight.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.highlightDao().update(highlight)
}
}
}
suspend fun DataService.deleteHighlights(jsonString: String) {
val highlightIDs = Gson().fromJson(jsonString, DeleteHighlightParams::class.java).asIdList()
for (highlightID in highlightIDs) {
deleteHighlight(highlightID)
}
}
private suspend fun DataService.deleteHighlight(highlightID: String) {
withContext(Dispatchers.IO) {
val highlight = db.highlightDao().findById(highlightId = highlightID) ?: return@withContext
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue
db.highlightDao().update(highlight)
val isUpdatedOnServer = networker.deleteHighlights(listOf(highlightID))
if (isUpdatedOnServer) {
db.highlightDao().deleteById(highlightId = highlightID)
}
}
}

View File

@ -1,28 +0,0 @@
package app.omnivore.omnivore.dataService
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.ReadingProgressParams
import app.omnivore.omnivore.networking.updateReadingProgress
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun DataService.updateWebReadingProgress(jsonString: String) {
val readingProgressParams = Gson().fromJson(jsonString, ReadingProgressParams::class.java)
val savedItemId = readingProgressParams.id ?: return
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(savedItemId) ?: return@withContext
savedItem.readingProgress = readingProgressParams.readingProgressPercent ?: 0.0
savedItem.readingProgressAnchor = readingProgressParams.readingProgressAnchorIndex ?: 0
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.updateReadingProgress(readingProgressParams)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}

View File

@ -1,56 +0,0 @@
package app.omnivore.omnivore.dataService
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.archiveSavedItem
import app.omnivore.omnivore.networking.deleteSavedItem
import app.omnivore.omnivore.networking.unarchiveSavedItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun DataService.deleteSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.deleteSavedItem(itemID)
if (isUpdatedOnServer) {
db.savedItemDao().deleteById(itemID)
}
}
}
suspend fun DataService.archiveSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = true
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.archiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}
suspend fun DataService.unarchiveSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = false
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.unarchiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}

View File

@ -1,145 +0,0 @@
package app.omnivore.omnivore.dataService
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.persistence.entities.SavedItem
import com.apollographql.apollo3.api.Optional
import kotlinx.coroutines.delay
suspend fun DataService.startSyncChannels() {
for (savedItem in savedItemSyncChannel) {
syncSavedItem(savedItem)
}
for (highlight in highlightSyncChannel) {
syncHighlight(highlight)
}
}
suspend fun DataService.syncOfflineItemsWithServerIfNeeded() {
val unSyncedSavedItems = db.savedItemDao().getUnSynced()
val unSyncedHighlights = db.highlightDao().getUnSynced()
for (savedItem in unSyncedSavedItems) {
delay(250)
savedItemSyncChannel.send(savedItem)
}
for (highlight in unSyncedHighlights) {
delay(250)
highlightSyncChannel.send(highlight)
}
}
private suspend fun DataService.syncSavedItem(item: SavedItem) {
fun updateSyncStatus(status: ServerSyncStatus) {
item.serverSyncStatus = status.rawValue
db.savedItemDao().update(item)
}
when (item.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteSavedItem(item.savedItemId)
if (isDeletedOnServer) {
db.savedItemDao().deleteById(item.savedItemId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isArchiveServerSynced = networker.updateArchiveStatusSavedItem(itemID = item.savedItemId, setAsArchived = item.isArchived)
val isReadingProgressSynced = networker.updateReadingProgress(
ReadingProgressParams(
id = item.savedItemId,
force = item.contentReader == "PDF",
readingProgressPercent = item.readingProgress,
readingProgressAnchorIndex = item.readingProgressAnchor
)
)
if (isArchiveServerSynced && isReadingProgressSynced) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
// TODO: implement when we are able to create content on device
// updateSyncStatus(ServerSyncStatus.IS_SYNCING)
// send update to server
// update db
}
else -> return
}
}
private suspend fun DataService.syncHighlight(highlight: Highlight) {
fun updateSyncStatus(status: ServerSyncStatus) {
highlight.serverSyncStatus = status.rawValue
db.highlightDao().update(highlight)
}
when (highlight.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteHighlights(listOf(highlight.highlightId))
if (isDeletedOnServer) {
db.highlightDao().deleteById(highlight.highlightId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isUpdatedOnServer = networker.updateHighlight(
UpdateHighlightInput(
annotation = Optional.presentIfNotNull(highlight.annotation),
highlightId = highlight.highlightId,
sharedAt = Optional.absent()
)
)
if (isUpdatedOnServer) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val savedItemID = db.savedItemAndHighlightCrossRefDao()
.associatedSavedItemID(highlightId = highlight.highlightId)
val isCreatedOnServer = networker.createHighlight(
CreateHighlightInput(
annotation = Optional.presentIfNotNull(highlight.annotation),
articleId = savedItemID ?: "",
id = highlight.highlightId,
patch = Optional.presentIfNotNull(highlight.patch),
quote = Optional.presentIfNotNull(highlight.quote),
shortId = highlight.shortId
)
)
if (isCreatedOnServer != null) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
}
else -> return
}
}

View File

@ -1,8 +1,12 @@
package app.omnivore.omnivore
package app.omnivore.omnivore.di
import android.content.Context
import app.omnivore.omnivore.dataService.DataService
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.core.analytics.EventTracker
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.database.OmnivoreDatabase
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.datastore.OmnivoreDatastore
import app.omnivore.omnivore.core.network.Networker
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -31,7 +35,8 @@ object AppModule {
@Singleton
@Provides
fun provideDataService(
@ApplicationContext app: Context,
networker: Networker
) = DataService(app, networker)
networker: Networker,
omnivoreDatabase: OmnivoreDatabase
) = DataService(networker, omnivoreDatabase)
}

View File

@ -0,0 +1,18 @@
package app.omnivore.omnivore.di
import app.omnivore.omnivore.core.database.OmnivoreDatabase
import app.omnivore.omnivore.core.database.dao.SavedItemDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
object DaosModule {
@Provides
fun providesSavedItemDao(
database: OmnivoreDatabase,
): SavedItemDao = database.savedItemDao()
}

View File

@ -0,0 +1,18 @@
package app.omnivore.omnivore.di
import app.omnivore.omnivore.core.data.repository.LibraryRepository
import app.omnivore.omnivore.core.data.repository.impl.LibraryRepositoryImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface DataModule {
@Binds
fun bindsLibraryRepository(
libraryRepository: LibraryRepositoryImpl,
): LibraryRepository
}

View File

@ -0,0 +1,25 @@
package app.omnivore.omnivore.di
import android.content.Context
import androidx.room.Room
import app.omnivore.omnivore.core.database.OmnivoreDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun providesOmnivoreDatabase(
@ApplicationContext context: Context,
): OmnivoreDatabase = Room.databaseBuilder(
context,
OmnivoreDatabase::class.java,
"omnivore-database",
).build()
}

View File

@ -1,12 +1,12 @@
package app.omnivore.omnivore.ui
package app.omnivore.omnivore.feature
import app.omnivore.omnivore.dataService.DataService
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.networking.updateLabelsForSavedItem
import app.omnivore.omnivore.persistence.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.updateLabelsForSavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import com.apollographql.apollo3.api.Optional

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui
package app.omnivore.omnivore.feature
import android.content.Context
import androidx.annotation.StringRes

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.net.Uri
@ -17,7 +17,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import app.omnivore.omnivore.AppleConstants
import app.omnivore.omnivore.utils.AppleConstants
import app.omnivore.omnivore.R
import java.net.URLEncoder
import java.util.*

View File

@ -0,0 +1,39 @@
package app.omnivore.omnivore.feature.auth
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.composed
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
object AuthUtils {
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.autofill(
autofillTypes: List<AutofillType>,
onFill: ((String) -> Unit),
) = composed {
val autofill = LocalAutofill.current
val autofillNode = AutofillNode(onFill = onFill, autofillTypes = autofillTypes)
LocalAutofillTree.current += autofillNode
this
.onGloballyPositioned {
autofillNode.boundingBox = it.boundsInWindow()
}
.onFocusChanged { focusState ->
autofill?.run {
if (focusState.isFocused) {
requestAutofillForNode(autofillNode)
} else {
cancelAutofillForNode(autofillNode)
}
}
}
}
}

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.widget.Toast

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.widget.Toast
@ -10,7 +10,9 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@ -25,6 +27,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.R
import app.omnivore.omnivore.feature.auth.AuthUtils.autofill
@SuppressLint("CoroutineCreationDuringComposition")
@Composable
@ -86,6 +89,7 @@ fun EmailLoginView(viewModel: LoginViewModel) {
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginFields(
email: String,
@ -105,6 +109,12 @@ fun LoginFields(
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(
modifier = Modifier.autofill(
autofillTypes = listOf(
AutofillType.EmailAddress,
),
onFill = { onEmailChange(it) }
),
value = email,
placeholder = { Text(stringResource(R.string.email_login_field_placeholder_email)) },
label = { Text(stringResource(R.string.email_login_field_label_email)) },
@ -112,11 +122,17 @@ fun LoginFields(
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Email,
),
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
OutlinedTextField(
modifier = Modifier.autofill(
autofillTypes = listOf(
AutofillType.Password,
),
onFill = { onPasswordChange(it) }
),
value = password,
placeholder = { Text(stringResource(R.string.email_login_field_placeholder_password)) },
label = { Text(stringResource(R.string.email_login_field_label_password)) },
@ -129,21 +145,22 @@ fun LoginFields(
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
Button(onClick = {
if (email.isNotBlank() && password.isNotBlank()) {
onLoginClick()
focusManager.clearFocus()
} else {
Toast.makeText(
context,
context.getString(R.string.email_login_error_msg),
Toast.LENGTH_SHORT
).show()
}
}, colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D),
containerColor = Color(0xffffd234)
)
Button(
onClick = {
if (email.isNotBlank() && password.isNotBlank()) {
onLoginClick()
focusManager.clearFocus()
} else {
Toast.makeText(
context,
context.getString(R.string.email_login_error_msg),
Toast.LENGTH_SHORT
).show()
}
}, colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D),
containerColor = Color(0xffffd234)
)
) {
Text(
text = stringResource(R.string.email_login_action_login),

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.widget.Toast
@ -14,7 +14,9 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@ -27,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.feature.auth.AuthUtils.autofill
@Composable
fun EmailSignUpView(viewModel: LoginViewModel) {
@ -140,6 +143,7 @@ fun EmailSignUpForm(viewModel: LoginViewModel) {
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun EmailSignUpFields(
email: String,
@ -165,6 +169,12 @@ fun EmailSignUpFields(
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(
modifier = Modifier.autofill(
autofillTypes = listOf(
AutofillType.EmailAddress,
),
onFill = { onEmailChange(it) }
),
value = email,
placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_email)) },
label = { Text(stringResource(R.string.email_signup_field_label_email)) },
@ -174,6 +184,12 @@ fun EmailSignUpFields(
)
OutlinedTextField(
modifier = Modifier.autofill(
autofillTypes = listOf(
AutofillType.Password,
),
onFill = { onPasswordChange(it) }
),
value = password,
placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_password)) },
label = { Text(stringResource(R.string.email_signup_field_label_password)) },

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.content.Context
import android.widget.Toast
@ -7,11 +7,26 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import app.omnivore.omnivore.*
import app.omnivore.omnivore.dataService.DataService
import app.omnivore.omnivore.core.analytics.EventTracker
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.network.AuthProviderLoginSubmit
import app.omnivore.omnivore.core.network.CreateAccountParams
import app.omnivore.omnivore.core.network.CreateAccountSubmit
import app.omnivore.omnivore.core.network.CreateEmailAccountSubmit
import app.omnivore.omnivore.core.network.EmailLoginCredentials
import app.omnivore.omnivore.core.network.EmailLoginSubmit
import app.omnivore.omnivore.core.network.EmailSignUpParams
import app.omnivore.omnivore.graphql.generated.ValidateUsernameQuery
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.networking.viewer
import app.omnivore.omnivore.ui.ResourceProvider
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.PendingUserSubmit
import app.omnivore.omnivore.core.network.RetrofitHelper
import app.omnivore.omnivore.core.network.SignInParams
import app.omnivore.omnivore.core.network.UserProfile
import app.omnivore.omnivore.core.network.viewer
import app.omnivore.omnivore.feature.ResourceProvider
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import com.apollographql.apollo3.ApolloClient
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.common.api.ApiException

View File

@ -1,13 +1,8 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
@ -19,22 +14,16 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.R
@SuppressLint("CoroutineCreationDuringComposition")

View File

@ -0,0 +1,172 @@
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDecoration
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) {
OmnivoreTheme(darkTheme = false) {
Surface(
modifier = Modifier.fillMaxSize(),
color = Color(0xFFFCEBA8)
) {
WelcomeScreenContent(viewModel = viewModel)
}
}
}
@SuppressLint("CoroutineCreationDuringComposition")
@Composable
fun WelcomeScreenContent(viewModel: LoginViewModel) {
val registrationState: RegistrationState by viewModel.registrationStateLiveData.observeAsState(
RegistrationState.SocialLogin
)
val snackBarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
Column(
verticalArrangement = Arrangement.SpaceAround,
horizontalAlignment = Alignment.Start,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Spacer(modifier = Modifier.height(50.dp))
Image(
painter = painterResource(id = R.drawable.ic_omnivore_name_logo),
contentDescription = "Omnivore Icon with Name"
)
Spacer(modifier = Modifier.height(50.dp))
when (registrationState) {
RegistrationState.EmailSignIn -> {
EmailLoginView(viewModel = viewModel)
}
RegistrationState.EmailSignUp -> {
EmailSignUpView(viewModel = viewModel)
}
RegistrationState.SelfHosted -> {
SelfHostedView(viewModel = viewModel)
}
RegistrationState.SocialLogin -> {
Text(
text = stringResource(id = R.string.welcome_title),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineLarge
)
Text(
text = stringResource(id = R.string.welcome_subtitle),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall
)
MoreInfoButton()
Spacer(modifier = Modifier.height(50.dp))
AuthProviderView(viewModel = viewModel)
}
RegistrationState.PendingUser -> {
CreateUserProfileView(viewModel = viewModel)
}
}
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 -> {}
}
}
SnackbarHost(hostState = snackBarHostState)
}
}
@Composable
fun AuthProviderView(viewModel: LoginViewModel) {
val isGoogleAuthAvailable: Boolean =
GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(LocalContext.current) == 0
Row(
horizontalArrangement = Arrangement.Center
) {
Spacer(modifier = Modifier.weight(1.0F))
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isGoogleAuthAvailable) {
GoogleAuthButton(viewModel)
}
AppleAuthButton(viewModel)
ClickableText(text = AnnotatedString(stringResource(R.string.welcome_screen_action_continue_with_email)),
style = MaterialTheme.typography.titleMedium.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showEmailSignIn() })
Spacer(modifier = Modifier.weight(1.0F))
ClickableText(
text = AnnotatedString(stringResource(R.string.welcome_screen_action_self_hosting_options)),
style = MaterialTheme.typography.titleMedium.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showSelfHostedSettings() },
modifier = Modifier.padding(vertical = 10.dp)
)
}
Spacer(modifier = Modifier.weight(1.0F))
}
}
@Composable
fun MoreInfoButton() {
val context = LocalContext.current
val intent = remember { Intent(Intent.ACTION_VIEW, Uri.parse("https://omnivore.app/about")) }
ClickableText(
text = AnnotatedString(
stringResource(id = R.string.learn_more),
),
style = MaterialTheme.typography.titleSmall.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = {
context.startActivity(intent)
},
modifier = Modifier.padding(vertical = 6.dp)
)
}

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import android.widget.Toast
import androidx.compose.foundation.*
@ -18,15 +18,15 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.MutableLiveData
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.save.SaveState
import app.omnivore.omnivore.ui.save.SaveViewModel
import app.omnivore.omnivore.feature.save.SaveState
import app.omnivore.omnivore.feature.save.SaveViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddLinkSheetContent(
viewModel: SaveViewModel,
@ -84,42 +84,47 @@ fun AddLinkSheetContent(
viewModel.saveURL(url)
}
Surface(
androidx.compose.material.Scaffold(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
.background(MaterialTheme.colorScheme.primaryContainer),
topBar = {
CenterAlignedTopAppBar(
title = {
Text(stringResource(R.string.add_link_sheet_title))
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
navigationIcon = {
TextButton(onClick = onCancel) {
Text(text = stringResource(R.string.label_selection_sheet_action_cancel))
}
},
actions = {
TextButton(onClick = { addLink(textFieldValue.text) }) {
Text(stringResource(R.string.add_link_sheet_action_add_link))
}
}
)
}
) { paddingValues ->
Column(
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 5.dp)
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 10.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
TextButton(onClick = onCancel) {
Text(text = stringResource(R.string.add_link_sheet_action_cancel))
}
Text(stringResource(R.string.add_link_sheet_title), fontWeight = FontWeight.ExtraBold)
TextButton(onClick = { addLink(textFieldValue.text) }) {
Text(stringResource(R.string.add_link_sheet_action_add_link))
}
}
if (isSaving.value == true) {
Spacer(modifier = Modifier.width(16.dp))
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.width(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
@ -129,18 +134,29 @@ fun AddLinkSheetContent(
value = textFieldValue,
placeholder = { Text(stringResource(R.string.add_link_sheet_text_field_placeholder)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
leadingIcon = { Icon(imageVector = Icons.Default.Link, contentDescription = "linkIcon") },
leadingIcon = {
Icon(
imageVector = Icons.Default.Link,
contentDescription = "linkIcon"
)
},
onValueChange = { textFieldValue = it },
modifier = Modifier.focusRequester(focusRequester).padding(top = 24.dp).fillMaxWidth()
modifier = Modifier
.focusRequester(focusRequester)
.padding(top = 24.dp)
.padding(horizontal = 10.dp)
.fillMaxWidth()
)
if (clipboardText != null) {
Button(
modifier = Modifier.padding(top = 10.dp),
modifier = Modifier.padding(top = 10.dp) .padding(horizontal = 10.dp)
,
onClick = {
textFieldValue = TextFieldValue(
text = clipboardText,
selection = TextRange(clipboardText.length))
selection = TextRange(clipboardText.length)
)
}
) {
Text(stringResource(R.string.add_link_sheet_action_paste_from_clipboard))

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.ui.graphics.Color

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.ui.graphics.Color
enum class HighlightColorPaletteMode(val backgroundColor: Color) {

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding

Some files were not shown because too many files have changed in this diff Show More