diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 4d290bd9..f824a80f 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -11,21 +11,27 @@ on: jobs: build: - runs-on: macos-latest + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: set up JDK 1.8 - uses: actions/setup-java@v1 + - name: set up JDK 1.17 + uses: actions/setup-java@v3 with: - java-version: 1.8 + distribution: 'temurin' + java-version: '17' + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm - name: Android Emulator Runner - uses: ReactiveCircus/android-emulator-runner@v2.20.0 + uses: ReactiveCircus/android-emulator-runner@v2.30.1 with: api-level: 29 - script: ./gradlew clean build connectedCheck jacocoFullReport + script: ./gradlew clean build connectedCheck - uses: codecov/codecov-action@v2.1.0 with: files: ./build/reports/jacoco/jacocoFullReport.xml - fail_ci_if_error: true + fail_ci_if_error: false verbose: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4f0565bd..8419b581 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,5 @@ Gemfile mapping/ **/*.exec + +.kotlin/ diff --git a/api/build.gradle b/api/build.gradle deleted file mode 100644 index 146f7141..00000000 --- a/api/build.gradle +++ /dev/null @@ -1,83 +0,0 @@ -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' - -android { - compileSdkVersion rootProject.ext.compileSdkVersion - - defaultConfig { - minSdkVersion rootProject.ext.minSdkVersion - targetSdkVersion rootProject.ext.targetSdkVersion - buildToolsVersion rootProject.ext.buildToolsVersion - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - lintOptions { - abortOnError false - } - - sourceSets { - androidTest.assets.srcDirs += files("$projectDir/androidTest/assets".toString()) - } - - buildTypes { - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - - debug { - minifyEnabled false - testCoverageEnabled true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' - } -} - -dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation project(':db') - - testImplementation 'junit:junit:4.13' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test:runner:1.4.0' - androidTestImplementation 'androidx.test:rules:1.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0' - - testImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version" - testImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version" - - implementation 'com.gitlab.mvysny.konsume-xml:konsume-xml:1.0' - implementation 'org.redundent:kotlin-xml-builder:1.7.3' - - implementation 'com.squareup.okhttp3:okhttp:4.9.1' - - implementation('com.squareup.retrofit2:retrofit:2.9.0') { - exclude group: 'com.squareup.okhttp3', module: 'okhttp3' - } - implementation('com.squareup.retrofit2:converter-moshi:2.9.0') { - exclude group: 'com.squareup.moshi', module: 'moshi' - } - - implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0' - - implementation 'com.squareup.moshi:moshi:1.12.0' - - api 'io.reactivex.rxjava2:rxandroid:2.1.1' - api 'org.jsoup:jsoup:1.13.1' - - debugApi 'com.chimerapps.niddler:niddler:1.5.5' - releaseApi 'com.chimerapps.niddler:niddler-noop:1.5.5' - - api "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2" - api "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1" - api "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:1.5.1" -} diff --git a/api/build.gradle.kts b/api/build.gradle.kts new file mode 100644 index 00000000..fd23d204 --- /dev/null +++ b/api/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("com.android.library") + kotlin("android") +} + +android { + namespace = "com.readrops.api" + + sourceSets { + getByName("androidTest") { + assets.srcDirs("$projectDir/androidTest/assets") + } + } + + kotlinOptions { + freeCompilerArgs = listOf("-Xstring-concat=inline") + } + + lint { + abortOnError = false + } +} + +dependencies { + implementation(project(":db")) + + coreLibraryDesugaring(libs.jdk.desugar) + + testImplementation(libs.junit4) + + implementation(libs.coroutines.core) + testImplementation(libs.coroutines.test) + + implementation(platform(libs.koin.bom)) + implementation(libs.bundles.koin) + //testImplementation(libs.bundles.kointest) + // I don't know why but those dependencies are unreachable when accessed directly from version catalog + testImplementation("io.insert-koin:koin-test:${libs.versions.koin.bom.get()}") + testImplementation("io.insert-koin:koin-test-junit4:${libs.versions.koin.bom.get()}") + + implementation(libs.konsumexml) + implementation(libs.kotlinxmlbuilder) + + implementation(libs.okhttp) + testImplementation(libs.okhttp.mockserver) + + implementation(libs.bundles.retrofit) { + exclude("com.squareup.okhttp3", "okhttp3") + exclude("com.squareup.moshi", "moshi") + } + + implementation(libs.moshi) + implementation(libs.jsoup) +} diff --git a/api/src/debug/AndroidManifest.xml b/api/src/debug/AndroidManifest.xml index bc80eafd..dd871fc0 100644 --- a/api/src/debug/AndroidManifest.xml +++ b/api/src/debug/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/api/src/main/AndroidManifest.xml b/api/src/main/AndroidManifest.xml index b1075664..0a0938ae 100644 --- a/api/src/main/AndroidManifest.xml +++ b/api/src/main/AndroidManifest.xml @@ -1,4 +1,3 @@ - + diff --git a/api/src/main/java/com/readrops/api/ApiModule.kt b/api/src/main/java/com/readrops/api/ApiModule.kt index a31ae2ac..726d1494 100644 --- a/api/src/main/java/com/readrops/api/ApiModule.kt +++ b/api/src/main/java/com/readrops/api/ApiModule.kt @@ -1,6 +1,5 @@ package com.readrops.api -import com.chimerapps.niddler.interceptor.okhttp.NiddlerOkHttpInterceptor import com.readrops.api.localfeed.LocalRSSDataSource import com.readrops.api.services.Credentials import com.readrops.api.services.fever.FeverDataSource @@ -8,13 +7,18 @@ import com.readrops.api.services.fever.FeverService import com.readrops.api.services.fever.adapters.* import com.readrops.api.services.freshrss.FreshRSSDataSource import com.readrops.api.services.freshrss.FreshRSSService -import com.readrops.api.services.freshrss.adapters.* -import com.readrops.api.services.nextcloudnews.NextNewsDataSource -import com.readrops.api.services.nextcloudnews.NextNewsService -import com.readrops.api.services.nextcloudnews.adapters.NextNewsFeedsAdapter -import com.readrops.api.services.nextcloudnews.adapters.NextNewsFoldersAdapter -import com.readrops.api.services.nextcloudnews.adapters.NextNewsItemsAdapter +import com.readrops.api.services.freshrss.adapters.FreshRSSFeedsAdapter +import com.readrops.api.services.freshrss.adapters.FreshRSSFoldersAdapter +import com.readrops.api.services.freshrss.adapters.FreshRSSItemsAdapter +import com.readrops.api.services.freshrss.adapters.FreshRSSItemsIdsAdapter +import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfoAdapter +import com.readrops.api.services.nextcloudnews.NextcloudNewsDataSource +import com.readrops.api.services.nextcloudnews.NextcloudNewsService +import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsFeedsAdapter +import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsFoldersAdapter +import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsItemsAdapter import com.readrops.api.utils.AuthInterceptor +import com.readrops.api.utils.ErrorInterceptor import com.readrops.db.entities.Item import com.squareup.moshi.Moshi import com.squareup.moshi.Types @@ -22,7 +26,6 @@ import okhttp3.OkHttpClient import org.koin.core.qualifier.named import org.koin.dsl.module import retrofit2.Retrofit -import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory import retrofit2.converter.moshi.MoshiConverterFactory import java.util.concurrent.TimeUnit @@ -30,15 +33,18 @@ val apiModule = module { single { OkHttpClient.Builder() - .callTimeout(1, TimeUnit.MINUTES) - .readTimeout(1, TimeUnit.HOURS) - .addInterceptor(get()) - .addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler")) - .build() + .callTimeout(1, TimeUnit.MINUTES) + .readTimeout(1, TimeUnit.MINUTES) + .addInterceptor(get()) + .addInterceptor(get()) + //.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler")) + .build() } single { AuthInterceptor() } + single { ErrorInterceptor() } + single { LocalRSSDataSource(get()) } //region freshrss @@ -47,12 +53,11 @@ val apiModule = module { factory { (credentials: Credentials) -> Retrofit.Builder() - .baseUrl(credentials.url) - .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) - .client(get()) - .addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi")))) - .build() - .create(FreshRSSService::class.java) + .baseUrl(credentials.url) + .client(get()) + .addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi")))) + .build() + .create(FreshRSSService::class.java) } single(named("freshrssMoshi")) { @@ -69,23 +74,22 @@ val apiModule = module { //region nextcloud news - factory { params -> NextNewsDataSource(get(parameters = { params })) } + factory { params -> NextcloudNewsDataSource(get(parameters = { params })) } factory { (credentials: Credentials) -> Retrofit.Builder() .baseUrl(credentials.url) - .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .client(get()) .addConverterFactory(MoshiConverterFactory.create(get(named("nextcloudNewsMoshi")))) .build() - .create(NextNewsService::class.java) + .create(NextcloudNewsService::class.java) } single(named("nextcloudNewsMoshi")) { Moshi.Builder() - .add(NextNewsFeedsAdapter()) - .add(NextNewsFoldersAdapter()) - .add(Types.newParameterizedType(List::class.java, Item::class.java), NextNewsItemsAdapter()) + .add(NextcloudNewsFeedsAdapter()) + .add(NextcloudNewsFoldersAdapter()) + .add(Types.newParameterizedType(List::class.java, Item::class.java), NextcloudNewsItemsAdapter()) .build() } diff --git a/api/src/main/java/com/readrops/api/localfeed/LocalRSSDataSource.kt b/api/src/main/java/com/readrops/api/localfeed/LocalRSSDataSource.kt index 4f536dda..cf2a4bc6 100644 --- a/api/src/main/java/com/readrops/api/localfeed/LocalRSSDataSource.kt +++ b/api/src/main/java/com/readrops/api/localfeed/LocalRSSDataSource.kt @@ -1,12 +1,12 @@ package com.readrops.api.localfeed -import android.accounts.NetworkErrorException import androidx.annotation.WorkerThread import com.gitlab.mvysny.konsumexml.Konsumer import com.gitlab.mvysny.konsumexml.konsumeXml import com.readrops.api.localfeed.json.JSONFeedAdapter import com.readrops.api.utils.ApiUtils import com.readrops.api.utils.AuthInterceptor +import com.readrops.api.utils.exceptions.HttpException import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.exceptions.UnknownFormatException import com.readrops.db.entities.Feed @@ -21,7 +21,6 @@ import okio.Buffer import org.koin.core.component.KoinComponent import org.koin.core.component.get import java.io.IOException -import java.lang.Exception import java.net.HttpURLConnection class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent { @@ -32,7 +31,7 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent { * @param headers request headers * @return a Feed object with its items */ - @Throws(ParseException::class, UnknownFormatException::class, NetworkErrorException::class, IOException::class) + @Throws(ParseException::class, UnknownFormatException::class, HttpException::class, IOException::class) @WorkerThread fun queryRSSResource(url: String, headers: Headers?): Pair>? { get().credentials = null @@ -46,7 +45,7 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent { pair } response.code == HttpURLConnection.HTTP_NOT_MODIFIED -> null - else -> throw NetworkErrorException("$url returned ${response.code} code : ${response.message}") + else -> throw HttpException(response) } } @@ -74,7 +73,8 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent { val rootKonsumer = nextElement(LocalRSSHelper.RSS_ROOT_NAMES) rootKonsumer?.let { type = LocalRSSHelper.guessRSSType(rootKonsumer) } } catch (e: Exception) { - throw UnknownFormatException(e.message) + close() + return false } } diff --git a/api/src/main/java/com/readrops/api/localfeed/atom/ATOMFeedAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/atom/ATOMFeedAdapter.kt index af91ace8..3f2d63ae 100644 --- a/api/src/main/java/com/readrops/api/localfeed/atom/ATOMFeedAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/atom/ATOMFeedAdapter.kt @@ -29,6 +29,7 @@ class ATOMFeedAdapter : XmlAdapter>> { "link" -> parseLink(this@allChildrenAutoIgnore, feed) "subtitle" -> description = nullableText() "entry" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore) + else -> skipContents() } } } diff --git a/api/src/main/java/com/readrops/api/localfeed/atom/ATOMItemAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/atom/ATOMItemAdapter.kt index a7ea08a3..de0cc8df 100644 --- a/api/src/main/java/com/readrops/api/localfeed/atom/ATOMItemAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/atom/ATOMItemAdapter.kt @@ -4,13 +4,13 @@ import com.gitlab.mvysny.konsumexml.Konsumer import com.gitlab.mvysny.konsumexml.Names import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore import com.readrops.api.localfeed.XmlAdapter -import com.readrops.api.utils.* import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nonNullText import com.readrops.api.utils.extensions.nullableText import com.readrops.api.utils.extensions.nullableTextRecursively import com.readrops.db.entities.Item -import org.joda.time.LocalDateTime +import com.readrops.db.util.DateUtils +import java.time.LocalDateTime class ATOMItemAdapter : XmlAdapter { @@ -22,7 +22,7 @@ class ATOMItemAdapter : XmlAdapter { konsumer.allChildrenAutoIgnore(names) { when (tagName) { "title" -> title = nonNullText() - "id" -> guid = nullableText() + "id" -> remoteId = nullableText() "updated" -> pubDate = DateUtils.parse(nullableText()) "link" -> parseLink(this, this@apply) "author" -> allChildrenAutoIgnore("name") { author = nullableText() } @@ -35,7 +35,7 @@ class ATOMItemAdapter : XmlAdapter { validateItem(item) if (item.pubDate == null) item.pubDate = LocalDateTime.now() - if (item.guid == null) item.guid = item.link + if (item.remoteId == null) item.remoteId = item.link item } catch (e: Exception) { diff --git a/api/src/main/java/com/readrops/api/localfeed/json/JSONItemsAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/json/JSONItemsAdapter.kt index 75e43c6c..1106193a 100644 --- a/api/src/main/java/com/readrops/api/localfeed/json/JSONItemsAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/json/JSONItemsAdapter.kt @@ -1,15 +1,15 @@ package com.readrops.api.localfeed.json import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX -import com.readrops.api.utils.DateUtils import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nextNonEmptyString import com.readrops.api.utils.extensions.nextNullableString import com.readrops.db.entities.Item +import com.readrops.db.util.DateUtils import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonReader import com.squareup.moshi.JsonWriter -import org.joda.time.LocalDateTime +import java.time.LocalDateTime class JSONItemsAdapter : JsonAdapter>() { @@ -33,7 +33,7 @@ class JSONItemsAdapter : JsonAdapter>() { while (hasNext()) { with(item) { when (selectName(names)) { - 0 -> guid = nextNonEmptyString() + 0 -> remoteId = nextNonEmptyString() 1 -> link = nextNonEmptyString() 2 -> title = nextNonEmptyString() 3 -> contentHtml = nextNullableString() diff --git a/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1FeedAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1FeedAdapter.kt index 31cf3546..ef0beff7 100644 --- a/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1FeedAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1FeedAdapter.kt @@ -26,6 +26,7 @@ class RSS1FeedAdapter : XmlAdapter>> { when (tagName) { "channel" -> parseChannel(this, feed) "item" -> items += itemAdapter.fromXml(this) + else -> skipContents() } } } diff --git a/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1ItemAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1ItemAdapter.kt index 9f838dcf..ce207bd5 100644 --- a/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1ItemAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1ItemAdapter.kt @@ -5,13 +5,13 @@ import com.gitlab.mvysny.konsumexml.Names import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore import com.readrops.api.localfeed.XmlAdapter import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX -import com.readrops.api.utils.* import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nonNullText import com.readrops.api.utils.extensions.nullableText import com.readrops.api.utils.extensions.nullableTextRecursively import com.readrops.db.entities.Item -import org.joda.time.LocalDateTime +import com.readrops.db.util.DateUtils +import java.time.LocalDateTime class RSS1ItemAdapter : XmlAdapter { @@ -40,7 +40,7 @@ class RSS1ItemAdapter : XmlAdapter { if (item.pubDate == null) item.pubDate = LocalDateTime.now() if (item.link == null) item.link = about ?: throw ParseException("RSS1 link or about element is required") - item.guid = item.link + item.remoteId = item.link if (authors.filterNotNull().isNotEmpty()) item.author = authors.filterNotNull() .joinToString(limit = AUTHORS_MAX) diff --git a/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2ItemAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2ItemAdapter.kt index fc36d3ba..7adce905 100644 --- a/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2ItemAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2ItemAdapter.kt @@ -1,15 +1,19 @@ package com.readrops.api.localfeed.rss2 -import com.gitlab.mvysny.konsumexml.* +import com.gitlab.mvysny.konsumexml.Konsumer +import com.gitlab.mvysny.konsumexml.KonsumerException +import com.gitlab.mvysny.konsumexml.Names +import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore import com.readrops.api.localfeed.XmlAdapter import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX -import com.readrops.api.utils.* +import com.readrops.api.utils.ApiUtils import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nonNullText import com.readrops.api.utils.extensions.nullableText import com.readrops.api.utils.extensions.nullableTextRecursively import com.readrops.db.entities.Item -import org.joda.time.LocalDateTime +import com.readrops.db.util.DateUtils +import java.time.LocalDateTime class RSS2ItemAdapter : XmlAdapter { @@ -29,7 +33,7 @@ class RSS2ItemAdapter : XmlAdapter { "dc:creator" -> creators += nullableText() "pubDate" -> pubDate = DateUtils.parse(nullableText()) "dc:date" -> pubDate = DateUtils.parse(nullableText()) - "guid" -> guid = nullableText() + "guid" -> remoteId = nullableText() "description" -> description = nullableTextRecursively() "content:encoded" -> content = nullableTextRecursively() "enclosure" -> parseEnclosure(this, item = this@apply) @@ -81,7 +85,7 @@ class RSS2ItemAdapter : XmlAdapter { validateItem(this) if (pubDate == null) pubDate = LocalDateTime.now() - if (guid == null) guid = link + if (remoteId == null) remoteId = link if (author == null && creators.filterNotNull().isNotEmpty()) author = creators.filterNotNull().joinToString(limit = AUTHORS_MAX) } diff --git a/api/src/main/java/com/readrops/api/opml/OPMLParser.kt b/api/src/main/java/com/readrops/api/opml/OPMLParser.kt index d7d3fe0e..5ec352ce 100644 --- a/api/src/main/java/com/readrops/api/opml/OPMLParser.kt +++ b/api/src/main/java/com/readrops/api/opml/OPMLParser.kt @@ -1,59 +1,45 @@ package com.readrops.api.opml import com.gitlab.mvysny.konsumexml.konsumeXml +import com.readrops.api.utils.exceptions.ParseException import com.readrops.db.entities.Feed import com.readrops.db.entities.Folder -import io.reactivex.Completable -import io.reactivex.Single import org.redundent.kotlin.xml.xml import java.io.InputStream import java.io.OutputStream object OPMLParser { - @JvmStatic - fun read(stream: InputStream): Single>> { - return Single.create { emitter -> - try { - val adapter = OPMLAdapter() - val opml = adapter.fromXml(stream.konsumeXml()) + suspend fun read(stream: InputStream): Map> { + try { + val adapter = OPMLAdapter() + val opml = adapter.fromXml(stream.konsumeXml()) - emitter.onSuccess(opml) - } catch (e: Exception) { - emitter.onError(e) - } + stream.close() + return opml + } catch (e: Exception) { + throw ParseException(e.message) } } - @JvmStatic - fun write(foldersAndFeeds: Map>, outputStream: OutputStream): Completable { - return Completable.create { emitter -> - val opml = xml("opml") { - attribute("version", "2.0") + suspend fun write(foldersAndFeeds: Map>, outputStream: OutputStream) { + val opml = xml("opml") { + attribute("version", "2.0") - "head" { - -"Subscriptions" - } + "head" { + -"Subscriptions" + } - "body" { - for (folderAndFeeds in foldersAndFeeds) { - if (folderAndFeeds.key != null) { // feeds with folder - "outline" { - folderAndFeeds.key?.name?.let { - attribute("title", it) - attribute("text", it) - } - - for (feed in folderAndFeeds.value) { - "outline" { - feed.name?.let { attribute("title", it) } - attribute("xmlUrl", feed.url!!) - feed.siteUrl?.let { attribute("htmlUrl", it) } - } - } + "body" { + for (folderAndFeeds in foldersAndFeeds) { + if (folderAndFeeds.key != null) { // feeds with folder + "outline" { + folderAndFeeds.key?.name?.let { + attribute("title", it) + attribute("text", it) } - } else { - for (feed in folderAndFeeds.value) { // feeds without folder + + for (feed in folderAndFeeds.value) { "outline" { feed.name?.let { attribute("title", it) } attribute("xmlUrl", feed.url!!) @@ -61,14 +47,21 @@ object OPMLParser { } } } + } else { + for (feed in folderAndFeeds.value) { // feeds without folder + "outline" { + feed.name?.let { attribute("title", it) } + attribute("xmlUrl", feed.url!!) + feed.siteUrl?.let { attribute("htmlUrl", it) } + } + } } } } - - outputStream.write(opml.toString().toByteArray()) - outputStream.flush() - - emitter.onComplete() } + + outputStream.write(opml.toString().toByteArray()) + outputStream.flush() + outputStream.close() } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/Credentials.kt b/api/src/main/java/com/readrops/api/services/Credentials.kt index b4274067..0fc421a9 100644 --- a/api/src/main/java/com/readrops/api/services/Credentials.kt +++ b/api/src/main/java/com/readrops/api/services/Credentials.kt @@ -3,8 +3,8 @@ package com.readrops.api.services import com.readrops.api.services.fever.FeverCredentials import com.readrops.api.services.freshrss.FreshRSSCredentials import com.readrops.api.services.freshrss.FreshRSSService -import com.readrops.api.services.nextcloudnews.NextNewsCredentials -import com.readrops.api.services.nextcloudnews.NextNewsService +import com.readrops.api.services.nextcloudnews.NextcloudNewsCredentials +import com.readrops.api.services.nextcloudnews.NextcloudNewsService import com.readrops.db.entities.account.Account import com.readrops.db.entities.account.AccountType @@ -16,7 +16,7 @@ abstract class Credentials(val authorization: String?, val url: String) { val endPoint = getEndPoint(account.accountType!!) return when (account.accountType) { - AccountType.NEXTCLOUD_NEWS -> NextNewsCredentials(account.login, account.password, account.url + endPoint) + AccountType.NEXTCLOUD_NEWS -> NextcloudNewsCredentials(account.login, account.password, account.url + endPoint) AccountType.FRESHRSS -> FreshRSSCredentials(account.token, account.url + endPoint) AccountType.FEVER -> FeverCredentials(account.login, account.password, account.url + endPoint) else -> throw IllegalArgumentException("Unknown account type") @@ -26,7 +26,7 @@ abstract class Credentials(val authorization: String?, val url: String) { private fun getEndPoint(accountType: AccountType): String { return when (accountType) { AccountType.FRESHRSS -> FreshRSSService.END_POINT - AccountType.NEXTCLOUD_NEWS -> NextNewsService.END_POINT + AccountType.NEXTCLOUD_NEWS -> NextcloudNewsService.END_POINT AccountType.FEVER -> "" else -> throw IllegalArgumentException("Unknown account type") } diff --git a/api/src/main/java/com/readrops/api/services/DataSourceResult.kt b/api/src/main/java/com/readrops/api/services/DataSourceResult.kt new file mode 100644 index 00000000..a96ccc0d --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/DataSourceResult.kt @@ -0,0 +1,15 @@ +package com.readrops.api.services + +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.Item + +data class DataSourceResult( + var items: List = mutableListOf(), + var starredItems: List = mutableListOf(), + var feeds: List = listOf(), + var folders: List = listOf(), + var unreadIds: List = listOf(), + var readIds: List = listOf(), + var starredIds: List = listOf(), +) diff --git a/api/src/main/java/com/readrops/api/services/SyncResult.kt b/api/src/main/java/com/readrops/api/services/SyncResult.kt deleted file mode 100644 index aff7c954..00000000 --- a/api/src/main/java/com/readrops/api/services/SyncResult.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.readrops.api.services - -import com.readrops.db.entities.Feed -import com.readrops.db.entities.Folder -import com.readrops.db.entities.Item - -class SyncResult(var items: List = mutableListOf(), - var starredItems: List = mutableListOf(), - var feeds: List = listOf(), - var folders: List = listOf(), - var unreadIds: List? = null, - var readIds: List? = null, - var starredIds: List? = null, - var isError: Boolean = false -) diff --git a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverItemsAdapter.kt b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverItemsAdapter.kt index 8290c1ab..8257e67b 100644 --- a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverItemsAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverItemsAdapter.kt @@ -7,10 +7,10 @@ import com.readrops.api.utils.extensions.nextNullableString import com.readrops.api.utils.extensions.skipField import com.readrops.api.utils.extensions.toBoolean import com.readrops.db.entities.Item +import com.readrops.db.util.DateUtils import com.squareup.moshi.FromJson import com.squareup.moshi.JsonReader import com.squareup.moshi.ToJson -import org.joda.time.LocalDateTime class FeverItemsAdapter { @@ -47,7 +47,7 @@ class FeverItemsAdapter { 5 -> link = nextNullableString() 6 -> isRead = nextInt().toBoolean() 7 -> isStarred = nextInt().toBoolean() - 8 -> pubDate = LocalDateTime(nextLong() * 1000L) + 8 -> pubDate = DateUtils.fromEpochSeconds(nextLong()) else -> skipValue() } } diff --git a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSDataSource.java b/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSDataSource.java deleted file mode 100644 index be17ec01..00000000 --- a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSDataSource.java +++ /dev/null @@ -1,316 +0,0 @@ -package com.readrops.api.services.freshrss; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.readrops.api.services.SyncResult; -import com.readrops.api.services.SyncType; -import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.Item; - -import java.io.StringReader; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Properties; - -import io.reactivex.Completable; -import io.reactivex.Single; -import okhttp3.MultipartBody; -import okhttp3.RequestBody; - -public class FreshRSSDataSource { - - private static final int MAX_ITEMS = 2500; - private static final int MAX_STARRED_ITEMS = 1000; - - public static final String GOOGLE_READ = "user/-/state/com.google/read"; - public static final String GOOGLE_UNREAD = "user/-/state/com.google/unread"; - public static final String GOOGLE_STARRED = "user/-/state/com.google/starred"; - public static final String GOOGLE_READING_LIST = "user/-/state/com.google/reading-list"; - - private static final String FEED_PREFIX = "feed/"; - - private final FreshRSSService api; - - public FreshRSSDataSource(FreshRSSService api) { - this.api = api; - } - - /** - * Call token API to generate a new token from account credentials - * - * @param login login - * @param password password - * @return the generated token - */ - public Single login(@NonNull String login, @NonNull String password) { - RequestBody requestBody = new MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart("Email", login) - .addFormDataPart("Passwd", password) - .build(); - - return api.login(requestBody) - .flatMap(response -> { - Properties properties = new Properties(); - properties.load(new StringReader(response.string())); - - return Single.just(properties.getProperty("Auth")); - }); - } - - /** - * Get a write token to modify feeds, folders and items on the server - * - * @return the write token generated by the server - */ - public Single getWriteToken() { - return api.getWriteToken() - .flatMap(responseBody -> Single.just(responseBody.string())); - } - - /** - * Retrieve user information : name, email, id, profileId - * - * @return user information - */ - public Single getUserInfo() { - return api.getUserInfo(); - } - - /** - * Synchronize feeds, folders, items and push read/unread items - * - * @param syncType INITIAL or CLASSIC - * @param syncData data to sync (read/unread items ids, lastModified timestamp) - * @param writeToken token for making modifications on the server - * @return the result of the synchronization - */ - public Single sync(@NonNull SyncType syncType, @NonNull FreshRSSSyncData syncData, @NonNull String writeToken) { - if (syncType == SyncType.INITIAL_SYNC) { - return Single.zip(setItemsReadState(syncData, writeToken).toSingleDefault(""), - setItemsStarState(syncData, writeToken).toSingleDefault(""), - getFolders(), - getFeeds(), - getItems(Arrays.asList(GOOGLE_READ, GOOGLE_STARRED), MAX_ITEMS, null), - getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS), // unread items ids - getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS), // starred items ids - getStarredItems(MAX_STARRED_ITEMS), - (readState, starState, folders, feeds, items, unreadItemsIds, starredItemsIds, starredItems) -> - new SyncResult(items, starredItems, feeds, folders, unreadItemsIds, Collections.emptyList(), starredItemsIds, false) - ); - } else { - return Single.zip(setItemsReadState(syncData, writeToken).toSingleDefault(""), - setItemsStarState(syncData, writeToken).toSingleDefault(""), - getFolders(), - getFeeds(), - getItems(null, MAX_ITEMS, syncData.getLastModified()), - getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS), // unread items ids - getItemsIds(GOOGLE_UNREAD, GOOGLE_READING_LIST, MAX_ITEMS), // read items ids - getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS), // starred items ids - (readState, starState, folders, feeds, items, unreadItemsIds, readItemsIds, starredItemsIds) -> - new SyncResult(items, Collections.emptyList(), feeds, folders, unreadItemsIds, readItemsIds, starredItemsIds, false) - - ); - } - } - - /** - * Fetch the feeds folders - * - * @return the feeds folders - */ - public Single> getFolders() { - return api.getFolders(); - } - - /** - * Fetch the feeds - * - * @return the feeds - */ - public Single> getFeeds() { - return api.getFeeds(); - } - - /** - * Fetch the items - * - * @param excludeTargets type of items to exclude (read items and starred items) - * @param max max number of items to fetch - * @param lastModified fetch only items created after this timestamp - * @return the items - */ - public Single> getItems(@Nullable List excludeTargets, int max, @Nullable Long lastModified) { - return api.getItems(excludeTargets, max, lastModified); - } - - /** - * Fetch starred items - * - * @param max max number of items to fetch - * @return items - */ - public Single> getStarredItems(int max) { - return api.getStarredItems(max); - } - - public Single> getItemsIds(String excludeTarget, String includeTarget, int max) { - return api.getItemsIds(excludeTarget, includeTarget, max); - } - - - /** - * Mark items read or unread - * - * @param read true for read, false for unread - * @param itemIds items ids to mark - * @param token token for modifications - * @return Completable - */ - public Completable setItemsReadState(boolean read, @NonNull List itemIds, @NonNull String token) { - if (read) { - return api.setItemsState(token, GOOGLE_READ, null, itemIds); - } else { - return api.setItemsState(token, null, GOOGLE_READ, itemIds); - } - } - - /** - * Mark items as starred or unstarred - * - * @param starred true for starred, false for unstarred - * @param itemIds items ids to mark - * @param token token for modifications - * @return Completable - */ - public Completable setItemsStarState(boolean starred, @NonNull List itemIds, @NonNull String token) { - if (starred) { - return api.setItemsState(token, GOOGLE_STARRED, null, itemIds); - } else { - return api.setItemsState(token, null, GOOGLE_STARRED, itemIds); - } - } - - /** - * Create a new feed - * - * @param token token for modifications - * @param feedUrl url of the feed to parse - * @return Completable - */ - public Completable createFeed(@NonNull String token, @NonNull String feedUrl) { - return api.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe"); - } - - /** - * Delete a feed - * - * @param token token for modifications - * @param feedUrl url of the feed to delete - * @return Completable - */ - public Completable deleteFeed(@NonNull String token, @NonNull String feedUrl) { - return api.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe"); - } - - /** - * Update feed title and folder - * - * @param token token for modifications - * @param feedUrl url of the feed to update - * @param title new title - * @param folderId id of the new folder - * @return Completable - */ - public Completable updateFeed(@NonNull String token, @NonNull String feedUrl, @NonNull String title, @NonNull String folderId) { - return api.updateFeed(token, FEED_PREFIX + feedUrl, title, folderId, "edit"); - } - - /** - * Create a new folder - * - * @param token token for modifications - * @param tagName name of the new folder - * @return Completable - */ - public Completable createFolder(@NonNull String token, @NonNull String tagName) { - return api.createFolder(token, "user/-/label/" + tagName); - } - - /** - * Update folder name - * - * @param token token for modifications - * @param folderId id of the folder - * @param name new folder name - * @return Completable - */ - public Completable updateFolder(@NonNull String token, @NonNull String folderId, @NonNull String name) { - return api.updateFolder(token, folderId, "user/-/label/" + name); - } - - /** - * Delete a folder - * - * @param token token for modifications - * @param folderId id of the folder to delete - * @return Completable - */ - public Completable deleteFolder(@NonNull String token, @NonNull String folderId) { - return api.deleteFolder(token, folderId); - } - - /** - * Set items star state - * - * @param syncData data containing items to mark - * @param token token for modifications - * @return A concatenation of two completable (read and unread completable) - */ - private Completable setItemsReadState(@NonNull FreshRSSSyncData syncData, @NonNull String token) { - Completable readItemsCompletable; - if (syncData.getReadItemsIds().isEmpty()) { - readItemsCompletable = Completable.complete(); - } else { - readItemsCompletable = setItemsReadState(true, syncData.getReadItemsIds(), token); - } - - Completable unreadItemsCompletable; - if (syncData.getUnreadItemsIds().isEmpty()) { - unreadItemsCompletable = Completable.complete(); - } else { - unreadItemsCompletable = setItemsReadState(false, syncData.getUnreadItemsIds(), token); - } - - return readItemsCompletable.concatWith(unreadItemsCompletable); - } - - /** - * Set items star state - * - * @param syncData data containing items to mark - * @param token token for modifications - * @return A concatenation of two completable (starred and unstarred completable) - */ - private Completable setItemsStarState(@NonNull FreshRSSSyncData syncData, @NonNull String token) { - Completable starredItemsCompletable; - if (syncData.getStarredItemsIds().isEmpty()) { - starredItemsCompletable = Completable.complete(); - } else { - starredItemsCompletable = setItemsStarState(true, syncData.getStarredItemsIds(), token); - } - - Completable unstarredItemsCompletable; - if (syncData.getUnstarredItemsIds().isEmpty()) { - unstarredItemsCompletable = Completable.complete(); - } else { - unstarredItemsCompletable = setItemsStarState(false, syncData.getUnstarredItemsIds(), token); - } - - return starredItemsCompletable.concatWith(unstarredItemsCompletable); - } -} diff --git a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSDataSource.kt b/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSDataSource.kt new file mode 100644 index 00000000..137efee8 --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSDataSource.kt @@ -0,0 +1,164 @@ +package com.readrops.api.services.freshrss + +import com.readrops.api.services.DataSourceResult +import com.readrops.api.services.SyncType +import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo +import com.readrops.db.entities.Item +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import okhttp3.MultipartBody +import java.io.StringReader +import java.util.Properties + +class FreshRSSDataSource(private val service: FreshRSSService) { + + suspend fun login(login: String, password: String): String { + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("Email", login) + .addFormDataPart("Passwd", password) + .build() + + val response = service.login(requestBody) + + val properties = Properties() + properties.load(StringReader(response.string())) + + response.close() + return properties.getProperty("Auth") + } + + suspend fun getWriteToken(): String = service.getWriteToken().string() + + suspend fun getUserInfo(): FreshRSSUserInfo = service.userInfo() + + suspend fun synchronize( + syncType: SyncType, + syncData: FreshRSSSyncData, + writeToken: String + ): DataSourceResult = with(CoroutineScope(Dispatchers.IO)) { + return if (syncType == SyncType.INITIAL_SYNC) { + DataSourceResult().apply { + listOf( + async { folders = getFolders() }, + async { feeds = getFeeds() }, + async { + items = getItems(listOf(GOOGLE_READ, GOOGLE_STARRED), MAX_ITEMS, null) + }, + async { starredItems = getStarredItems(MAX_STARRED_ITEMS) }, + async { unreadIds = getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS) }, + async { starredIds = getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS) } + ).awaitAll() + + } + } else { + DataSourceResult().apply { + listOf( + async { setItemsReadState(syncData, writeToken) }, + async { setItemsStarState(syncData, writeToken) }, + ).awaitAll() + + listOf( + async { folders = getFolders() }, + async { feeds = getFeeds() }, + async { items = getItems(null, MAX_ITEMS, syncData.lastModified) }, + async { unreadIds = getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS) }, + async { + readIds = getItemsIds(GOOGLE_UNREAD, GOOGLE_READING_LIST, MAX_ITEMS) + }, + async { starredIds = getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS) } + ).awaitAll() + } + } + + } + + suspend fun getFolders() = service.getFolders() + + suspend fun getFeeds() = service.getFeeds() + + suspend fun getItems(excludeTargets: List?, max: Int, lastModified: Long?): List { + return service.getItems(excludeTargets, max, lastModified) + } + + suspend fun getStarredItems(max: Int) = service.getStarredItems(max) + + suspend fun getItemsIds(excludeTarget: String?, includeTarget: String, max: Int): List { + return service.getItemsIds(excludeTarget, includeTarget, max) + } + + private suspend fun setItemsReadState(read: Boolean, itemIds: List, token: String) { + return if (read) { + service.setItemsState(token, GOOGLE_READ, null, itemIds) + } else { + service.setItemsState(token, null, GOOGLE_READ, itemIds) + } + } + + private suspend fun setItemStarState(starred: Boolean, itemIds: List, token: String) { + return if (starred) { + service.setItemsState(token, GOOGLE_STARRED, null, itemIds) + } else { + service.setItemsState(token, null, GOOGLE_STARRED, itemIds) + } + } + + suspend fun createFeed(token: String, feedUrl: String) { + service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe") + } + + suspend fun deleteFeed(token: String, feedUrl: String) { + service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe") + } + + suspend fun updateFeed(token: String, feedUrl: String, title: String, folderId: String) { + service.updateFeed(token, FEED_PREFIX + feedUrl, title, folderId, "edit") + } + + suspend fun createFolder(token: String, tagName: String) { + service.createFolder(token, "$FOLDER_PREFIX$tagName") + } + + suspend fun updateFolder(token: String, folderId: String, name: String) { + service.updateFolder(token, folderId, "$FOLDER_PREFIX$name") + } + + suspend fun deleteFolder(token: String, folderId: String) { + service.deleteFolder(token, folderId) + } + + private suspend fun setItemsReadState(syncData: FreshRSSSyncData, token: String) { + if (syncData.readIds.isNotEmpty()) { + setItemsReadState(true, syncData.readIds, token) + } + + if (syncData.unreadIds.isNotEmpty()) { + setItemsReadState(false, syncData.unreadIds, token) + } + } + + private suspend fun setItemsStarState(syncData: FreshRSSSyncData, token: String) { + if (syncData.starredIds.isNotEmpty()) { + setItemStarState(true, syncData.starredIds, token) + } + + if (syncData.unstarredIds.isNotEmpty()) { + setItemStarState(false, syncData.unstarredIds, token) + } + } + + companion object { + private const val MAX_ITEMS = 2500 + private const val MAX_STARRED_ITEMS = 1000 + + const val GOOGLE_READ = "user/-/state/com.google/read" + const val GOOGLE_UNREAD = "user/-/state/com.google/unread" + const val GOOGLE_STARRED = "user/-/state/com.google/starred" + const val GOOGLE_READING_LIST = "user/-/state/com.google/reading-list" + + const val FEED_PREFIX = "feed/" + const val FOLDER_PREFIX = "user/-/label/" + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSService.kt b/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSService.kt index 4069b1bb..3dfd0d55 100644 --- a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSService.kt +++ b/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSService.kt @@ -4,65 +4,68 @@ import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo import com.readrops.db.entities.Feed import com.readrops.db.entities.Folder import com.readrops.db.entities.Item -import io.reactivex.Completable -import io.reactivex.Single import okhttp3.RequestBody import okhttp3.ResponseBody -import retrofit2.http.* +import retrofit2.http.Body +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query interface FreshRSSService { @POST("accounts/ClientLogin") - fun login(@Body body: RequestBody?): Single? + suspend fun login(@Body body: RequestBody?): ResponseBody - @get:GET("reader/api/0/token") - val writeToken: Single + @GET("reader/api/0/token") + suspend fun getWriteToken(): ResponseBody - @get:GET("reader/api/0/user-info") - val userInfo: Single + @GET("reader/api/0/user-info") + suspend fun userInfo(): FreshRSSUserInfo - @get:GET("reader/api/0/subscription/list?output=json") - val feeds: Single> + @GET("reader/api/0/subscription/list?output=json") + suspend fun getFeeds(): List - @get:GET("reader/api/0/tag/list?output=json") - val folders: Single> + @GET("reader/api/0/tag/list?output=json") + suspend fun getFolders(): List @GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list") - fun getItems(@Query("xt") excludeTarget: List?, @Query("n") max: Int, - @Query("ot") lastModified: Long?): Single> + suspend fun getItems(@Query("xt") excludeTarget: List?, @Query("n") max: Int, + @Query("ot") lastModified: Long?): List @GET("reader/api/0/stream/contents/user/-/state/com.google/starred") - fun getStarredItems(@Query("n") max: Int): Single> + suspend fun getStarredItems(@Query("n") max: Int): List @GET("reader/api/0/stream/items/ids") - fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?, - @Query("n") max: Int): Single> + suspend fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?, + @Query("n") max: Int): List @FormUrlEncoded @POST("reader/api/0/edit-tag") - fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?, - @Field("r") removeAction: String?, @Field("i") itemIds: List): Completable + suspend fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?, + @Field("r") removeAction: String?, @Field("i") itemIds: List) @FormUrlEncoded @POST("reader/api/0/subscription/edit") - fun createOrDeleteFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("ac") action: String): Completable + suspend fun createOrDeleteFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("ac") action: String) @FormUrlEncoded @POST("reader/api/0/subscription/edit") - fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String, - @Field("a") folderId: String, @Field("ac") action: String): Completable + suspend fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String, + @Field("a") folderId: String, @Field("ac") action: String) @FormUrlEncoded @POST("reader/api/0/edit-tag") - fun createFolder(@Field("T") token: String, @Field("a") tagName: String): Completable + suspend fun createFolder(@Field("T") token: String, @Field("a") tagName: String) @FormUrlEncoded @POST("reader/api/0/rename-tag") - fun updateFolder(@Field("T") token: String, @Field("s") folderId: String, @Field("dest") newFolderId: String): Completable + suspend fun updateFolder(@Field("T") token: String, @Field("s") folderId: String, @Field("dest") newFolderId: String) @FormUrlEncoded @POST("reader/api/0/disable-tag") - fun deleteFolder(@Field("T") token: String, @Field("s") folderId: String): Completable + suspend fun deleteFolder(@Field("T") token: String, @Field("s") folderId: String) companion object { const val END_POINT = "/api/greader.php/" diff --git a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSSyncData.kt b/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSSyncData.kt index e783b4ac..80ba5ce7 100644 --- a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSSyncData.kt +++ b/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSSyncData.kt @@ -1,9 +1,9 @@ package com.readrops.api.services.freshrss data class FreshRSSSyncData( - var lastModified: Long = 0, - var readItemsIds: List = listOf(), - var unreadItemsIds: List = listOf(), - var starredItemsIds: List = listOf(), - var unstarredItemsIds: List = listOf(), + var lastModified: Long = 0, + var readIds: List = listOf(), + var unreadIds: List = listOf(), + var starredIds: List = listOf(), + var unstarredIds: List = listOf(), ) \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapter.kt b/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapter.kt index 74cf14b3..e3318b56 100644 --- a/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapter.kt @@ -1,17 +1,15 @@ package com.readrops.api.services.freshrss.adapters -import android.util.TimingLogger -import com.readrops.api.services.freshrss.FreshRSSDataSource.GOOGLE_READ -import com.readrops.api.services.freshrss.FreshRSSDataSource.GOOGLE_STARRED +import com.readrops.api.services.freshrss.FreshRSSDataSource.Companion.GOOGLE_READ +import com.readrops.api.services.freshrss.FreshRSSDataSource.Companion.GOOGLE_STARRED import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nextNonEmptyString import com.readrops.api.utils.extensions.nextNullableString import com.readrops.db.entities.Item +import com.readrops.db.util.DateUtils import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonReader import com.squareup.moshi.JsonWriter -import org.joda.time.DateTimeZone -import org.joda.time.LocalDateTime class FreshRSSItemsAdapter : JsonAdapter>() { @@ -47,8 +45,7 @@ class FreshRSSItemsAdapter : JsonAdapter>() { with(item) { when (reader.selectName(NAMES)) { 0 -> remoteId = reader.nextNonEmptyString() - 1 -> pubDate = LocalDateTime(reader.nextLong() * 1000L, - DateTimeZone.getDefault()) + 1 -> pubDate = DateUtils.fromEpochSeconds(reader.nextLong()) 2 -> title = reader.nextNonEmptyString() 3 -> content = getContent(reader) 4 -> link = getLink(reader) @@ -108,7 +105,6 @@ class FreshRSSItemsAdapter : JsonAdapter>() { when (reader.nextString()) { GOOGLE_READ -> item.isRead = true GOOGLE_STARRED -> item.isStarred = true - else -> reader.skipValue() } } diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsCredentials.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsCredentials.kt deleted file mode 100644 index 21c86283..00000000 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsCredentials.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.readrops.api.services.nextcloudnews - -import com.readrops.api.services.Credentials - -class NextNewsCredentials(login: String?, password: String?, url: String): - Credentials((login != null && password != null).let { - okhttp3.Credentials.basic(login!!, password!!) - }, url) \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsDataSource.java b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsDataSource.java deleted file mode 100644 index 43708b6f..00000000 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsDataSource.java +++ /dev/null @@ -1,300 +0,0 @@ -package com.readrops.api.services.nextcloudnews; - -import android.content.res.Resources; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.readrops.api.services.SyncResult; -import com.readrops.api.services.SyncType; -import com.readrops.api.services.nextcloudnews.adapters.NextNewsUserAdapter; -import com.readrops.api.utils.ApiUtils; -import com.readrops.api.utils.exceptions.ConflictException; -import com.readrops.api.utils.exceptions.UnknownFormatException; -import com.readrops.api.utils.extensions.KonsumerExtensionsKt; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.Item; -import com.readrops.db.entities.account.Account; -import com.readrops.db.pojo.StarItem; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import okhttp3.OkHttpClient; -import okhttp3.Request; -import retrofit2.Response; - -public class NextNewsDataSource { - - private static final String TAG = NextNewsDataSource.class.getSimpleName(); - - private static final int MAX_ITEMS = 5000; - private static final int MAX_STARRED_ITEMS = 1000; - - private NextNewsService api; - - public NextNewsDataSource(NextNewsService api) { - this.api = api; - } - - @Nullable - public String login(OkHttpClient client, Account account) throws IOException { - Request request = new Request.Builder() - .url(account.getUrl() + "/ocs/v1.php/cloud/users/" + account.getLogin()) - .addHeader("OCS-APIRequest", "true") - .build(); - - okhttp3.Response response = client.newCall(request).execute(); - - String displayName = new NextNewsUserAdapter().fromXml(KonsumerExtensionsKt - .instantiateKonsumer(response.body().byteStream())); - response.body().close(); - - return displayName; - } - - @Nullable - public List createFeed(String url, int folderId) throws IOException, UnknownFormatException { - Response> response = api.createFeed(url, folderId).execute(); - - if (!response.isSuccessful()) { - if (response.code() == ApiUtils.HTTP_UNPROCESSABLE) - throw new UnknownFormatException(); - else - return null; - } - - return response.body(); - } - - public SyncResult sync(@NonNull SyncType syncType, @Nullable NextNewsSyncData data) throws IOException { - SyncResult syncResult = new SyncResult(); - switch (syncType) { - case INITIAL_SYNC: - initialSync(syncResult); - break; - case CLASSIC_SYNC: - if (data == null) - throw new NullPointerException("NextNewsSyncData can't be null"); - - classicSync(syncResult, data); - break; - } - - return syncResult; - } - - private void initialSync(SyncResult syncResult) throws IOException { - getFeedsAndFolders(syncResult); - - // unread items - Response> itemsResponse = api.getItems(ItemQueryType.ALL.value, false, MAX_ITEMS).execute(); - List itemList = itemsResponse.body(); - - if (!itemsResponse.isSuccessful()) - syncResult.setError(true); - - if (itemList != null) - syncResult.setItems(itemList); - - // starred items - Response> starredItemsResponse = api.getItems(ItemQueryType.STARRED.value, true, MAX_STARRED_ITEMS).execute(); - List starredItems = starredItemsResponse.body(); - - if (!itemsResponse.isSuccessful()) - syncResult.setError(true); - - if (itemList != null) - syncResult.setStarredItems(starredItems); - } - - private void classicSync(SyncResult syncResult, NextNewsSyncData data) throws IOException { - putModifiedItems(data, syncResult); - getFeedsAndFolders(syncResult); - - Response> itemsResponse = api.getNewItems(data.getLastModified(), ItemQueryType.ALL.value).execute(); - List itemList = itemsResponse.body(); - - if (!itemsResponse.isSuccessful()) - syncResult.setError(true); - - if (itemList != null) - syncResult.setItems(itemList); - } - - private void getFeedsAndFolders(SyncResult syncResult) throws IOException { - Response> feedResponse = api.getFeeds().execute(); - List feedList = feedResponse.body(); - - if (!feedResponse.isSuccessful()) - syncResult.setError(true); - - Response> folderResponse = api.getFolders().execute(); - List folderList = folderResponse.body(); - - if (!folderResponse.isSuccessful()) - syncResult.setError(true); - - if (folderList != null) - syncResult.setFolders(folderList); - - if (feedList != null) - syncResult.setFeeds(feedList); - - } - - private void putModifiedItems(NextNewsSyncData data, SyncResult syncResult) throws IOException { - setReadState(data.getReadItems(), syncResult, StateType.READ); - setReadState(data.getUnreadItems(), syncResult, StateType.UNREAD); - - setStarState(data.getStarredItems(), syncResult, StateType.STAR); - setStarState(data.getUnstarredItems(), syncResult, StateType.UNSTAR); - } - - public List createFolder(Folder folder) throws IOException, UnknownFormatException, ConflictException { - Map folderNameMap = new HashMap<>(); - folderNameMap.put("name", folder.getName()); - - Response> foldersResponse = api.createFolder(folderNameMap).execute(); - - if (foldersResponse.isSuccessful()) - return foldersResponse.body(); - else if (foldersResponse.code() == ApiUtils.HTTP_UNPROCESSABLE) - throw new UnknownFormatException(); - else if (foldersResponse.code() == ApiUtils.HTTP_CONFLICT) - throw new ConflictException(); - else - return new ArrayList<>(); - } - - public boolean deleteFolder(Folder folder) throws IOException { - Response response = api.deleteFolder(Integer.parseInt(folder.getRemoteId())).execute(); - - if (response.isSuccessful()) - return true; - else if (response.code() == ApiUtils.HTTP_NOT_FOUND) - throw new Resources.NotFoundException(); - else - return false; - } - - public boolean renameFolder(Folder folder) throws IOException, UnknownFormatException, ConflictException { - Map folderNameMap = new HashMap<>(); - folderNameMap.put("name", folder.getName()); - - Response response = api.renameFolder(Integer.parseInt(folder.getRemoteId()), folderNameMap).execute(); - - if (response.isSuccessful()) - return true; - else { - switch (response.code()) { - case ApiUtils.HTTP_NOT_FOUND: - throw new Resources.NotFoundException(); - case ApiUtils.HTTP_UNPROCESSABLE: - throw new UnknownFormatException(); - case ApiUtils.HTTP_CONFLICT: - throw new ConflictException(); - default: - return false; - } - } - } - - public boolean deleteFeed(int feedId) throws IOException { - Response response = api.deleteFeed(feedId).execute(); - - if (response.isSuccessful()) - return true; - else if (response.code() == ApiUtils.HTTP_NOT_FOUND) - throw new Resources.NotFoundException(); - else - return false; - } - - public boolean changeFeedFolder(Feed feed) throws IOException { - Map folderIdMap = new HashMap<>(); - folderIdMap.put("folderId", Integer.parseInt(feed.getRemoteFolderId())); - - Response response = api.changeFeedFolder(Integer.parseInt(feed.getRemoteId()), folderIdMap).execute(); - - if (response.isSuccessful()) - return true; - else if (response.code() == ApiUtils.HTTP_NOT_FOUND) - throw new Resources.NotFoundException(); - else - return false; - } - - public boolean renameFeed(Feed feed) throws IOException { - Map feedTitleMap = new HashMap<>(); - feedTitleMap.put("feedTitle", feed.getName()); - - Response response = api.renameFeed(Integer.parseInt(feed.getRemoteId()), feedTitleMap).execute(); - - if (response.isSuccessful()) - return true; - else if (response.code() == ApiUtils.HTTP_NOT_FOUND) - throw new Resources.NotFoundException(); - else - return false; - } - - private void setReadState(List items, SyncResult syncResult, StateType stateType) throws IOException { - if (!items.isEmpty()) { - Map> itemIdsMap = new HashMap<>(); - itemIdsMap.put("items", items); - - Response readItemsResponse = api.setReadState(stateType.name().toLowerCase(), - itemIdsMap).execute(); - - if (!readItemsResponse.isSuccessful()) - syncResult.setError(true); - } - } - - private void setStarState(List items, SyncResult syncResult, StateType stateType) throws IOException { - if (!items.isEmpty()) { - List> body = new ArrayList<>(); - for (StarItem item : items) { - Map itemBody = new HashMap<>(); - itemBody.put("feedId", item.getFeedRemoteId()); - itemBody.put("guidHash", item.getGuidHash()); - - body.add(itemBody); - } - - Response response = api.setStarState(stateType.name().toLowerCase(), - Collections.singletonMap("items", body)).execute(); - if (!response.isSuccessful()) { - syncResult.setError(true); - } - } - } - - public enum StateType { - READ, - UNREAD, - STAR, - UNSTAR - } - - public enum ItemQueryType { - ALL(3), - STARRED(2); - - private int value; - - ItemQueryType(int value) { - this.value = value; - } - - public int getValue() { - return value; - } - } -} diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsService.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsService.kt deleted file mode 100644 index d5c29075..00000000 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsService.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.readrops.api.services.nextcloudnews - -import com.readrops.db.entities.Feed -import com.readrops.db.entities.Folder -import com.readrops.db.entities.Item -import okhttp3.ResponseBody -import retrofit2.Call -import retrofit2.http.* - -interface NextNewsService { - - @GET("/ocs/v1.php/cloud/users/{userId}") - @Headers("OCS-APIRequest: true") - fun getUser(@Path("userId") userId: String): Call - - @get:GET("folders") - val folders: Call> - - @get:GET("feeds") - val feeds: Call> - - @GET("items") - fun getItems(@Query("type") type: Int, @Query("getRead") read: Boolean, @Query("batchSize") batchSize: Int): Call> - - @GET("items/updated") - fun getNewItems(@Query("lastModified") lastModified: Long, @Query("type") type: Int): Call> - - @PUT("items/{stateType}/multiple") - fun setReadState(@Path("stateType") stateType: String, @Body itemIdsMap: Map>): Call - - @PUT("items/{starType}/multiple") - fun setStarState(@Path("starType") starType: String?, @Body body: Map>>): Call - - @POST("feeds") - fun createFeed(@Query("url") url: String, @Query("folderId") folderId: Int): Call> - - @DELETE("feeds/{feedId}") - fun deleteFeed(@Path("feedId") feedId: Int): Call? - - @PUT("feeds/{feedId}/move") - fun changeFeedFolder(@Path("feedId") feedId: Int, @Body folderIdMap: Map): Call - - @PUT("feeds/{feedId}/rename") - fun renameFeed(@Path("feedId") feedId: Int, @Body feedTitleMap: Map): Call - - @POST("folders") - fun createFolder(@Body folderName: Map): Call> - - @DELETE("folders/{folderId}") - fun deleteFolder(@Path("folderId") folderId: Int): Call - - @PUT("folders/{folderId}") - fun renameFolder(@Path("folderId") folderId: Int, @Body folderName: Map): Call - - companion object { - const val END_POINT = "/index.php/apps/news/api/v1-2/" - } -} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsSyncData.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsSyncData.kt deleted file mode 100644 index 974d0154..00000000 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsSyncData.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.readrops.api.services.nextcloudnews - -import com.readrops.db.pojo.StarItem - -data class NextNewsSyncData( - var lastModified: Long = 0, - var unreadItems: List = listOf(), - var readItems: List = listOf(), - var starredItems: List = listOf(), - var unstarredItems: List = listOf(), -) \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsCredentials.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsCredentials.kt new file mode 100644 index 00000000..dd401d88 --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsCredentials.kt @@ -0,0 +1,8 @@ +package com.readrops.api.services.nextcloudnews + +import com.readrops.api.services.Credentials + +class NextcloudNewsCredentials(login: String?, password: String?, url: String): + Credentials(if (login != null && password != null) { + okhttp3.Credentials.basic(login, password) + } else null, url) \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSource.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSource.kt new file mode 100644 index 00000000..58d5f3ed --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSource.kt @@ -0,0 +1,153 @@ +package com.readrops.api.services.nextcloudnews + +import com.gitlab.mvysny.konsumexml.konsumeXml +import com.readrops.api.services.DataSourceResult +import com.readrops.api.services.SyncType +import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsUserAdapter +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.Item +import com.readrops.db.entities.account.Account +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import okhttp3.OkHttpClient +import okhttp3.Request + +class NextcloudNewsDataSource(private val service: NextcloudNewsService) { + + suspend fun login(client: OkHttpClient, account: Account): String { + val request = Request.Builder() + .url(account.url + "/ocs/v1.php/cloud/users/" + account.login) + .addHeader("OCS-APIRequest", "true") + .build() + + val response = client.newCall(request) + .execute() + + val displayName = NextcloudNewsUserAdapter().fromXml(response.body!!.byteStream().konsumeXml()) + response.close() + + return displayName + } + + suspend fun synchronize(syncType: SyncType, syncData: NextcloudNewsSyncData): DataSourceResult = + with(CoroutineScope(Dispatchers.IO)) { + return if (syncType == SyncType.INITIAL_SYNC) { + DataSourceResult().apply { + listOf( + async { folders = getFolders() }, + async { feeds = getFeeds() }, + async { items = getItems(ItemQueryType.ALL.value, false, MAX_ITEMS) }, + async { + starredItems = + getItems(ItemQueryType.STARRED.value, true, MAX_STARRED_ITEMS) + } + ).awaitAll() + } + } else { + listOf( + async { setItemsReadState(syncData) }, + async { setItemsStarState(syncData) }, + ).awaitAll() + + DataSourceResult().apply { + listOf( + async { folders = getFolders() }, + async { feeds = getFeeds() }, + async { items = getNewItems(syncData.lastModified, ItemQueryType.ALL) } + ).awaitAll() + } + } + } + + suspend fun getFolders() = service.getFolders() + + suspend fun getFeeds() = service.getFeeds() + + suspend fun getItems(type: Int, read: Boolean, batchSize: Int): List { + return service.getItems(type, read, batchSize) + } + + suspend fun getNewItems(lastModified: Long, itemQueryType: ItemQueryType): List { + return service.getNewItems(lastModified, itemQueryType.value) + } + + suspend fun createFeed(url: String, folderId: Int?): List { + return service.createFeed(url, folderId) + } + + suspend fun changeFeedFolder(newFolderId: Int?, feedId: Int) { + service.changeFeedFolder(feedId, mapOf("folderId" to newFolderId)) + } + + suspend fun renameFeed(name: String, feedId: Int) { + service.renameFeed(feedId, mapOf("feedTitle" to name)) + } + + suspend fun deleteFeed(feedId: Int) { + service.deleteFeed(feedId) + } + + suspend fun createFolder(name: String): List { + return service.createFolder(mapOf("name" to name)) + } + + suspend fun renameFolder(name: String, folderId: Int) { + service.renameFolder(folderId, mapOf("name" to name)) + } + + suspend fun deleteFolder(folderId: Int) { + service.deleteFolder(folderId) + } + + suspend fun setItemsReadState(syncData: NextcloudNewsSyncData) = with(syncData) { + if (unreadIds.isNotEmpty()) { + service.setReadState( + StateType.UNREAD.name.lowercase(), + mapOf("itemIds" to unreadIds) + ) + } + + if (readIds.isNotEmpty()) { + service.setReadState( + StateType.READ.name.lowercase(), + mapOf("itemIds" to readIds) + ) + } + } + + suspend fun setItemsStarState(syncData: NextcloudNewsSyncData) = with(syncData) { + if (starredIds.isNotEmpty()) { + service.setStarState( + StateType.STAR.name.lowercase(), + mapOf("itemIds" to starredIds) + ) + } + + if (unstarredIds.isNotEmpty()) { + service.setStarState( + StateType.UNSTAR.name.lowercase(), + mapOf("itemIds" to unstarredIds) + ) + } + } + + enum class ItemQueryType(val value: Int) { + ALL(3), + STARRED(2) + } + + enum class StateType { + READ, + UNREAD, + STAR, + UNSTAR + } + + companion object { + private const val MAX_ITEMS = 5000 + private const val MAX_STARRED_ITEMS = 1000 + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsService.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsService.kt new file mode 100644 index 00000000..1400d9a7 --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsService.kt @@ -0,0 +1,73 @@ +package com.readrops.api.services.nextcloudnews + +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.Item +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +interface NextcloudNewsService { + + @GET("folders") + suspend fun getFolders(): List + + @GET("feeds") + suspend fun getFeeds(): List + + @GET("items") + suspend fun getItems( + @Query("type") type: Int, + @Query("getRead") read: Boolean, + @Query("batchSize") batchSize: Int + ): List + + @GET("items/updated") + suspend fun getNewItems( + @Query("lastModified") lastModified: Long, + @Query("type") type: Int + ): List + + @POST("items/{stateType}/multiple") + @JvmSuppressWildcards + suspend fun setReadState( + @Path("stateType") stateType: String, + @Body itemIdsMap: Map> + ) + + @POST("items/{starType}/multiple") + @JvmSuppressWildcards + suspend fun setStarState( + @Path("starType") starType: String?, + @Body body: Map> + ) + + @POST("feeds") + suspend fun createFeed(@Query("url") url: String, @Query("folderId") folderId: Int?): List + + @DELETE("feeds/{feedId}") + suspend fun deleteFeed(@Path("feedId") feedId: Int) + + @POST("feeds/{feedId}/move") + suspend fun changeFeedFolder(@Path("feedId") feedId: Int, @Body folderIdMap: Map) + + @POST("feeds/{feedId}/rename") + suspend fun renameFeed(@Path("feedId") feedId: Int, @Body feedTitleMap: Map) + + @POST("folders") + suspend fun createFolder(@Body folderName: Map): List + + @DELETE("folders/{folderId}") + suspend fun deleteFolder(@Path("folderId") folderId: Int) + + @PUT("folders/{folderId}") + suspend fun renameFolder(@Path("folderId") folderId: Int, @Body folderName: Map) + + companion object { + const val END_POINT = "/index.php/apps/news/api/v1-3/" + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsSyncData.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsSyncData.kt new file mode 100644 index 00000000..98a5c269 --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsSyncData.kt @@ -0,0 +1,9 @@ +package com.readrops.api.services.nextcloudnews + +data class NextcloudNewsSyncData( + val lastModified: Long = 0, + val readIds: List = listOf(), + val unreadIds: List = listOf(), + val starredIds: List = listOf(), + val unstarredIds: List = listOf(), +) \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsFeedsAdapter.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFeedsAdapter.kt similarity index 98% rename from api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsFeedsAdapter.kt rename to api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFeedsAdapter.kt index 9a523585..1c4cf090 100644 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsFeedsAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFeedsAdapter.kt @@ -11,7 +11,7 @@ import com.squareup.moshi.JsonReader import com.squareup.moshi.ToJson import java.net.URI -class NextNewsFeedsAdapter { +class NextcloudNewsFeedsAdapter { @ToJson fun toJson(feeds: List): String = "" diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsFoldersAdapter.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFoldersAdapter.kt similarity index 97% rename from api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsFoldersAdapter.kt rename to api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFoldersAdapter.kt index a8849472..eb890c14 100644 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsFoldersAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFoldersAdapter.kt @@ -8,7 +8,7 @@ import com.squareup.moshi.FromJson import com.squareup.moshi.JsonReader import com.squareup.moshi.ToJson -class NextNewsFoldersAdapter { +class NextcloudNewsFoldersAdapter { @ToJson fun toJson(folders: List): String = "" diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsItemsAdapter.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapter.kt similarity index 88% rename from api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsItemsAdapter.kt rename to api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapter.kt index 8543cd86..48046cad 100644 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsItemsAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapter.kt @@ -1,18 +1,17 @@ package com.readrops.api.services.nextcloudnews.adapters import android.annotation.SuppressLint -import com.readrops.db.entities.Item import com.readrops.api.utils.ApiUtils import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nextNonEmptyString import com.readrops.api.utils.extensions.nextNullableString +import com.readrops.db.entities.Item +import com.readrops.db.util.DateUtils import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonReader import com.squareup.moshi.JsonWriter -import org.joda.time.DateTimeZone -import org.joda.time.LocalDateTime -class NextNewsItemsAdapter : JsonAdapter>() { +class NextcloudNewsItemsAdapter : JsonAdapter>() { override fun toJson(writer: JsonWriter, value: List?) { // no need of this @@ -42,14 +41,13 @@ class NextNewsItemsAdapter : JsonAdapter>() { 1 -> link = reader.nextNullableString() 2 -> title = reader.nextNonEmptyString() 3 -> author = reader.nextNullableString() - 4 -> pubDate = LocalDateTime(reader.nextLong() * 1000L, DateTimeZone.getDefault()) + 4 -> pubDate = DateUtils.fromEpochSeconds(reader.nextLong()) 5 -> content = reader.nextNullableString() 6 -> enclosureMime = reader.nextNullableString() 7 -> enclosureLink = reader.nextNullableString() 8 -> feedRemoteId = reader.nextInt().toString() 9 -> isRead = !reader.nextBoolean() // the negation is important here 10 -> isStarred = reader.nextBoolean() - 11 -> guid = reader.nextNullableString() else -> reader.skipValue() } } @@ -73,6 +71,6 @@ class NextNewsItemsAdapter : JsonAdapter>() { companion object { val NAMES: JsonReader.Options = JsonReader.Options.of("id", "url", "title", "author", - "pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred", "guidHash") + "pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred") } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsUserAdapter.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsUserAdapter.kt similarity index 93% rename from api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsUserAdapter.kt rename to api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsUserAdapter.kt index ac35039d..940c18c4 100644 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsUserAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsUserAdapter.kt @@ -6,7 +6,7 @@ import com.readrops.api.localfeed.XmlAdapter import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nonNullText -class NextNewsUserAdapter : XmlAdapter { +class NextcloudNewsUserAdapter : XmlAdapter { override fun fromXml(konsumer: Konsumer): String { var displayName: String? = null diff --git a/api/src/main/java/com/readrops/api/utils/ApiUtils.kt b/api/src/main/java/com/readrops/api/utils/ApiUtils.kt index bfd270be..a09dfb3d 100644 --- a/api/src/main/java/com/readrops/api/utils/ApiUtils.kt +++ b/api/src/main/java/com/readrops/api/utils/ApiUtils.kt @@ -18,6 +18,8 @@ object ApiUtils { const val HTTP_NOT_FOUND = 404 const val HTTP_CONFLICT = 409 + val OPML_MIMETYPES = listOf("application/xml", "text/xml", "text/x-opml") + private const val RSS_CONTENT_TYPE_REGEX = "([^;]+)" fun isMimeImage(type: String): Boolean = diff --git a/api/src/main/java/com/readrops/api/utils/DateUtils.kt b/api/src/main/java/com/readrops/api/utils/DateUtils.kt deleted file mode 100644 index b201b4af..00000000 --- a/api/src/main/java/com/readrops/api/utils/DateUtils.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.readrops.api.utils - -import org.joda.time.LocalDateTime -import org.joda.time.format.DateTimeFormat -import org.joda.time.format.DateTimeFormatterBuilder -import java.util.* - -object DateUtils { - - private val TAG = DateUtils::class.java.simpleName - - /** - * Base of common RSS 2 date formats. - * Examples : - * Fri, 04 Jan 2019 22:21:46 GMT - * Fri, 04 Jan 2019 22:21:46 +0000 - */ - private const val RSS_2_BASE_PATTERN = "EEE, dd MMM yyyy HH:mm:ss" - - private const val GMT_PATTERN = "ZZZ" - - private const val OFFSET_PATTERN = "Z" - - private const val ISO_PATTERN = ".SSSZZ" - - private const val EDT_PATTERN = "zzz" - - /** - * Date pattern for format : 2019-01-04T22:21:46+00:00 - */ - private const val ATOM_JSON_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" - - @JvmStatic - fun parse(value: String?): LocalDateTime? = if (value == null) { - null - } else try { - val formatter = DateTimeFormatterBuilder() - .appendOptional(DateTimeFormat.forPattern("$RSS_2_BASE_PATTERN ").parser) // with timezone - .appendOptional(DateTimeFormat.forPattern(RSS_2_BASE_PATTERN).parser) // no timezone, important order here - .appendOptional(DateTimeFormat.forPattern(ATOM_JSON_DATE_FORMAT).parser) - .appendOptional(DateTimeFormat.forPattern(GMT_PATTERN).parser) - .appendOptional(DateTimeFormat.forPattern(OFFSET_PATTERN).parser) - .appendOptional(DateTimeFormat.forPattern(ISO_PATTERN).parser) - .appendOptional(DateTimeFormat.forPattern(EDT_PATTERN).parser) - .toFormatter() - .withLocale(Locale.ENGLISH) - .withOffsetParsed() - - formatter.parseLocalDateTime(value) - } catch (e: Exception) { - null - } - - @JvmStatic - fun formattedDateByLocal(dateTime: LocalDateTime): String { - return DateTimeFormat.mediumDate() - .withLocale(Locale.getDefault()) - .print(dateTime) - } - - @JvmStatic - fun formattedDateTimeByLocal(dateTime: LocalDateTime): String { - return DateTimeFormat.forPattern("dd MMM yyyy · HH:mm") - .withLocale(Locale.getDefault()) - .print(dateTime) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/utils/ErrorInterceptor.kt b/api/src/main/java/com/readrops/api/utils/ErrorInterceptor.kt new file mode 100644 index 00000000..f462e4a7 --- /dev/null +++ b/api/src/main/java/com/readrops/api/utils/ErrorInterceptor.kt @@ -0,0 +1,20 @@ +package com.readrops.api.utils + +import com.readrops.api.utils.exceptions.HttpException +import okhttp3.Interceptor +import okhttp3.Response + +class ErrorInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + + // TODO cover all cases + if (!response.isSuccessful && response.code != 304) { + throw HttpException(response) + } + + return response + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/utils/HtmlParser.kt b/api/src/main/java/com/readrops/api/utils/HtmlParser.kt new file mode 100644 index 00000000..02005881 --- /dev/null +++ b/api/src/main/java/com/readrops/api/utils/HtmlParser.kt @@ -0,0 +1,88 @@ +package com.readrops.api.utils + +import android.nfc.FormatException +import com.readrops.api.localfeed.LocalRSSHelper +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jsoup.Jsoup +import org.jsoup.nodes.Document + +data class ParsingResult( + val url: String, + val label: String?, +) + +object HtmlParser { + + suspend fun getFaviconLink(url: String, client: OkHttpClient): String? { + val document = getHTMLHeadFromUrl(url, client) + val elements = document.select("link") + + for (element in elements) { + if (element.attributes()["rel"].lowercase().contains("icon")) { + return element.absUrl("href") + } + } + + return null + } + + suspend fun getFeedLink(url: String, client: OkHttpClient): List { + val results = mutableListOf() + + val document = getHTMLHeadFromUrl(url, client) + val elements = document.select("link") + + for (element in elements) { + val type = element.attributes()["type"] + + if (LocalRSSHelper.isRSSType(type)) { + results += ParsingResult( + url = element.absUrl("href"), + label = element.attributes()["title"] + ) + } + } + + return results + } + + private fun getHTMLHeadFromUrl(url: String, client: OkHttpClient): Document { + client.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (response.header(ApiUtils.CONTENT_TYPE_HEADER)!!.contains(ApiUtils.HTML_CONTENT_TYPE) + ) { + val body = response.body!!.source() + + val stringBuilder = StringBuilder() + var collectionStarted = false + + while (!body.exhausted()) { + val currentLine = body.readUtf8LineStrict() + + when { + currentLine.contains("") -> { + stringBuilder.append(currentLine) + collectionStarted = true + } + currentLine.contains("") -> { + stringBuilder.append(currentLine) + break + } + collectionStarted -> { + stringBuilder.append(currentLine) + } + } + } + + if (!stringBuilder.contains("") || !stringBuilder.contains("")) + throw FormatException("Failed to get HTML head") + + body.close() + return Jsoup.parse(stringBuilder.toString(), url) + } else { + throw FormatException("The response is not a html file") + } + } + } + +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/utils/exceptions/HttpException.kt b/api/src/main/java/com/readrops/api/utils/exceptions/HttpException.kt new file mode 100644 index 00000000..a9d81f30 --- /dev/null +++ b/api/src/main/java/com/readrops/api/utils/exceptions/HttpException.kt @@ -0,0 +1,14 @@ +package com.readrops.api.utils.exceptions + +import okhttp3.Response +import java.io.IOException + + +class HttpException(val response: Response) : IOException() { + + val code: Int + get() = response.code + + override val message: String + get() = "HTTP " + response.code + " " + response.message +} \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/MockServerExtensions.kt b/api/src/test/java/com/readrops/api/MockServerExtensions.kt new file mode 100644 index 00000000..057c0978 --- /dev/null +++ b/api/src/test/java/com/readrops/api/MockServerExtensions.kt @@ -0,0 +1,19 @@ +package com.readrops.api + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import java.io.InputStream +import java.net.HttpURLConnection + +fun MockWebServer.enqueueOK() { + enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + ) +} + +fun MockWebServer.enqueueStream(stream: InputStream) { + enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(stream))) +} \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/localfeed/LocalRSSDataSourceTest.kt b/api/src/test/java/com/readrops/api/localfeed/LocalRSSDataSourceTest.kt index 3c33b932..48ed7a99 100644 --- a/api/src/test/java/com/readrops/api/localfeed/LocalRSSDataSourceTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/LocalRSSDataSourceTest.kt @@ -5,6 +5,7 @@ import com.readrops.api.TestUtils import com.readrops.api.apiModule import com.readrops.api.utils.ApiUtils import com.readrops.api.utils.AuthInterceptor +import com.readrops.api.utils.exceptions.HttpException import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.exceptions.UnknownFormatException import junit.framework.TestCase.* @@ -149,7 +150,7 @@ class LocalRSSDataSourceTest : KoinTest { assertNull(pair) } - @Test(expected = NetworkErrorException::class) + @Test(expected = HttpException::class) fun response404Test() { mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND)) diff --git a/api/src/test/java/com/readrops/api/localfeed/atom/ATOMAdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/atom/ATOMAdapterTest.kt index 4e345fcc..18ee69c9 100644 --- a/api/src/test/java/com/readrops/api/localfeed/atom/ATOMAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/atom/ATOMAdapterTest.kt @@ -2,14 +2,13 @@ package com.readrops.api.localfeed.atom import com.gitlab.mvysny.konsumexml.konsumeXml import com.readrops.api.TestUtils -import com.readrops.api.utils.DateUtils import com.readrops.api.utils.exceptions.ParseException +import com.readrops.db.util.DateUtils import junit.framework.TestCase import junit.framework.TestCase.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test -import java.lang.Exception class ATOMAdapterTest { @@ -37,7 +36,7 @@ class ATOMAdapterTest { assertEquals(pubDate, DateUtils.parse("2020-09-06T21:09:59Z")) assertEquals(author, "Shinokuni") assertEquals(description, "Summary") - assertEquals(guid, "tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac") + assertEquals(remoteId, "tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac") TestCase.assertNotNull(content) } } diff --git a/api/src/test/java/com/readrops/api/localfeed/json/JSONFeedAdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/json/JSONFeedAdapterTest.kt index ea00b757..9280fa83 100644 --- a/api/src/test/java/com/readrops/api/localfeed/json/JSONFeedAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/json/JSONFeedAdapterTest.kt @@ -1,15 +1,14 @@ package com.readrops.api.localfeed.json import com.readrops.api.TestUtils -import com.readrops.api.utils.DateUtils import com.readrops.api.utils.exceptions.ParseException import com.readrops.db.entities.Feed import com.readrops.db.entities.Item +import com.readrops.db.util.DateUtils import com.squareup.moshi.Moshi import com.squareup.moshi.Types import junit.framework.TestCase import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertTrue import okio.Buffer import org.junit.Assert.assertThrows import org.junit.Test @@ -40,7 +39,7 @@ class JSONFeedAdapterTest { with(items[0]) { assertEquals(items.size, 10) - assertEquals(guid, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html") + assertEquals(remoteId, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html") assertEquals(title, "Acorn and 10.13") assertEquals(link, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html") assertEquals(pubDate, DateUtils.parse("2017-09-25T14:27:27-07:00")) diff --git a/api/src/test/java/com/readrops/api/localfeed/rss1/RSS1AdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/rss1/RSS1AdapterTest.kt index ae266fa9..cd1d0f72 100644 --- a/api/src/test/java/com/readrops/api/localfeed/rss1/RSS1AdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/rss1/RSS1AdapterTest.kt @@ -2,8 +2,8 @@ package com.readrops.api.localfeed.rss1 import com.gitlab.mvysny.konsumexml.konsumeXml import com.readrops.api.TestUtils -import com.readrops.api.utils.DateUtils import com.readrops.api.utils.exceptions.ParseException +import com.readrops.db.util.DateUtils import junit.framework.Assert.assertEquals import junit.framework.Assert.assertNotNull import junit.framework.TestCase @@ -35,7 +35,7 @@ class RSS1AdapterTest { assertEquals(title, "Google Expands its Flutter Development Kit To Windows Apps") assertEquals(link!!.trim(), "https://developers.slashdot.org/story/20/09/23/1616231/google-expands-" + "its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&utm_medium=feed") - assertEquals(guid!!.trim(), "https://developers.slashdot.org/story/20/09/23/1616231/google-expands-" + + assertEquals(remoteId!!.trim(), "https://developers.slashdot.org/story/20/09/23/1616231/google-expands-" + "its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&utm_medium=feed") assertEquals(pubDate, DateUtils.parse("2020-09-23T16:15:00+00:00")) assertEquals(author, "msmash") diff --git a/api/src/test/java/com/readrops/api/localfeed/rss2/RSS2AdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/rss2/RSS2AdapterTest.kt index 5d03a336..7e71d15e 100644 --- a/api/src/test/java/com/readrops/api/localfeed/rss2/RSS2AdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/rss2/RSS2AdapterTest.kt @@ -2,8 +2,8 @@ package com.readrops.api.localfeed.rss2 import com.gitlab.mvysny.konsumexml.konsumeXml import com.readrops.api.TestUtils -import com.readrops.api.utils.DateUtils import com.readrops.api.utils.exceptions.ParseException +import com.readrops.db.util.DateUtils import junit.framework.TestCase import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue @@ -36,7 +36,7 @@ class RSS2AdapterTest { assertEquals(pubDate, DateUtils.parse("Tue, 25 Aug 2020 17:15:49 +0000")) assertEquals(author, "Author 1") assertEquals(description, "Comments") - assertEquals(guid, "https://www.bbc.com/news/world-africa-53887947") + assertEquals(remoteId, "https://www.bbc.com/news/world-africa-53887947") } } @@ -55,7 +55,7 @@ class RSS2AdapterTest { val stream = TestUtils.loadResource("localfeed/rss2/rss_items_other_namespaces.xml") val item = adapter.fromXml(stream.konsumeXml()).second[0] - assertEquals(item.guid, "guid") + assertEquals(item.remoteId, "guid") assertEquals(item.author, "creator 1, creator 2, creator 3, creator 4") assertEquals(item.pubDate, DateUtils.parse("2020-08-05T14:03:48Z")) assertEquals(item.content, "content:encoded") diff --git a/api/src/test/java/com/readrops/api/opml/OPMLParserTest.kt b/api/src/test/java/com/readrops/api/opml/OPMLParserTest.kt index 32d3fd56..e0fca210 100644 --- a/api/src/test/java/com/readrops/api/opml/OPMLParserTest.kt +++ b/api/src/test/java/com/readrops/api/opml/OPMLParserTest.kt @@ -4,8 +4,8 @@ import com.readrops.api.TestUtils import com.readrops.api.utils.exceptions.ParseException import com.readrops.db.entities.Feed import com.readrops.db.entities.Folder -import io.reactivex.schedulers.Schedulers import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest import org.junit.Test import java.io.File import java.io.FileOutputStream @@ -13,83 +13,85 @@ import java.io.FileOutputStream class OPMLParserTest { @Test - fun readOpmlTest() { + fun readOpmlTest() = runTest { val stream = TestUtils.loadResource("opml/subscriptions.opml") + val foldersAndFeeds = OPMLParser.read(stream) - var foldersAndFeeds: Map>? = null + assertEquals(foldersAndFeeds.size, 6) - OPMLParser.read(stream) - .observeOn(Schedulers.trampoline()) - .subscribeOn(Schedulers.trampoline()) - .subscribe { result -> foldersAndFeeds = result } - - assertEquals(foldersAndFeeds?.size, 6) - - assertEquals(foldersAndFeeds?.get(Folder(name = "Folder 1"))?.size, 2) - assertEquals(foldersAndFeeds?.get(Folder(name = "Subfolder 1"))?.size, 4) - assertEquals(foldersAndFeeds?.get(Folder(name = "Subfolder 2"))?.size, 1) - assertEquals(foldersAndFeeds?.get(Folder(name = "Sub subfolder 1"))?.size, 2) - assertEquals(foldersAndFeeds?.get(Folder(name = "Sub subfolder 2"))?.size, 0) - assertEquals(foldersAndFeeds?.get(null)?.size, 2) + assertEquals(foldersAndFeeds[Folder(name = "Folder 1")]?.size, 2) + assertEquals(foldersAndFeeds[Folder(name = "Subfolder 1")]?.size, 4) + assertEquals(foldersAndFeeds[Folder(name = "Subfolder 2")]?.size, 1) + assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 1")]?.size, 2) + assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 2")]?.size, 0) + assertEquals(foldersAndFeeds[null]?.size, 2) stream.close() } @Test - fun readLiteSubscriptionsTest() { + fun readLiteSubscriptionsTest() = runTest { val stream = TestUtils.loadResource("opml/lite_subscriptions.opml") - var foldersAndFeeds: Map>? = null + val foldersAndFeeds = OPMLParser.read(stream) - OPMLParser.read(stream) - .subscribe { result -> foldersAndFeeds = result } - - assertEquals(foldersAndFeeds?.values?.first()?.size, 2) - assertEquals(foldersAndFeeds?.values?.first()?.first()?.url, "http://www.theverge.com/rss/index.xml") - assertEquals(foldersAndFeeds?.values?.first()?.get(1)?.url, "https://techcrunch.com/feed/") + assertEquals(foldersAndFeeds.values.first().size, 2) + assertEquals( + foldersAndFeeds.values.first().first().url, + "http://www.theverge.com/rss/index.xml" + ) + assertEquals(foldersAndFeeds.values.first()[1].url, "https://techcrunch.com/feed/") stream.close() } - @Test - fun opmlVersionTest() { + @Test(expected = ParseException::class) + fun opmlVersionTest() = runTest { val stream = TestUtils.loadResource("opml/wrong_version.opml") OPMLParser.read(stream) - .test() - .assertError(ParseException::class.java) - stream.close() } @Test - fun writeOpmlTest() { + fun writeOpmlTest() = runTest { val file = File("subscriptions.opml") val outputStream = FileOutputStream(file) val foldersAndFeeds: Map> = HashMap>().apply { - put(null, listOf(Feed(name = "Feed1", url = "https://feed1.com"), - Feed(name = "Feed2", url = "https://feed2.com"))) + put( + null, listOf( + Feed(name = "Feed1", url = "https://feed1.com"), + Feed(name = "Feed2", url = "https://feed2.com") + ) + ) put(Folder(name = "Folder1"), listOf()) - put(Folder(name = "Folder2"), listOf(Feed(name = "Feed3", url = "https://feed3.com"), - Feed(name = "Feed4", url ="https://feed4.com"))) + put( + Folder(name = "Folder2"), listOf( + Feed(name = "Feed3", url = "https://feed3.com"), + Feed(name = "Feed4", url = "https://feed4.com") + ) + ) } OPMLParser.write(foldersAndFeeds, outputStream) - .subscribeOn(Schedulers.trampoline()) - .subscribe() outputStream.flush() outputStream.close() val inputStream = file.inputStream() - var foldersAndFeeds2: Map>? = null - OPMLParser.read(inputStream).subscribe { result -> foldersAndFeeds2 = result } + val foldersAndFeeds2 = OPMLParser.read(inputStream) - assertEquals(foldersAndFeeds.size, foldersAndFeeds2?.size) - assertEquals(foldersAndFeeds[Folder(name = "Folder1")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder1"))?.size) - assertEquals(foldersAndFeeds[Folder(name = "Folder2")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder2"))?.size) - assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2?.get(null)?.size) + assertEquals(foldersAndFeeds.size, foldersAndFeeds2.size) + assertEquals( + foldersAndFeeds[Folder(name = "Folder1")]?.size, + foldersAndFeeds2[Folder(name = "Folder1")]?.size + ) + assertEquals( + foldersAndFeeds[Folder(name = "Folder2")]?.size, + foldersAndFeeds2[Folder(name = "Folder2")]?.size + ) + assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2[null]?.size) inputStream.close() } diff --git a/api/src/test/java/com/readrops/api/services/CredentialsTest.kt b/api/src/test/java/com/readrops/api/services/CredentialsTest.kt index 24b23454..0cab3213 100644 --- a/api/src/test/java/com/readrops/api/services/CredentialsTest.kt +++ b/api/src/test/java/com/readrops/api/services/CredentialsTest.kt @@ -1,7 +1,7 @@ package com.readrops.api.services import com.readrops.api.services.freshrss.FreshRSSCredentials -import com.readrops.api.services.nextcloudnews.NextNewsCredentials +import com.readrops.api.services.nextcloudnews.NextcloudNewsCredentials import org.junit.Test import kotlin.test.assertEquals @@ -17,7 +17,7 @@ class CredentialsTest { @Test fun nextcloudNewsCredentialsTest() { - val credentials = NextNewsCredentials("login", "password", "https://freshrss.org") + val credentials = NextcloudNewsCredentials("login", "password", "https://freshrss.org") assertEquals(credentials.authorization!!, "Basic bG9naW46cGFzc3dvcmQ=") assertEquals(credentials.url, "https://freshrss.org") diff --git a/api/src/test/java/com/readrops/api/services/freshrss/FreshRSSDataSourceTest.kt b/api/src/test/java/com/readrops/api/services/freshrss/FreshRSSDataSourceTest.kt new file mode 100644 index 00000000..3fac6d89 --- /dev/null +++ b/api/src/test/java/com/readrops/api/services/freshrss/FreshRSSDataSourceTest.kt @@ -0,0 +1,260 @@ +package com.readrops.api.services.freshrss + +import com.readrops.api.TestUtils +import com.readrops.api.apiModule +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule +import org.koin.test.get +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.net.HttpURLConnection +import java.net.URLEncoder +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FreshRSSDataSourceTest : KoinTest { + + private lateinit var freshRSSDataSource: FreshRSSDataSource + private val mockServer = MockWebServer() + + @get:Rule + val koinTestRule = KoinTestRule.create { + modules(apiModule, module { + single { + Retrofit.Builder() + .baseUrl("http://localhost:8080/") + .client(get()) + .addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi")))) + .build() + .create(FreshRSSService::class.java) + } + }) + } + + @Before + fun before() { + mockServer.start(8080) + freshRSSDataSource = FreshRSSDataSource(get()) + } + + @After + fun tearDown() { + mockServer.shutdown() + } + + @Test + fun loginTest() { + runBlocking { + val responseBody = TestUtils.loadResource("services/freshrss/login_response_body") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(responseBody))) + + val authString = freshRSSDataSource.login("Login", "Password") + assertEquals("login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a", authString) + + val request = mockServer.takeRequest() + val requestBody = request.body.readUtf8() + + assertTrue { + requestBody.contains("name=\"Email\"") && requestBody.contains("Login") + } + + assertTrue { + requestBody.contains("name=\"Passwd\"") && requestBody.contains("Password") + } + } + } + + @Test + fun writeTokenTest() = runBlocking { + val responseBody = TestUtils.loadResource("services/freshrss/writetoken_response_body") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(responseBody))) + + val writeToken = freshRSSDataSource.getWriteToken() + + assertEquals("PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg", writeToken) + } + + @Test + fun userInfoTest() = runBlocking { + + } + + @Test + fun foldersTest() = runBlocking { + val stream = TestUtils.loadResource("services/freshrss/adapters/folders.json") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(stream))) + + val folders = freshRSSDataSource.getFolders() + assertTrue { folders.size == 1 } + } + + @Test + fun feedsTest() = runBlocking { + val stream = TestUtils.loadResource("services/freshrss/adapters/feeds.json") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(stream))) + + val feeds = freshRSSDataSource.getFeeds() + assertTrue { feeds.size == 1 } + } + + @Test + fun itemsTest() = runBlocking { + val stream = TestUtils.loadResource("services/freshrss/adapters/items.json") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(stream))) + + val items = freshRSSDataSource.getItems(listOf(FreshRSSDataSource.GOOGLE_READ, FreshRSSDataSource.GOOGLE_STARRED), 100, 21343321321321) + assertTrue { items.size == 2 } + + val request = mockServer.takeRequest() + + with(request.requestUrl!!) { + assertEquals(listOf(FreshRSSDataSource.GOOGLE_READ, FreshRSSDataSource.GOOGLE_STARRED), queryParameterValues("xt")) + assertEquals("100", queryParameter("n")) + assertEquals("21343321321321", queryParameter("ot")) + + } + } + + @Test + fun starredItemsTest() = runBlocking { + val stream = TestUtils.loadResource("services/freshrss/adapters/items.json") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(stream))) + + val items = freshRSSDataSource.getStarredItems(100) + assertTrue { items.size == 2 } + + val request = mockServer.takeRequest() + + assertEquals("100", request.requestUrl!!.queryParameter("n")) + } + + @Test + fun getItemsIdsTest() = runBlocking { + val stream = TestUtils.loadResource("services/freshrss/adapters/items_starred_ids.json") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(stream))) + + val ids = freshRSSDataSource.getItemsIds(FreshRSSDataSource.GOOGLE_READ, FreshRSSDataSource.GOOGLE_READING_LIST, 100) + assertTrue { ids.size == 5 } + + val request = mockServer.takeRequest() + with(request.requestUrl!!) { + assertEquals(FreshRSSDataSource.GOOGLE_READ, queryParameter("xt")) + assertEquals(FreshRSSDataSource.GOOGLE_READING_LIST, queryParameter("s")) + assertEquals("100", queryParameter("n")) + } + } + + @Test + fun createFeedTest() = runBlocking { + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + + freshRSSDataSource.createFeed("token", "https://feed.url") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("s=${URLEncoder.encode("${FreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") } + assertTrue { contains("ac=subscribe") } + } + } + + @Test + fun deleteFeedTest() = runBlocking { + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + + freshRSSDataSource.deleteFeed("token", "https://feed.url") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("s=${URLEncoder.encode("${FreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") } + assertTrue { contains("ac=unsubscribe") } + } + } + + @Test + fun updateFeedTest() = runBlocking { + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + + freshRSSDataSource.updateFeed("token", "https://feed.url", "title", "folderId") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("s=${URLEncoder.encode("${FreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") } + assertTrue { contains("t=title") } + assertTrue { contains("a=folderId") } + assertTrue { contains("ac=edit") } + } + } + + @Test + fun createFolderTest() = runBlocking { + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + + freshRSSDataSource.createFolder("token", "folder") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("a=${URLEncoder.encode("${FreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") } + } + } + + @Test + fun updateFolderTest() = runBlocking { + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + + freshRSSDataSource.updateFolder("token", "folderId", "folder") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("s=folderId") } + assertTrue { contains("dest=${URLEncoder.encode("${FreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") } + } + } + + @Test + fun deleteFolderTest() = runBlocking { + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + + freshRSSDataSource.deleteFolder("token", "folderId") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("s=folderId") } + } + } +} \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapterTest.kt b/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapterTest.kt index de5ee76d..5d914282 100644 --- a/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapterTest.kt @@ -2,12 +2,12 @@ package com.readrops.api.services.freshrss.adapters import com.readrops.api.TestUtils import com.readrops.db.entities.Item +import com.readrops.db.util.DateUtils import com.squareup.moshi.Moshi import com.squareup.moshi.Types import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import okio.Buffer -import org.joda.time.LocalDateTime import org.junit.Test class FreshRSSItemsAdapterTest { @@ -29,7 +29,7 @@ class FreshRSSItemsAdapterTest { assertNotNull(content) assertEquals(link, "http://feedproxy.google.com/~r/d0od/~3/4Zk-fncSuek/adwaita-borderless-theme-in-development-gnome-41") assertEquals(author, "Joey Sneddon") - assertEquals(pubDate, LocalDateTime(1625234040 * 1000L)) + assertEquals(pubDate, DateUtils.fromEpochSeconds(1625234040)) assertEquals(isRead, false) assertEquals(isStarred, false) } diff --git a/api/src/test/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSourceTest.kt b/api/src/test/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSourceTest.kt new file mode 100644 index 00000000..2fe6109e --- /dev/null +++ b/api/src/test/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSourceTest.kt @@ -0,0 +1,284 @@ +package com.readrops.api.services.nextcloudnews + +import com.readrops.api.TestUtils +import com.readrops.api.apiModule +import com.readrops.api.enqueueOK +import com.readrops.api.enqueueStream +import com.readrops.db.entities.account.Account +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule +import org.koin.test.get +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NextcloudNewsDataSourceTest : KoinTest { + + private lateinit var nextcloudNewsDataSource: NextcloudNewsDataSource + private val mockServer = MockWebServer() + private val moshi = Moshi.Builder() + .build() + + @get:Rule + val koinTestRule = KoinTestRule.create { + modules(apiModule, module { + single { + Retrofit.Builder() + .baseUrl("http://localhost:8080/") + .client(get()) + .addConverterFactory(MoshiConverterFactory.create(get(named("nextcloudNewsMoshi")))) + .build() + .create(NextcloudNewsService::class.java) + } + }) + } + + @Before + fun before() { + mockServer.start(8080) + nextcloudNewsDataSource = NextcloudNewsDataSource(get()) + } + + @After + fun tearDown() { + mockServer.shutdown() + } + + @Test + fun loginTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/user.xml") + val account = Account(login = "login", url = mockServer.url("").toString()) + + mockServer.enqueueStream(stream) + + val displayName = nextcloudNewsDataSource.login(get(), account) + val request = mockServer.takeRequest() + + assertTrue { displayName == "Shinokuni" } + assertTrue { request.headers.contains("OCS-APIRequest" to "true") } + assertTrue { request.path == "//ocs/v1.php/cloud/users/login" } + } + + @Test + fun foldersTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json") + mockServer.enqueueStream(stream) + + val folders = nextcloudNewsDataSource.getFolders() + assertTrue { folders.size == 1 } + } + + @Test + fun feedsTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json") + mockServer.enqueueStream(stream) + + val feeds = nextcloudNewsDataSource.getFeeds() + assertTrue { feeds.size == 3 } + } + + @Test + fun itemsTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json") + mockServer.enqueueStream(stream) + + val items = nextcloudNewsDataSource.getItems(NextcloudNewsDataSource.ItemQueryType.ALL.value, false, 10) + val request = mockServer.takeRequest() + + assertTrue { items.size == 3 } + with(request.requestUrl!!) { + assertEquals("3", queryParameter("type")) + assertEquals("false", queryParameter("getRead")) + assertEquals("10", queryParameter("batchSize")) + } + } + + @Test + fun newItemsTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json") + mockServer.enqueueStream(stream) + + val items = + nextcloudNewsDataSource.getNewItems(1512, NextcloudNewsDataSource.ItemQueryType.ALL) + val request = mockServer.takeRequest() + + assertTrue { items.size == 3 } + with(request.requestUrl!!) { + assertEquals("1512", queryParameter("lastModified")) + assertEquals("3", queryParameter("type")) + } + } + + @Test + fun createFeedTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json") + mockServer.enqueueStream(stream) + + val feeds = nextcloudNewsDataSource.createFeed("https://news.ycombinator.com/rss", null) + val request = mockServer.takeRequest() + + assertTrue { feeds.isNotEmpty() } + with(request.requestUrl!!) { + assertEquals("https://news.ycombinator.com/rss", queryParameter("url")) + assertEquals(null, queryParameter("folderId")) + } + } + + @Test + fun deleteFeedTest() = runTest { + mockServer.enqueueOK() + + nextcloudNewsDataSource.deleteFeed(15) + val request = mockServer.takeRequest() + + assertTrue { request.path!!.endsWith("/15") } + } + + @Test + fun changeFeedFolderTest() = runTest { + mockServer.enqueueOK() + + nextcloudNewsDataSource.changeFeedFolder(15, 18) + val request = mockServer.takeRequest() + + val type = + Types.newParameterizedType( + Map::class.java, + String::class.java, + Int::class.javaObjectType + ) + val adapter = moshi.adapter>(type) + val body = adapter.fromJson(request.body)!! + + assertTrue { request.path!!.endsWith("/18/move") } + assertEquals(15, body["folderId"]) + } + + @Test + fun renameFeedTest() = runTest { + mockServer.enqueueOK() + + nextcloudNewsDataSource.renameFeed("name", 15) + val request = mockServer.takeRequest() + + val type = + Types.newParameterizedType(Map::class.java, String::class.java, String::class.java) + val adapter = moshi.adapter>(type) + val body = adapter.fromJson(request.body)!! + + assertTrue { request.path!!.endsWith("/15/rename") } + assertEquals("name", body["feedTitle"]) + } + + @Test + fun createFolderTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json") + mockServer.enqueueStream(stream) + + val folders = nextcloudNewsDataSource.createFolder("folder name") + val request = mockServer.takeRequest() + + val type = + Types.newParameterizedType(Map::class.java, String::class.java, String::class.java) + val adapter = moshi.adapter>(type) + val body = adapter.fromJson(request.body)!! + + assertTrue { folders.size == 1 } + assertEquals("folder name", body["name"]) + } + + @Test + fun renameFolderTest() = runTest { + mockServer.enqueueOK() + + nextcloudNewsDataSource.renameFolder("new name", 15) + val request = mockServer.takeRequest() + + val type = + Types.newParameterizedType(Map::class.java, String::class.java, String::class.java) + val adapter = moshi.adapter>(type) + val body = adapter.fromJson(request.body)!! + + assertTrue { request.path!!.endsWith("/15") } + assertEquals("new name", body["name"]) + } + + @Test + fun deleteFolderTest() = runTest { + mockServer.enqueueOK() + + nextcloudNewsDataSource.deleteFolder(15) + val request = mockServer.takeRequest() + + assertEquals(request.method, "DELETE") + assertTrue { request.path!!.endsWith("/15") } + } + + @Test + fun setItemsReadStateTest() = runTest { + mockServer.enqueueOK() + mockServer.enqueueOK() + + val data = NextcloudNewsSyncData( + readIds = listOf(15, 16, 17), + unreadIds = listOf(18, 19, 20) + ) + + nextcloudNewsDataSource.setItemsReadState(data) + val unreadRequest = mockServer.takeRequest() + val readRequest = mockServer.takeRequest() + + val type = + Types.newParameterizedType( + Map::class.java, + String::class.java, + Types.newParameterizedType(List::class.java, Int::class.javaObjectType) + ) + val adapter = moshi.adapter>>(type) + val unreadBody = adapter.fromJson(unreadRequest.body)!! + val readBody = adapter.fromJson(readRequest.body)!! + + assertEquals(data.readIds, readBody["itemIds"]) + assertEquals(data.unreadIds, unreadBody["itemIds"]) + } + + @Test + fun setItemsStarStateTest() = runTest { + mockServer.enqueueOK() + mockServer.enqueueOK() + + val data = NextcloudNewsSyncData( + starredIds = listOf(15, 16, 17), + unstarredIds = listOf(18, 19, 20) + ) + + nextcloudNewsDataSource.setItemsStarState(data) + val starRequest = mockServer.takeRequest() + val unstarRequest = mockServer.takeRequest() + + val type = + Types.newParameterizedType( + Map::class.java, + String::class.java, + Types.newParameterizedType(List::class.java, Int::class.javaObjectType) + ) + val adapter = moshi.adapter>>(type) + + val starBody = adapter.fromJson(starRequest.body)!! + val unstarBody = adapter.fromJson(unstarRequest.body)!! + + assertEquals(data.starredIds, starBody["itemIds"]) + assertEquals(data.unstarredIds, unstarBody["itemIds"]) + } +} \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsFeedsAdapterTest.kt b/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFeedsAdapterTest.kt similarity index 93% rename from api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsFeedsAdapterTest.kt rename to api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFeedsAdapterTest.kt index 96fc9389..c7fc1300 100644 --- a/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsFeedsAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFeedsAdapterTest.kt @@ -9,10 +9,10 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test -class NextNewsFeedsAdapterTest { +class NextcloudNewsFeedsAdapterTest { private val adapter = Moshi.Builder() - .add(NextNewsFeedsAdapter()) + .add(NextcloudNewsFeedsAdapter()) .build() .adapter>(Types.newParameterizedType(List::class.java, Feed::class.java)) diff --git a/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsFoldersAdapterTest.kt b/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFoldersAdapterTest.kt similarity index 92% rename from api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsFoldersAdapterTest.kt rename to api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFoldersAdapterTest.kt index be58847d..8a69df69 100644 --- a/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsFoldersAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFoldersAdapterTest.kt @@ -10,10 +10,10 @@ import okio.Buffer import org.junit.Assert.assertThrows import org.junit.Test -class NextNewsFoldersAdapterTest { +class NextcloudNewsFoldersAdapterTest { private val adapter = Moshi.Builder() - .add(NextNewsFoldersAdapter()) + .add(NextcloudNewsFoldersAdapter()) .build() .adapter>(Types.newParameterizedType(List::class.java, Folder::class.java)) diff --git a/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsItemsAdapterTest.kt b/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapterTest.kt similarity index 85% rename from api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsItemsAdapterTest.kt rename to api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapterTest.kt index 2e91b44e..716acae8 100644 --- a/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsItemsAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapterTest.kt @@ -2,17 +2,17 @@ package com.readrops.api.services.nextcloudnews.adapters import com.readrops.api.TestUtils import com.readrops.db.entities.Item +import com.readrops.db.util.DateUtils import com.squareup.moshi.Moshi import com.squareup.moshi.Types import junit.framework.TestCase.assertEquals import okio.Buffer -import org.joda.time.LocalDateTime import org.junit.Test -class NextNewsItemsAdapterTest { +class NextcloudNewsItemsAdapterTest { private val adapter = Moshi.Builder() - .add(Types.newParameterizedType(List::class.java, Item::class.java), NextNewsItemsAdapter()) + .add(Types.newParameterizedType(List::class.java, Item::class.java), NextcloudNewsItemsAdapter()) .build() .adapter>(Types.newParameterizedType(List::class.java, Item::class.java)) @@ -25,7 +25,6 @@ class NextNewsItemsAdapterTest { with(item) { assertEquals(remoteId, "3443") - assertEquals(guid, "3059047a572cd9cd5d0bf645faffd077") assertEquals(link, "http://grulja.wordpress.com/2013/04/29/plasma-nm-after-the-solid-sprint/") assertEquals(title, "Plasma-nm after the solid sprint") assertEquals(author, "Jan Grulich (grulja)") @@ -33,7 +32,7 @@ class NextNewsItemsAdapterTest { assertEquals(feedRemoteId, "67") assertEquals(isRead, false) assertEquals(isStarred, false) - assertEquals(pubDate, LocalDateTime(1367270544000)) + assertEquals(pubDate, DateUtils.fromEpochSeconds(1367270544)) assertEquals(imageLink, null) } diff --git a/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsUserAdapterTest.kt b/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsUserAdapterTest.kt similarity index 81% rename from api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsUserAdapterTest.kt rename to api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsUserAdapterTest.kt index 03c3d7c3..2495f684 100644 --- a/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextNewsUserAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsUserAdapterTest.kt @@ -5,9 +5,9 @@ import com.readrops.api.TestUtils import org.junit.Assert.assertEquals import org.junit.Test -class NextNewsUserAdapterTest { +class NextcloudNewsUserAdapterTest { - private val adapter = NextNewsUserAdapter() + private val adapter = NextcloudNewsUserAdapter() @Test fun validXmlTest() { diff --git a/api/src/test/java/com/readrops/api/utils/DateUtilsTest.java b/api/src/test/java/com/readrops/api/utils/DateUtilsTest.java deleted file mode 100644 index af3d98c7..00000000 --- a/api/src/test/java/com/readrops/api/utils/DateUtilsTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.readrops.api.utils; - -import org.joda.time.LocalDateTime; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - -public class DateUtilsTest { - - @Test - public void rssDateTest() { - LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46); - - assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 04 Jan 2019 22:21:46 GMT"))); - } - - @Test - public void rssDate2Test() { - LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46); - - assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 04 Jan 2019 22:21:46 +0000"))); - } - - @Test - public void rssDate3Test() { - LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46); - - assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 04 Jan 2019 22:21:46"))); - } - - @Test - public void atomJsonDateTest() { - LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46); - - assertEquals(0, dateTime.compareTo(DateUtils.parse("2019-01-04T22:21:46+00:00"))); - } - - @Test - public void atomJsonDate2Test() { - LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46); - - assertEquals(0, dateTime.compareTo(DateUtils.parse("2019-01-04T22:21:46-0000"))); - } - - @Test - public void isoPatternTest() { - LocalDateTime dateTime = new LocalDateTime(2020, 6, 30, 11, 39, 37, 206); - - assertEquals(0, dateTime.compareTo(DateUtils.parse("2020-06-30T11:39:37.206-07:00"))); - } - - @Test - public void edtPatternTest() { - LocalDateTime dateTime = new LocalDateTime(2020, 7, 17, 16, 30, 0); - - assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 17 Jul 2020 16:30:00 EDT"))); - } -} \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/utils/ErrorInterceptorTest.kt b/api/src/test/java/com/readrops/api/utils/ErrorInterceptorTest.kt new file mode 100644 index 00000000..22f243c5 --- /dev/null +++ b/api/src/test/java/com/readrops/api/utils/ErrorInterceptorTest.kt @@ -0,0 +1,39 @@ +package com.readrops.api.utils + +import com.readrops.api.utils.exceptions.HttpException +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.net.HttpURLConnection + +class ErrorInterceptorTest { + + private val interceptor = ErrorInterceptor() + private val server = MockWebServer() + private lateinit var client: OkHttpClient + + @Before + fun before() { + client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + server.start(8080) + } + + @After + fun tearDown() { + server.close() + } + + @Test(expected = HttpException::class) + fun interceptorTest() { + server.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND)) + + client.newCall(Request.Builder().url(server.url("/url")).build()).execute() + //val request = server.takeRequest() + } +} \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/utils/HtmlParserTest.kt b/api/src/test/java/com/readrops/api/utils/HtmlParserTest.kt new file mode 100644 index 00000000..0bf410c8 --- /dev/null +++ b/api/src/test/java/com/readrops/api/utils/HtmlParserTest.kt @@ -0,0 +1,124 @@ +package com.readrops.api.utils + +import android.nfc.FormatException +import com.readrops.api.TestUtils +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.Rule +import org.junit.Test +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule +import java.net.HttpURLConnection +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HtmlParserTest : KoinTest { + + private val mockServer = MockWebServer() + + @get:Rule + val koinTestRule = KoinTestRule.create { + modules(module { + single { + OkHttpClient.Builder() + .callTimeout(1, TimeUnit.MINUTES) + .readTimeout(1, TimeUnit.HOURS) + .build() + } + }) + } + + @Test + fun before() { + mockServer.start() + } + + @Test + fun after() { + mockServer.shutdown() + } + + @Test + fun getFeedLinkTest() { + val stream = TestUtils.loadResource("utils/file.html") + + mockServer.enqueue( + MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE) + .setBody(Buffer().readFrom(stream)) + ) + + runBlocking { + val result = + HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) + + assertTrue { result.size == 1 } + assertTrue { result.first().url.endsWith("/rss") } + assertEquals("RSS", result.first().label) + + } + } + + @Test(expected = FormatException::class) + fun getFeedLinkWithoutHeadTest() { + val stream = TestUtils.loadResource("utils/file_without_head.html") + + mockServer.enqueue( + MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE) + .setBody(Buffer().readFrom(stream)) + ) + + runBlocking { HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) } + } + + @Test(expected = FormatException::class) + fun getFeedLinkNoHtmlFileTest() { + mockServer.enqueue( + MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/rss+xml")) + + + runBlocking { HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) } + } + + @Test + fun getFaviconLinkTest() { + val stream = TestUtils.loadResource("utils/file.html") + + mockServer.enqueue( + MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE) + .setBody(Buffer().readFrom(stream)) + ) + + runBlocking { + val result = HtmlParser.getFaviconLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) + + assertTrue { result!!.contains("favicon.ico") } + } + } + + @Test + fun getFaviconLinkWithoutHeadTest() { + val stream = TestUtils.loadResource("utils/file_without_icon.html") + + mockServer.enqueue( + MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE) + .setBody(Buffer().readFrom(stream)) + ) + + runBlocking { + val result = HtmlParser.getFaviconLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) + + assertNull(result) + } + } +} \ No newline at end of file diff --git a/api/src/test/resources/services/freshrss/adapters/items.json b/api/src/test/resources/services/freshrss/adapters/items.json index 1a5fef73..66043935 100644 --- a/api/src/test/resources/services/freshrss/adapters/items.json +++ b/api/src/test/resources/services/freshrss/adapters/items.json @@ -18,7 +18,10 @@ ], "categories": [ "user/-/state/com.google/reading-list", - "user/-/label/Libre" + "user/-/label/Libre", + "category1", + "category2", + "category3" ], "origin": { "streamId": "feed/15", @@ -44,7 +47,10 @@ "user/-/state/com.google/reading-list", "user/-/label/Libre", "user/-/state/com.google/starred", - "user/-/state/com.google/read" + "user/-/state/com.google/read", + "category1", + "category2", + "category3" ], "origin": { "streamId": "feed/15", diff --git a/api/src/test/resources/services/freshrss/login_response_body b/api/src/test/resources/services/freshrss/login_response_body new file mode 100644 index 00000000..e6bf504c --- /dev/null +++ b/api/src/test/resources/services/freshrss/login_response_body @@ -0,0 +1,3 @@ +SID=login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a +LSID=null +Auth=login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a diff --git a/api/src/test/resources/services/freshrss/writetoken_response_body b/api/src/test/resources/services/freshrss/writetoken_response_body new file mode 100644 index 00000000..42765922 --- /dev/null +++ b/api/src/test/resources/services/freshrss/writetoken_response_body @@ -0,0 +1 @@ +PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg \ No newline at end of file diff --git a/api/src/test/resources/utils/file.html b/api/src/test/resources/utils/file.html new file mode 100644 index 00000000..d55ef435 --- /dev/null +++ b/api/src/test/resources/utils/file.html @@ -0,0 +1,601 @@ + + + + + + + + Hacker News + + +
+ + + + + + + + + + + +
+ + + + + + +
Hacker News + new | past | comments | ask | show | jobs | submit + + login + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1.A Brief History of Computers (lesswrong.com)
+ 31 points by zdw 1 hour ago | hide | 3 comments +
2.Consumer Software Is Expected to Be Next Fast-Growing Segment (1994) (csmonitor.com)
+ 9 points by 1970-01-01 1 hour ago | hide | 1 comment +
3.MSX-DOS (wikipedia.org)
+ 82 points by pavlov 6 hours ago | hide | 26 comments +
4.New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft (nytimes.com)
+ 12 points by asnyder 20 minutes ago | hide | 1 comment +
5.Apple's interactive television box: Hacking the set top box System 7.1 in ROM (oldvcr.blogspot.com)
+ 160 points by todsacerdoti 10 hours ago | hide | 20 comments +
6.Putting the “You” in CPU (cpu.land)
+ 187 points by uneekname 10 hours ago | hide | 73 comments +
7.Botulinum toxin: Bioweapon and magic drug (nih.gov)
+ 12 points by redbell 2 hours ago | hide | 10 comments +
8.Octos – HTML live wallpaper engine (github.com/underpig1)
+ 85 points by underpig1 6 hours ago | hide | 23 comments +
9.More than you've ever wanted to know about errors in Rust (shuttle.rs)
+ 13 points by asymmetric 2 hours ago | hide | 3 comments +
10.Embrace Complexity; Tighten Your Feedback Loops (ferd.ca)
+ 27 points by lutzh 4 hours ago | hide | 1 comment +
11.AWS networking concepts in a diagram (miparnisariblog.wordpress.com)
+ 171 points by mparnisari 10 hours ago | hide | 66 comments +
12.Plane – Open-source Jira alternative (plane.so)
+ 240 points by prhrb 7 hours ago | hide | 93 comments +
13.Neurotechnology: Current Developments and Ethical Issues (frontiersin.org)
+ 28 points by Quinzel 3 hours ago | hide | 15 comments +
14.What we talk about when we talk about System Design (maheshba.bitbucket.io)
+ 166 points by scv119 11 hours ago | hide | 22 comments +
15.ElKaWe – Electrocaloric heat pumps (fraunhofer.de)
+ 140 points by danans 10 hours ago | hide | 73 comments +
16.Over-grazing and desertification in the Syrian steppe root causes of war (2015) (theecologist.org)
+ 64 points by joveian 6 hours ago | hide | 43 comments +
17.Redmine – open-source project management (redmine.org)
+ 34 points by synergy20 2 hours ago | hide | 24 comments +
18.Google tries internet air-gap for some staff PCs (theregister.com)
+ 67 points by beardyw 9 hours ago | hide | 73 comments +
19.I thought I wanted to be a professor, then I served on a hiring committee (2021) (science.org)
+ 104 points by ykonstant 4 hours ago | hide | 72 comments +
20.Internet search tips (gwern.net)
+ 161 points by herbertl 12 hours ago | hide | 58 comments +
21.Bayesian methods to provide probablistic solution for the Drake equation (2019) (sciencedirect.com)
+ 22 points by benbreen 4 hours ago | hide | 18 comments +
22.Biotumen: Bitumen Reinvented (biofabrik.com)
+ 40 points by patall 7 hours ago | hide | 11 comments +
23.Why even let users set their own passwords? (devever.net)
+ 103 points by hlandau 2 hours ago | hide | 121 comments +
24.Confronting failure as a core life skill (buildinghealthier.substack.com)
+ 168 points by blh75 15 hours ago | hide | 75 comments +
25.Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836) (publicdomainreview.org)
+ 19 points by tintinnabula 2 hours ago | hide | discuss +
26.Bun v0.7.0 (bun.sh)
+ 163 points by sshroot 9 hours ago | hide | 107 comments +
27.Simpson Fan Grows Tomacco (2003) (simpsonsarchive.com)
+ 81 points by pipeline_peak 6 hours ago | hide | 55 comments +
28.Discovery: Metals can heal themselves (sandia.gov)
+ 77 points by bobvanluijt 13 hours ago | hide | 24 comments +
29.Pressure and vacuum marination does not work (2016) (genuineideas.com)
+ 87 points by OJFord 13 hours ago | hide | 57 comments +
30.Scientists: Fishing boats compete with whales and penguins for Antarctic krill (mongabay.com)
+ 5 points by PaulHoule 1 hour ago | hide | discuss +
+
+ + + + + +
+
+
+ Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

+
Search:
+
+
+
+ + + + diff --git a/api/src/test/resources/utils/file_without_head.html b/api/src/test/resources/utils/file_without_head.html new file mode 100644 index 00000000..3606ac07 --- /dev/null +++ b/api/src/test/resources/utils/file_without_head.html @@ -0,0 +1,593 @@ + + +
+ + + + + + + + + + + +
+ + + + + + +
Hacker News + new | past | comments | ask | show | jobs | submit + + login + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1.A Brief History of Computers (lesswrong.com)
+ 31 points by zdw 1 hour ago | hide | 3 comments +
2.Consumer Software Is Expected to Be Next Fast-Growing Segment (1994) (csmonitor.com)
+ 9 points by 1970-01-01 1 hour ago | hide | 1 comment +
3.MSX-DOS (wikipedia.org)
+ 82 points by pavlov 6 hours ago | hide | 26 comments +
4.New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft (nytimes.com)
+ 12 points by asnyder 20 minutes ago | hide | 1 comment +
5.Apple's interactive television box: Hacking the set top box System 7.1 in ROM (oldvcr.blogspot.com)
+ 160 points by todsacerdoti 10 hours ago | hide | 20 comments +
6.Putting the “You” in CPU (cpu.land)
+ 187 points by uneekname 10 hours ago | hide | 73 comments +
7.Botulinum toxin: Bioweapon and magic drug (nih.gov)
+ 12 points by redbell 2 hours ago | hide | 10 comments +
8.Octos – HTML live wallpaper engine (github.com/underpig1)
+ 85 points by underpig1 6 hours ago | hide | 23 comments +
9.More than you've ever wanted to know about errors in Rust (shuttle.rs)
+ 13 points by asymmetric 2 hours ago | hide | 3 comments +
10.Embrace Complexity; Tighten Your Feedback Loops (ferd.ca)
+ 27 points by lutzh 4 hours ago | hide | 1 comment +
11.AWS networking concepts in a diagram (miparnisariblog.wordpress.com)
+ 171 points by mparnisari 10 hours ago | hide | 66 comments +
12.Plane – Open-source Jira alternative (plane.so)
+ 240 points by prhrb 7 hours ago | hide | 93 comments +
13.Neurotechnology: Current Developments and Ethical Issues (frontiersin.org)
+ 28 points by Quinzel 3 hours ago | hide | 15 comments +
14.What we talk about when we talk about System Design (maheshba.bitbucket.io)
+ 166 points by scv119 11 hours ago | hide | 22 comments +
15.ElKaWe – Electrocaloric heat pumps (fraunhofer.de)
+ 140 points by danans 10 hours ago | hide | 73 comments +
16.Over-grazing and desertification in the Syrian steppe root causes of war (2015) (theecologist.org)
+ 64 points by joveian 6 hours ago | hide | 43 comments +
17.Redmine – open-source project management (redmine.org)
+ 34 points by synergy20 2 hours ago | hide | 24 comments +
18.Google tries internet air-gap for some staff PCs (theregister.com)
+ 67 points by beardyw 9 hours ago | hide | 73 comments +
19.I thought I wanted to be a professor, then I served on a hiring committee (2021) (science.org)
+ 104 points by ykonstant 4 hours ago | hide | 72 comments +
20.Internet search tips (gwern.net)
+ 161 points by herbertl 12 hours ago | hide | 58 comments +
21.Bayesian methods to provide probablistic solution for the Drake equation (2019) (sciencedirect.com)
+ 22 points by benbreen 4 hours ago | hide | 18 comments +
22.Biotumen: Bitumen Reinvented (biofabrik.com)
+ 40 points by patall 7 hours ago | hide | 11 comments +
23.Why even let users set their own passwords? (devever.net)
+ 103 points by hlandau 2 hours ago | hide | 121 comments +
24.Confronting failure as a core life skill (buildinghealthier.substack.com)
+ 168 points by blh75 15 hours ago | hide | 75 comments +
25.Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836) (publicdomainreview.org)
+ 19 points by tintinnabula 2 hours ago | hide | discuss +
26.Bun v0.7.0 (bun.sh)
+ 163 points by sshroot 9 hours ago | hide | 107 comments +
27.Simpson Fan Grows Tomacco (2003) (simpsonsarchive.com)
+ 81 points by pipeline_peak 6 hours ago | hide | 55 comments +
28.Discovery: Metals can heal themselves (sandia.gov)
+ 77 points by bobvanluijt 13 hours ago | hide | 24 comments +
29.Pressure and vacuum marination does not work (2016) (genuineideas.com)
+ 87 points by OJFord 13 hours ago | hide | 57 comments +
30.Scientists: Fishing boats compete with whales and penguins for Antarctic krill (mongabay.com)
+ 5 points by PaulHoule 1 hour ago | hide | discuss +
+
+ + + + + +
+
+
+ Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

+
Search:
+
+
+
+ + + + diff --git a/api/src/test/resources/utils/file_without_icon.html b/api/src/test/resources/utils/file_without_icon.html new file mode 100644 index 00000000..1c2df253 --- /dev/null +++ b/api/src/test/resources/utils/file_without_icon.html @@ -0,0 +1,600 @@ + + + + + + + Hacker News + + +
+ + + + + + + + + + + +
+ + + + + + +
Hacker News + new | past | comments | ask | show | jobs | submit + + login + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1.A Brief History of Computers (lesswrong.com)
+ 31 points by zdw 1 hour ago | hide | 3 comments +
2.Consumer Software Is Expected to Be Next Fast-Growing Segment (1994) (csmonitor.com)
+ 9 points by 1970-01-01 1 hour ago | hide | 1 comment +
3.MSX-DOS (wikipedia.org)
+ 82 points by pavlov 6 hours ago | hide | 26 comments +
4.New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft (nytimes.com)
+ 12 points by asnyder 20 minutes ago | hide | 1 comment +
5.Apple's interactive television box: Hacking the set top box System 7.1 in ROM (oldvcr.blogspot.com)
+ 160 points by todsacerdoti 10 hours ago | hide | 20 comments +
6.Putting the “You” in CPU (cpu.land)
+ 187 points by uneekname 10 hours ago | hide | 73 comments +
7.Botulinum toxin: Bioweapon and magic drug (nih.gov)
+ 12 points by redbell 2 hours ago | hide | 10 comments +
8.Octos – HTML live wallpaper engine (github.com/underpig1)
+ 85 points by underpig1 6 hours ago | hide | 23 comments +
9.More than you've ever wanted to know about errors in Rust (shuttle.rs)
+ 13 points by asymmetric 2 hours ago | hide | 3 comments +
10.Embrace Complexity; Tighten Your Feedback Loops (ferd.ca)
+ 27 points by lutzh 4 hours ago | hide | 1 comment +
11.AWS networking concepts in a diagram (miparnisariblog.wordpress.com)
+ 171 points by mparnisari 10 hours ago | hide | 66 comments +
12.Plane – Open-source Jira alternative (plane.so)
+ 240 points by prhrb 7 hours ago | hide | 93 comments +
13.Neurotechnology: Current Developments and Ethical Issues (frontiersin.org)
+ 28 points by Quinzel 3 hours ago | hide | 15 comments +
14.What we talk about when we talk about System Design (maheshba.bitbucket.io)
+ 166 points by scv119 11 hours ago | hide | 22 comments +
15.ElKaWe – Electrocaloric heat pumps (fraunhofer.de)
+ 140 points by danans 10 hours ago | hide | 73 comments +
16.Over-grazing and desertification in the Syrian steppe root causes of war (2015) (theecologist.org)
+ 64 points by joveian 6 hours ago | hide | 43 comments +
17.Redmine – open-source project management (redmine.org)
+ 34 points by synergy20 2 hours ago | hide | 24 comments +
18.Google tries internet air-gap for some staff PCs (theregister.com)
+ 67 points by beardyw 9 hours ago | hide | 73 comments +
19.I thought I wanted to be a professor, then I served on a hiring committee (2021) (science.org)
+ 104 points by ykonstant 4 hours ago | hide | 72 comments +
20.Internet search tips (gwern.net)
+ 161 points by herbertl 12 hours ago | hide | 58 comments +
21.Bayesian methods to provide probablistic solution for the Drake equation (2019) (sciencedirect.com)
+ 22 points by benbreen 4 hours ago | hide | 18 comments +
22.Biotumen: Bitumen Reinvented (biofabrik.com)
+ 40 points by patall 7 hours ago | hide | 11 comments +
23.Why even let users set their own passwords? (devever.net)
+ 103 points by hlandau 2 hours ago | hide | 121 comments +
24.Confronting failure as a core life skill (buildinghealthier.substack.com)
+ 168 points by blh75 15 hours ago | hide | 75 comments +
25.Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836) (publicdomainreview.org)
+ 19 points by tintinnabula 2 hours ago | hide | discuss +
26.Bun v0.7.0 (bun.sh)
+ 163 points by sshroot 9 hours ago | hide | 107 comments +
27.Simpson Fan Grows Tomacco (2003) (simpsonsarchive.com)
+ 81 points by pipeline_peak 6 hours ago | hide | 55 comments +
28.Discovery: Metals can heal themselves (sandia.gov)
+ 77 points by bobvanluijt 13 hours ago | hide | 24 comments +
29.Pressure and vacuum marination does not work (2016) (genuineideas.com)
+ 87 points by OJFord 13 hours ago | hide | 57 comments +
30.Scientists: Fishing boats compete with whales and penguins for Antarctic krill (mongabay.com)
+ 5 points by PaulHoule 1 hour ago | hide | discuss +
+
+ + + + + +
+
+
+ Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

+
Search:
+
+
+
+ + + + diff --git a/app/.gitignore b/app/.gitignore index 796b96d1..42afabfd 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1 @@ -/build +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 48914c02..00000000 --- a/app/build.gradle +++ /dev/null @@ -1,106 +0,0 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' - -android { - compileSdkVersion rootProject.ext.compileSdkVersion - - defaultConfig { - applicationId "com.readrops.app" - minSdkVersion rootProject.ext.minSdkVersion - targetSdkVersion rootProject.ext.targetSdkVersion - buildToolsVersion rootProject.ext.buildToolsVersion - - versionCode 14 - versionName "1.3.1" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - testOptions { - unitTests.returnDefaultValues = true - } - lintOptions { - abortOnError false - } - buildTypes { - release { - minifyEnabled true - shrinkResources true - - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - - debug { - minifyEnabled false - shrinkResources false - - testCoverageEnabled true - applicationIdSuffix ".debug" - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } - compileOptions { - coreLibraryDesugaringEnabled true - - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' - } - - buildFeatures { - viewBinding true - buildConfig true - } -} - -dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation project(':api') - implementation project(':db') - - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' - - testImplementation 'junit:junit:4.13' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test:runner:1.4.0' - androidTestImplementation 'androidx.test:rules:1.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - - implementation 'com.google.android.material:material:1.4.0' - implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.palette:palette-ktx:1.0.0' - implementation 'androidx.recyclerview:recyclerview:1.2.1' - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.preference:preference:1.1.1' - implementation "androidx.work:work-runtime-ktx:2.5.0" - implementation "androidx.fragment:fragment-ktx:1.3.5" - implementation "androidx.browser:browser:1.3.0" - - testImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version" - testImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version" - - implementation 'com.github.bumptech.glide:glide:4.12.0' - kapt 'com.github.bumptech.glide:compiler:4.12.0' - implementation 'com.github.bumptech.glide:okhttp3-integration:4.12.0' - implementation('com.github.bumptech.glide:recyclerview-integration:4.12.0') { - transitive = false - } - - implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - kapt 'androidx.lifecycle:lifecycle-common-java8:2.3.1' - - implementation 'com.afollestad.material-dialogs:core:0.9.6.0' - - implementation 'com.mikepenz:fastadapter:3.2.9' - implementation 'com.mikepenz:fastadapter-commons:3.3.0' - implementation 'com.mikepenz:materialdrawer:6.1.2' - implementation "com.mikepenz:aboutlibraries:6.2.3" - implementation "com.mikepenz:iconics-views:3.2.5" - implementation "com.mikepenz:iconics-core:3.2.5" - - debugImplementation 'com.facebook.flipper:flipper:0.96.1' - debugImplementation 'com.facebook.soloader:soloader:0.10.1' - debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.96.1' -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 00000000..9945d525 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,90 @@ +plugins { + id("com.android.application") + kotlin("android") + alias(libs.plugins.compose.compiler) + id("com.mikepenz.aboutlibraries.plugin") +} + + +android { + namespace = "com.readrops.app" + + defaultConfig { + applicationId = "com.readrops.app" + + versionCode = 15 + versionName = "2.0-beta01" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + + debug { + isMinifyEnabled = false + isShrinkResources = false + + applicationIdSuffix = ".debug" + enableUnitTestCoverage = true + enableAndroidTestCoverage = true + + proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + } + } + + buildFeatures { + buildConfig = true + compose = true + } + + lint { + abortOnError = false + } +} + +dependencies { + implementation(project(":api")) + implementation(project(":db")) + + coreLibraryDesugaring(libs.jdk.desugar) + + implementation(libs.corektx) + implementation(libs.appcompat) + implementation(libs.material) + implementation(libs.palette) + implementation(libs.workmanager) + implementation(libs.encrypted.preferences) + implementation(libs.datastore) + implementation(libs.browser) + + implementation(libs.jsoup) + + testImplementation(libs.junit4) + androidTestImplementation(libs.bundles.test) + + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose) + + implementation(libs.bundles.voyager) + implementation(libs.bundles.lifecycle) + implementation(libs.bundles.coil) + + implementation(libs.bundles.coroutines) + androidTestImplementation(libs.coroutines.test) + + implementation(libs.bundles.room) + implementation(libs.bundles.paging) + + implementation(platform(libs.koin.bom)) + implementation(libs.bundles.koin) + //androidTestImplementation(libs.bundles.kointest) + // I don't know why but those dependencies are unreachable when accessed directly from version catalog + androidTestImplementation("io.insert-koin:koin-test:${libs.versions.koin.bom.get()}") + androidTestImplementation("io.insert-koin:koin-test-junit4:${libs.versions.koin.bom.get()}") + + androidTestImplementation(libs.okhttp.mockserver) + + implementation(libs.aboutlibraries.composem3) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 77345f4c..a3d03f19 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 @@ -31,4 +31,21 @@ -keep class com.readrops.api.localfeed.** { *; } --keep class com.readrops.api.opml.model.** { *; } \ No newline at end of file +-keep class com.readrops.api.opml.model.** { *; } + +# Please add these rules to your existing keep rules in order to suppress warnings. +# This is generated automatically by the Android Gradle plugin. +-dontwarn javax.xml.stream.Location +-dontwarn javax.xml.stream.XMLInputFactory +-dontwarn javax.xml.stream.XMLStreamReader +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.joda.convert.FromString +-dontwarn org.joda.convert.ToString +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE \ No newline at end of file diff --git a/app/src/androidTest/java/com/readrops/app/FeedColorsTest.kt b/app/src/androidTest/java/com/readrops/app/FeedColorsTest.kt new file mode 100644 index 00000000..fdfb41bd --- /dev/null +++ b/app/src/androidTest/java/com/readrops/app/FeedColorsTest.kt @@ -0,0 +1,58 @@ +package com.readrops.app + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.readrops.api.apiModule +import com.readrops.api.utils.ApiUtils +import com.readrops.app.util.FeedColors +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.dsl.module +import org.koin.test.KoinTestRule +import java.net.HttpURLConnection +import kotlin.test.assertTrue + +class FeedColorsTest { + + private val mockServer = MockWebServer() + + @Before + fun before() { + val context = ApplicationProvider.getApplicationContext() + + KoinTestRule.create { + modules(apiModule, module { + single { context } + }) + } + + mockServer.start() + } + + @After + fun after() { + mockServer.shutdown() + } + + @Test + fun getFeedColorTest() = runBlocking { + val stream = TestUtils.loadResource("favicon.ico") + + mockServer.enqueue( + MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, "image/jpeg") + .setBody(Buffer().readFrom(stream)) + ) + + val url = mockServer.url("/rss").toString() + val color = FeedColors.getFeedColor(url) + + assertTrue { color != 0 } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/readrops/app/GetFoldersWithFeedsTest.kt b/app/src/androidTest/java/com/readrops/app/GetFoldersWithFeedsTest.kt new file mode 100644 index 00000000..40801a10 --- /dev/null +++ b/app/src/androidTest/java/com/readrops/app/GetFoldersWithFeedsTest.kt @@ -0,0 +1,86 @@ +package com.readrops.app + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.readrops.app.repositories.GetFoldersWithFeeds +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.Item +import com.readrops.db.entities.account.Account +import com.readrops.db.entities.account.AccountType +import com.readrops.db.filters.MainFilter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import java.time.LocalDateTime +import org.junit.Before +import org.junit.Test +import kotlin.test.assertTrue + +class GetFoldersWithFeedsTest { + + private lateinit var database: Database + private lateinit var getFoldersWithFeeds: GetFoldersWithFeeds + private val account = Account(accountType = AccountType.LOCAL) + + @Before + fun before() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build() + + runTest { + account.id = database.accountDao().insert(account).toInt() + + // inserting 3 folders + repeat(3) { time -> + database.folderDao() + .insert(Folder(name = "Folder $time", accountId = account.id)) + } + + // inserting 2 feeds, not linked to any folder + repeat(2) { time -> + database.feedDao().insert(Feed(name = "Feed $time", accountId = account.id)) + } + + // inserting 2 feeds linked to first folder (Folder 0) + repeat(2) { time -> + database.feedDao() + .insert(Feed(name = "Feed ${time + 2}", folderId = 1, accountId = account.id)) + } + + // inserting 3 unread items linked to first feed (Feed 0) + repeat(3) { time -> + database.itemDao() + .insert(Item(title = "Item $time", feedId = 1, pubDate = LocalDateTime.now())) + } + + // insert 3 read items items linked to second feed (feed 1) + repeat(3) { time -> + database.itemDao() + .insert( + Item( + title = "Item ${time + 3}", + feedId = 3, + isRead = true, + pubDate = LocalDateTime.now() + ) + ) + } + } + } + + @Test + fun getFoldersWithFeedsTest() = runTest { + getFoldersWithFeeds = GetFoldersWithFeeds(database) + val foldersAndFeeds = + getFoldersWithFeeds.get(account.id, MainFilter.ALL, account.config.useSeparateState) + .first() + + assertTrue { foldersAndFeeds.size == 4 } + assertTrue { foldersAndFeeds.entries.first().value.size == 2 } + assertTrue { foldersAndFeeds.entries.last().key == null } + assertTrue { foldersAndFeeds[null]!!.size == 2 } + assertTrue { foldersAndFeeds[null]!!.first().unreadCount == 3 } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/readrops/app/LocalRSSRepositoryTest.kt b/app/src/androidTest/java/com/readrops/app/LocalRSSRepositoryTest.kt new file mode 100644 index 00000000..8b87f364 --- /dev/null +++ b/app/src/androidTest/java/com/readrops/app/LocalRSSRepositoryTest.kt @@ -0,0 +1,116 @@ +package com.readrops.app + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.readrops.api.apiModule +import com.readrops.api.utils.ApiUtils +import com.readrops.api.utils.AuthInterceptor +import com.readrops.app.repositories.LocalRSSRepository +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.account.Account +import com.readrops.db.entities.account.AccountType +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.Before +import org.junit.Test +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule +import org.koin.test.get +import java.net.HttpURLConnection +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertTrue + + +class LocalRSSRepositoryTest : KoinTest { + + private val mockServer: MockWebServer = MockWebServer() + private val account = Account(accountType = AccountType.LOCAL) + private lateinit var database: Database + private lateinit var repository: LocalRSSRepository + private lateinit var feeds: List + + @Before + fun before() = runTest { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build() + + KoinTestRule.create { + modules(apiModule, module { + single { database } + single { + OkHttpClient.Builder() + .callTimeout(1, TimeUnit.MINUTES) + .readTimeout(1, TimeUnit.HOURS) + .addInterceptor(get()) + .build() + } + }) + } + + mockServer.start() + val url = mockServer.url("/rss") + + account.id = database.accountDao().insert(account).toInt() + feeds = listOf( + Feed( + name = "feedTest", + url = url.toString(), + accountId = account.id, + ), + ) + + database.feedDao().insert(feeds).apply { + feeds.first().id = first().toInt() + } + + repository = LocalRSSRepository(get(), database, account) + } + + @Test + fun synchronizeTest() = runTest { + val stream = TestUtils.loadResource("rss_feed.xml") + + mockServer.enqueue( + MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/xml; charset=UTF-8") + .setBody(Buffer().readFrom(stream)) + ) + + val result = repository.synchronize(listOf()) { + assertEquals(it.name, feeds.first().name) + } + + assertTrue { result.first.items.isNotEmpty() } + assertTrue { + database.itemDao().itemExists(result.first.items.first().remoteId!!, account.id) + } + } + + @Test + fun synchronizeWithFeedsTest(): Unit = runBlocking { + val stream = TestUtils.loadResource("rss_feed.xml") + + mockServer.enqueue( + MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/xml; charset=UTF-8") + .setBody(Buffer().readFrom(stream)) + ) + + val result = repository.synchronize(feeds) { + assertEquals(it.name, feeds.first().name) + } + + assertTrue { result.first.items.isNotEmpty() } + assertTrue { + database.itemDao().itemExists(result.first.items.first().remoteId!!, account.id) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/readrops/app/SyncResultAnalyserTest.kt b/app/src/androidTest/java/com/readrops/app/SyncResultAnalyserTest.kt index 782c724d..983b428a 100644 --- a/app/src/androidTest/java/com/readrops/app/SyncResultAnalyserTest.kt +++ b/app/src/androidTest/java/com/readrops/app/SyncResultAnalyserTest.kt @@ -4,72 +4,73 @@ import android.content.Context import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.readrops.app.notifications.sync.SyncResultAnalyser +import com.readrops.app.repositories.SyncResult +import com.readrops.app.sync.SyncAnalyzer import com.readrops.db.Database import com.readrops.db.entities.Feed import com.readrops.db.entities.Item import com.readrops.db.entities.account.Account import com.readrops.db.entities.account.AccountType -import com.readrops.api.services.SyncResult -import org.joda.time.LocalDateTime +import kotlinx.coroutines.test.runTest +import java.time.LocalDateTime import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import kotlin.test.assertNull @RunWith(AndroidJUnit4::class) -class SyncResultAnalyserTest { +class SyncAnalyzerTest { private lateinit var database: Database - + private lateinit var syncAnalyzer: SyncAnalyzer private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext - private val account1 = Account().apply { - accountName = "test account 1" - accountType = AccountType.FRESHRSS - isNotificationsEnabled = true - } - private val account2 = Account().apply { - accountName = "test account 2" - accountType = AccountType.NEXTCLOUD_NEWS + private val nullContentException = + NullPointerException("Notification content shouldn't be null") + + private val account1 = Account( + accountName = "test account 1", + accountType = AccountType.FRESHRSS, + isNotificationsEnabled = true + ) + + private val account2 = Account( + accountName = "test account 2", + accountType = AccountType.NEXTCLOUD_NEWS, isNotificationsEnabled = false - } + ) - private val account3 = Account().apply { - accountName = "test account 3" - accountType = AccountType.LOCAL + private val account3 = Account( + accountName = "test account 3", + accountType = AccountType.LOCAL, isNotificationsEnabled = true - } + ) @Before - fun setupDb() { + fun setupDb() = runTest { database = Room.inMemoryDatabaseBuilder(context, Database::class.java) - .build() + .build() - var account1Id = 0 - database.accountDao().insert(account1).subscribe { id -> account1Id = id.toInt() } - account1.id = account1Id + syncAnalyzer = SyncAnalyzer(context, database) - var account2Id = 0 - database.accountDao().insert(account2).subscribe { id -> account2Id = id.toInt() } - account2.id = account2Id + account1.id = database.accountDao().insert(account1).toInt() + account2.id = database.accountDao().insert(account2).toInt() + account3.id = database.accountDao().insert(account3).toInt() - var account3Id = 0 - database.accountDao().insert(account3).subscribe { id -> account3Id = id.toInt() } - account3.id = account3Id - - val accountIds = listOf(account1Id, account2Id, account3Id) + val accountIds = listOf(account1.id, account2.id, account3.id) for (i in 0..2) { val feed = Feed().apply { name = "feed ${i + 1}" - iconUrl = "https://i0.wp.com/mrmondialisation.org/wp-content/uploads/2017/05/ico_final.gif" + iconUrl = + "https://i0.wp.com/mrmondialisation.org/wp-content/uploads/2017/05/ico_final.gif" this.accountId = accountIds.find { it == (i + 1) }!! isNotificationEnabled = i % 2 == 0 } - database.feedDao().insert(feed).subscribe() + database.feedDao().insert(feed) } } @@ -79,228 +80,194 @@ class SyncResultAnalyserTest { } @Test - fun testOneElementEveryWhere() { - val item = Item().apply { - title = "caseOneElementEveryWhere" - feedId = 1 - remoteId = "item 1" + fun testOneElementEveryWhere() = runTest { + val item = Item( + title = "caseOneElementEveryWhere", + feedId = 1, + remoteId = "item 1", pubDate = LocalDateTime.now() - } + ) - database.itemDao() - .insert(item) - .subscribe() + database.itemDao().insert(item) - val syncResult = SyncResult().apply { items = mutableListOf(item) } - val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent() + val syncResult = SyncResult(items = listOf(item)) - assertEquals("caseOneElementEveryWhere", notifContent.content) - assertEquals("feed 1", notifContent.title) - assertTrue(notifContent.largeIcon != null) - assertTrue(notifContent.accountId!! > 0) + syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content -> + assertEquals("caseOneElementEveryWhere", content.text) + assertEquals("feed 1", content.title) + assertTrue(content.largeIcon != null) + assertTrue(content.accountId > 0) + } ?: throw nullContentException - database.itemDao() - .delete(item) - .subscribe() + database.itemDao().delete(item) } @Test - fun testTwoItemsOneFeed() { - val item = Item().apply { - title = "caseTwoItemsOneFeed" - feedId = 1 - } + fun testTwoItemsOneFeed() = runTest { + val item = Item(title = "caseTwoItemsOneFeed", feedId = 1) + val syncResult = SyncResult(items = listOf(item, item, item)) - val syncResult = SyncResult().apply { items = listOf(item, item, item) } - val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent() - - assertEquals(context.getString(R.string.new_items, 3), notifContent.content) - assertEquals("feed 1", notifContent.title) - assertTrue(notifContent.largeIcon != null) - assertTrue(notifContent.accountId!! > 0) + syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content -> + assertEquals(context.getString(R.string.new_items, 3), content.text) + assertEquals("feed 1", content.title) + assertTrue(content.largeIcon != null) + assertTrue(content.accountId > 0) + } ?: throw nullContentException } @Test - fun testMultipleFeeds() { - val item = Item().apply { feedId = 1 } - val item2 = Item().apply { feedId = 3 } + fun testMultipleFeeds() = runTest { + val item = Item(feedId = 1) + val item2 = Item(feedId = 3) - val syncResult = SyncResult().apply { items = listOf(item, item2) } - val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent() + val syncResult = SyncResult(items = listOf(item, item2)) - assertEquals(context.getString(R.string.new_items, 2), notifContent.content) - assertEquals(account1.accountName, notifContent.title) - assertTrue(notifContent.largeIcon != null) - assertTrue(notifContent.accountId!! > 0) + syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content -> + assertEquals(context.getString(R.string.new_items, 2), content.text) + assertEquals(account1.accountName, content.title) + assertTrue(content.largeIcon != null) + assertTrue(content.accountId > 0) + } ?: throw nullContentException } @Test - fun testMultipleAccounts() { - val item = Item().apply { feedId = 1 } - val item2 = Item().apply { feedId = 3 } + fun testMultipleAccounts() = runTest { + val item = Item(feedId = 1) + val item2 = Item(feedId = 3) - val syncResult = SyncResult().apply { items = listOf(item, item2) } - val syncResult2 = SyncResult().apply { items = listOf(item, item2) } + val syncResult = SyncResult(items = listOf(item, item2)) + val syncResult2 = SyncResult(items = listOf(item, item2)) + val syncResults = mapOf(account1 to syncResult, account3 to syncResult2) - val syncResults = mutableMapOf().apply { - put(account1, syncResult) - put(account3, syncResult2) - } - - val notifContent = SyncResultAnalyser(context, syncResults, database).getSyncNotifContent() - - assertEquals(context.getString(R.string.new_items, 4), notifContent.title) + syncAnalyzer.getNotificationContent(syncResults)?.let { content -> + assertEquals(context.getString(R.string.new_items, 4), content.title) + } ?: throw nullContentException } @Test - fun testAccountNotificationsDisabled() { - val item1 = Item().apply { - title = "testAccountNotificationsDisabled" - feedId = 1 - } + fun testAccountNotificationsDisabled() = runTest { + val item1 = Item(title = "testAccountNotificationsDisabled", feedId = 1) + val item2 = Item(title = "testAccountNotificationsDisabled2", feedId = 1) - val item2 = Item().apply { - title = "testAccountNotificationsDisabled2" - feedId = 1 - } + val syncResult = SyncResult(items = listOf(item1, item2)) - val syncResult = SyncResult().apply { items = listOf(item1, item2) } - val notifContent = SyncResultAnalyser(context, mapOf(Pair(account2, syncResult)), database).getSyncNotifContent() - - assert(notifContent.title == null) - assert(notifContent.content == null) - assert(notifContent.largeIcon == null) + val content = syncAnalyzer.getNotificationContent(mapOf(account2 to syncResult)) + assertNull(content) } @Test - fun testFeedNotificationsDisabled() { - val item1 = Item().apply { - title = "testAccountNotificationsDisabled" - feedId = 2 - } + fun testFeedNotificationsDisabled() = runTest { + val item1 = Item(title = "testAccountNotificationsDisabled", feedId = 2) + val item2 = Item(title = "testAccountNotificationsDisabled2", feedId = 2) - val item2 = Item().apply { - title = "testAccountNotificationsDisabled2" - feedId = 2 - } - - val syncResult = SyncResult().apply { items = listOf(item1, item2) } - val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent() - - assert(notifContent.title == null) - assert(notifContent.content == null) - assert(notifContent.largeIcon == null) + val syncResult = SyncResult(items = listOf(item1, item2)) + val content = syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult)) + assertNull(content) } @Test - fun testTwoAccountsWithOneAccountNotificationsEnabled() { - val item1 = Item().apply { - title = "testTwoAccountsWithOneAccountNotificationsEnabled" - feedId = 1 - remoteId = "remoteId 1" + fun testTwoAccountsWithOneAccountNotificationsEnabled() = runTest { + val item1 = Item( + title = "testTwoAccountsWithOneAccountNotificationsEnabled", + feedId = 1, + remoteId = "remoteId 1", pubDate = LocalDateTime.now() - } + ) - val item2 = Item().apply { - title = "testTwoAccountsWithOneAccountNotificationsEnabled2" + val item2 = Item( + title = "testTwoAccountsWithOneAccountNotificationsEnabled2", feedId = 3 - } + ) - val item3 = Item().apply { - title = "testTwoAccountsWithOneAccountNotificationsEnabled3" + val item3 = Item( + title = "testTwoAccountsWithOneAccountNotificationsEnabled3", feedId = 3 - } + ) - database.itemDao().insert(item1).subscribe() + database.itemDao().insert(item1) - val syncResult1 = SyncResult().apply { items = listOf(item1) } - val syncResult2 = SyncResult().apply { items = listOf(item2, item3) } + val syncResult1 = SyncResult(items = listOf(item1)) + val syncResult2 = SyncResult(items = listOf(item2, item3)) - val syncResults = mutableMapOf().apply { - put(account1, syncResult1) - put(account2, syncResult2) - } + val syncResults = mapOf(account1 to syncResult1, account2 to syncResult2) - val notifContent = SyncResultAnalyser(context, syncResults, database).getSyncNotifContent() + syncAnalyzer.getNotificationContent(syncResults)?.let { content -> + assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", content.text) + assertEquals("feed 1", content.title) + assertTrue(content.largeIcon != null) + assertTrue(content.item != null) + } ?: throw nullContentException - assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", notifContent.content) - assertEquals("feed 1", notifContent.title) - assertTrue(notifContent.largeIcon != null) - assertTrue(notifContent.item != null) - - database.itemDao().delete(item1).subscribe() + database.itemDao().delete(item1) } @Test - fun testTwoAccountsWithOneFeedNotificationEnabled() { - val item1 = Item().apply { - title = "testTwoAccountsWithOneAccountNotificationsEnabled" - feedId = 1 - remoteId = "remoteId 1" + fun testTwoAccountsWithOneFeedNotificationEnabled() = runTest { + val item1 = Item( + title = "testTwoAccountsWithOneAccountNotificationsEnabled", + feedId = 1, + remoteId = "remoteId 1", pubDate = LocalDateTime.now() - } + ) - val item2 = Item().apply { - title = "testTwoAccountsWithOneAccountNotificationsEnabled2" + val item2 = Item( + title = "testTwoAccountsWithOneAccountNotificationsEnabled2", feedId = 2 - } + ) - val item3 = Item().apply { - title = "testTwoAccountsWithOneAccountNotificationsEnabled3" + val item3 = Item( + title = "testTwoAccountsWithOneAccountNotificationsEnabled3", feedId = 2 - } + ) - database.itemDao().insert(item1).subscribe() + database.itemDao().insert(item1) - val syncResult1 = SyncResult().apply { items = listOf(item1) } - val syncResult2 = SyncResult().apply { items = listOf(item2, item3) } + val syncResult1 = SyncResult(items = listOf(item1)) + val syncResult2 = SyncResult(items = listOf(item2, item3)) - val syncResults = mutableMapOf().apply { - put(account1, syncResult1) - put(account2, syncResult2) - } + val syncResults = mapOf(account1 to syncResult1, account2 to syncResult2) - val notifContent = SyncResultAnalyser(context, syncResults, database).getSyncNotifContent() + syncAnalyzer.getNotificationContent(syncResults)?.let { content -> + assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", content.text) + assertEquals("feed 1", content.title) + assertTrue(content.largeIcon != null) + assertTrue(content.item != null) + } ?: throw nullContentException - assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", notifContent.content) - assertEquals("feed 1", notifContent.title) - assertTrue(notifContent.largeIcon != null) - assertTrue(notifContent.item != null) - - database.itemDao().delete(item1).subscribe() + database.itemDao().delete(item1) } @Test - fun testOneAccountTwoFeedsWithOneFeedNotificationEnabled() { - val item1 = Item().apply { - title = "testTwoAccountsWithOneAccountNotificationsEnabled" - feedId = 1 - remoteId = "remoteId 1" + fun testOneAccountTwoFeedsWithOneFeedNotificationEnabled() = runTest { + val item1 = Item( + title = "testTwoAccountsWithOneAccountNotificationsEnabled", + feedId = 1, + remoteId = "remoteId 1", pubDate = LocalDateTime.now() - } + ) - val item2 = Item().apply { - title = "testTwoAccountsWithOneAccountNotificationsEnabled2" + val item2 = Item( + title = "testTwoAccountsWithOneAccountNotificationsEnabled2", feedId = 2 - } + ) - val item3 = Item().apply { - title = "testTwoAccountsWithOneAccountNotificationsEnabled3" + val item3 = Item( + title = "testTwoAccountsWithOneAccountNotificationsEnabled3", feedId = 2 - } + ) - database.itemDao().insert(item1).subscribe() + database.itemDao().insert(item1) - val syncResult = SyncResult().apply { items = listOf(item1, item2, item3) } - val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent() + val syncResult = SyncResult(items = listOf(item1, item2, item3)) + syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content -> + assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", content.text) + assertEquals("feed 1", content.title) + assertTrue(content.largeIcon != null) + assertTrue(content.item != null) + assertTrue(content.accountId > 0) + } ?: throw nullContentException - assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", notifContent.content) - assertEquals("feed 1", notifContent.title) - assertTrue(notifContent.largeIcon != null) - assertTrue(notifContent.item != null) - assertTrue(notifContent.accountId!! > 0) - - database.itemDao().delete(item1).subscribe() + database.itemDao().delete(item1) } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/readrops/app/TestUtils.kt b/app/src/androidTest/java/com/readrops/app/TestUtils.kt new file mode 100644 index 00000000..5cf190db --- /dev/null +++ b/app/src/androidTest/java/com/readrops/app/TestUtils.kt @@ -0,0 +1,9 @@ +package com.readrops.app + +import java.io.InputStream + +object TestUtils { + + fun loadResource(path: String): InputStream = + javaClass.classLoader?.getResourceAsStream(path)!! +} \ No newline at end of file diff --git a/app/src/androidTest/resources/favicon.ico b/app/src/androidTest/resources/favicon.ico new file mode 100644 index 00000000..a415c1ea Binary files /dev/null and b/app/src/androidTest/resources/favicon.ico differ diff --git a/app/src/androidTest/resources/rss_feed.xml b/app/src/androidTest/resources/rss_feed.xml new file mode 100644 index 00000000..c4702aae --- /dev/null +++ b/app/src/androidTest/resources/rss_feed.xml @@ -0,0 +1,61 @@ + + + + Hacker News + + https://news.ycombinator.com/ + Links for the intellectually curious, ranked by readers. + + Africa declared free of wild polio + https://www.bbc.com/news/world-africa-53887947 + Tue, 25 Aug 2020 17:15:49 +0000 + https://news.ycombinator.com/item?id=24273602 + Author 1 + Comments]]> + media description + + + Palantir S-1 + https://www.sec.gov/Archives/edgar/data/1321655/000119312520230013/d904406ds1.htm + Tue, 25 Aug 2020 21:03:42 +0000 + https://news.ycombinator.com/item?id=24276086 + Comments]]> + + + Openwifi: Linux mac80211 compatible full-stack 802.11/Wi-Fi design based on SDR + https://github.com/open-sdr/openwifi + Tue, 25 Aug 2020 17:45:19 +0000 + https://news.ycombinator.com/item?id=24273919 + Comments]]> + + + Syllabus for Eric's PhD Students + https://docs.google.com/document/d/11D3kHElzS2HQxTwPqcaTnU5HCJ8WGE5brTXI4KLf4dM/edit + Tue, 25 Aug 2020 18:55:12 +0000 + https://news.ycombinator.com/item?id=24274699 + Comments]]> + + + WebBundles harmful to content blocking, security tools, and the open web + https://brave.com/webbundles-harmful-to-content-blocking-security-tools-and-the-open-web/ + Tue, 25 Aug 2020 19:18:50 +0000 + https://news.ycombinator.com/item?id=24274968 + Comments]]> + + + Zappos CEO Tony Hsieh is stepping down after 21 years + https://footwearnews.com/2020/business/executive-moves/zappos-ceo-tony-hsieh-steps-down-1203045974/ + Tue, 25 Aug 2020 06:11:42 +0000 + https://news.ycombinator.com/item?id=24268522 + Comments]]> + + + Evgeny Kuznetsov practices with Bauer stick that has hole in the blade + https://russianmachineneverbreaks.com/2020/07/17/evgeny-kuznetsov-practices-with-bauer-stick-that-has-hole-in-the-blade/ + Tue, 25 Aug 2020 19:38:09 +0000 + https://news.ycombinator.com/item?id=24275159 + Comments]]> + + + \ No newline at end of file diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml deleted file mode 100644 index a1d2a946..00000000 --- a/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - diff --git a/app/src/debug/java/com/readrops/app/ReadropsDebugApp.java b/app/src/debug/java/com/readrops/app/ReadropsDebugApp.java deleted file mode 100644 index 2ef2a039..00000000 --- a/app/src/debug/java/com/readrops/app/ReadropsDebugApp.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.readrops.app; - -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.work.Configuration; - -import com.facebook.flipper.android.AndroidFlipperClient; -import com.facebook.flipper.android.utils.FlipperUtils; -import com.facebook.flipper.core.FlipperClient; -import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin; -import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin; -import com.facebook.flipper.plugins.inspector.DescriptorMapping; -import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin; -import com.facebook.flipper.plugins.navigation.NavigationFlipperPlugin; -import com.facebook.flipper.plugins.network.NetworkFlipperPlugin; -import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin; -import com.facebook.soloader.SoLoader; - -public class ReadropsDebugApp extends ReadropsApp implements Configuration.Provider { - - @Override - public void onCreate() { - super.onCreate(); - SoLoader.init(this, false); - - initFlipper(); - } - - private void initFlipper() { - if (FlipperUtils.shouldEnableFlipper(this)) { - FlipperClient client = AndroidFlipperClient.getInstance(this); - client.addPlugin(new InspectorFlipperPlugin(this, DescriptorMapping.withDefaults())); - - NetworkFlipperPlugin networkPlugin = new NetworkFlipperPlugin(); - client.addPlugin(networkPlugin); - - client.addPlugin(new DatabasesFlipperPlugin(this)); - client.addPlugin(CrashReporterPlugin.getInstance()); - client.addPlugin(NavigationFlipperPlugin.getInstance()); - client.addPlugin(new SharedPreferencesFlipperPlugin(this)); - - client.start(); - } - } - - @NonNull - @Override - public Configuration getWorkManagerConfiguration() { - return new Configuration.Builder() - .setMinimumLoggingLevel(Log.DEBUG) - .build(); - } -} - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aca83851..47229776 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,26 +1,18 @@ - + - - + + + android:theme="@style/Theme.Readrops"> - + - - - - - - - - - - - - - + android:windowSoftInputMode="adjustResize"> - - - - + - + + \ No newline at end of file diff --git a/app/src/main/assets/fonts/Inter-Bold.woff2 b/app/src/main/assets/fonts/Inter-Bold.woff2 new file mode 100644 index 00000000..0f1b1576 Binary files /dev/null and b/app/src/main/assets/fonts/Inter-Bold.woff2 differ diff --git a/app/src/main/assets/fonts/Inter-BoldItalic.woff2 b/app/src/main/assets/fonts/Inter-BoldItalic.woff2 new file mode 100644 index 00000000..bc50f24c Binary files /dev/null and b/app/src/main/assets/fonts/Inter-BoldItalic.woff2 differ diff --git a/app/src/main/assets/fonts/Inter-Italic.woff2 b/app/src/main/assets/fonts/Inter-Italic.woff2 new file mode 100644 index 00000000..4c24ce28 Binary files /dev/null and b/app/src/main/assets/fonts/Inter-Italic.woff2 differ diff --git a/app/src/main/assets/fonts/Inter-Regular.woff2 b/app/src/main/assets/fonts/Inter-Regular.woff2 new file mode 100644 index 00000000..b8699af2 Binary files /dev/null and b/app/src/main/assets/fonts/Inter-Regular.woff2 differ diff --git a/app/src/main/java/com/readrops/app/AppModule.kt b/app/src/main/java/com/readrops/app/AppModule.kt index 0df8df16..7f8394e4 100644 --- a/app/src/main/java/com/readrops/app/AppModule.kt +++ b/app/src/main/java/com/readrops/app/AppModule.kt @@ -1,81 +1,105 @@ package com.readrops.app -import androidx.preference.PreferenceManager -import com.chimerapps.niddler.core.AndroidNiddler -import com.chimerapps.niddler.core.Niddler +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey import com.readrops.api.services.Credentials -import com.readrops.app.account.AccountViewModel -import com.readrops.app.addfeed.AddFeedsViewModel -import com.readrops.app.feedsfolders.ManageFeedsFoldersViewModel -import com.readrops.app.item.ItemViewModel -import com.readrops.app.itemslist.MainViewModel -import com.readrops.app.notifications.NotificationPermissionViewModel -import com.readrops.app.repositories.FeverRepository +import com.readrops.app.account.AccountScreenModel +import com.readrops.app.account.credentials.AccountCredentialsScreenMode +import com.readrops.app.account.credentials.AccountCredentialsScreenModel +import com.readrops.app.account.selection.AccountSelectionScreenModel +import com.readrops.app.feeds.FeedScreenModel +import com.readrops.app.item.ItemScreenModel +import com.readrops.app.more.preferences.PreferencesScreenModel +import com.readrops.app.notifications.NotificationsScreenModel +import com.readrops.app.repositories.BaseRepository import com.readrops.app.repositories.FreshRSSRepository -import com.readrops.app.repositories.LocalFeedRepository -import com.readrops.app.repositories.NextNewsRepository -import com.readrops.app.utils.GlideApp +import com.readrops.app.repositories.GetFoldersWithFeeds +import com.readrops.app.repositories.LocalRSSRepository +import com.readrops.app.repositories.NextcloudNewsRepository +import com.readrops.app.timelime.TimelineScreenModel +import com.readrops.app.util.DataStorePreferences +import com.readrops.app.util.Preferences import com.readrops.db.entities.account.Account import com.readrops.db.entities.account.AccountType +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import org.koin.android.ext.koin.androidApplication +import kotlinx.coroutines.SupervisorJob import org.koin.android.ext.koin.androidContext -import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.parameter.parametersOf import org.koin.dsl.module val appModule = module { - factory { (account: Account) -> + factory { TimelineScreenModel(get(), get(), get()) } + + factory { FeedScreenModel(get(), get(), get(), androidContext()) } + + factory { AccountSelectionScreenModel(get()) } + + factory { AccountScreenModel(get()) } + + factory { (itemId: Int) -> ItemScreenModel(get(), itemId, get()) } + + factory { (accountType: Account, mode: AccountCredentialsScreenMode) -> + AccountCredentialsScreenModel(accountType, mode, get()) + } + + factory { (account: Account) -> NotificationsScreenModel(account, get(), get(), get()) } + + factory { PreferencesScreenModel(get()) } + + single { GetFoldersWithFeeds(get()) } + + factory { (account: Account) -> when (account.accountType) { - AccountType.LOCAL -> LocalFeedRepository(get(), get(), androidContext(), account) - AccountType.NEXTCLOUD_NEWS -> NextNewsRepository(get(parameters = { parametersOf(Credentials.toCredentials(account)) }), - get(), androidContext(), account) - AccountType.FRESHRSS -> FreshRSSRepository(get(parameters = { parametersOf(Credentials.toCredentials(account)) }), - get(), androidContext(), account) - AccountType.FEVER -> FeverRepository(get(parameters = { parametersOf(Credentials.toCredentials(account)) }), - Dispatchers.IO, get(), get(), account) - else -> throw IllegalArgumentException("Account type not supported") + AccountType.LOCAL -> LocalRSSRepository(get(), get(), account) + AccountType.FRESHRSS -> FreshRSSRepository( + get(), account, + get(parameters = { parametersOf(Credentials.toCredentials(account)) }) + ) + AccountType.NEXTCLOUD_NEWS -> NextcloudNewsRepository( + get(), account, + get(parameters = { parametersOf(Credentials.toCredentials(account)) }) + ) + else -> throw IllegalArgumentException("Unknown account type") } } - viewModel { - MainViewModel(get()) + single { + val masterKey = MasterKey.Builder(androidContext()) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + EncryptedSharedPreferences.create( + androidContext(), + "account_credentials", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) } - viewModel { - AddFeedsViewModel(get(), get()) + single { + PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler( + produceNewData = { emptyPreferences() } + ), + migrations = listOf(SharedPreferencesMigration(get(),"settings")), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { get().preferencesDataStoreFile("settings") } + ) } - viewModel { - ItemViewModel(get()) - } + single { DataStorePreferences(get()) } - viewModel { - ManageFeedsFoldersViewModel(get()) - } + single { Preferences(get()) } - viewModel { - NotificationPermissionViewModel(get()) - } - - viewModel { - AccountViewModel(get()) - } - - single { GlideApp.with(androidApplication()) } - - single { PreferenceManager.getDefaultSharedPreferences(androidContext()) } - - single { - val niddler = AndroidNiddler.Builder() - .setNiddlerInformation(AndroidNiddler.fromApplication(get())) - .setPort(0) - .setMaxStackTraceSize(10) - .build() - - niddler.attachToApplication(get()) - - niddler.apply { start() } - } + single { NotificationManagerCompat.from(get()) } } \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/MainActivity.kt b/app/src/main/java/com/readrops/app/MainActivity.kt new file mode 100644 index 00000000..9d7c158f --- /dev/null +++ b/app/src/main/java/com/readrops/app/MainActivity.kt @@ -0,0 +1,130 @@ +package com.readrops.app + +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Color +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.toArgb +import androidx.lifecycle.lifecycleScope +import cafe.adriel.voyager.navigator.CurrentScreen +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior +import com.readrops.app.account.selection.AccountSelectionScreen +import com.readrops.app.account.selection.AccountSelectionScreenModel +import com.readrops.app.home.HomeScreen +import com.readrops.app.sync.SyncWorker +import com.readrops.app.timelime.TimelineTab +import com.readrops.app.util.Preferences +import com.readrops.app.util.theme.ReadropsTheme +import com.readrops.db.Database +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.koin.androidx.compose.KoinAndroidContext +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.component.KoinComponent +import org.koin.core.component.get + +class MainActivity : ComponentActivity(), KoinComponent { + + @OptIn(KoinExperimentalAPI::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val screenModel = get() + val accountExists = screenModel.accountExists() + + val preferences = get() + + val darkFlag = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + val initialUseDarkTheme = runBlocking { + useDarkTheme(preferences.theme.flow.first(), darkFlag) + } + + setContent { + KoinAndroidContext { + val useDarkTheme by preferences.theme.flow + .map { mode -> useDarkTheme(mode, darkFlag) } + .collectAsState(initial = initialUseDarkTheme) + + ReadropsTheme( + useDarkTheme = useDarkTheme + ) { + val navigationBarElevation = NavigationBarDefaults.Elevation + + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.light( + MaterialTheme.colorScheme.surfaceColorAtElevation(navigationBarElevation) + .toArgb(), + MaterialTheme.colorScheme.surfaceColorAtElevation(navigationBarElevation) + .toArgb() + ) + ) + + Navigator( + screen = if (accountExists) HomeScreen else AccountSelectionScreen(), + disposeBehavior = NavigatorDisposeBehavior( + // prevent screenModels being recreated when opening a screen from a tab + disposeNestedNavigators = false + ) + ) { + LaunchedEffect(Unit) { + handleIntent(intent) + } + + CurrentScreen() + } + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + lifecycleScope.launch(Dispatchers.IO) { + handleIntent(intent) + } + } + + private suspend fun handleIntent(intent: Intent) { + when { + intent.hasExtra(SyncWorker.ACCOUNT_ID_KEY) -> { + val accountId = intent.getIntExtra(SyncWorker.ACCOUNT_ID_KEY, -1) + get().accountDao() + .updateCurrentAccount(accountId) + + HomeScreen.openTab(TimelineTab) + + if (intent.hasExtra(SyncWorker.ITEM_ID_KEY)) { + val itemId = intent.getIntExtra(SyncWorker.ITEM_ID_KEY, -1) + HomeScreen.openItemScreen(itemId) + } + } + intent.action != null && intent.action == Intent.ACTION_SEND -> { + HomeScreen.openAddFeedDialog(intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty()) + } + } + } + + private fun useDarkTheme(mode: String, darkFlag: Int): Boolean { + return when (mode) { + "light" -> false + "dark" -> true + else -> darkFlag == Configuration.UI_MODE_NIGHT_YES + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/ReadropsApp.kt b/app/src/main/java/com/readrops/app/ReadropsApp.kt index cf2fadb8..4c269fc6 100644 --- a/app/src/main/java/com/readrops/app/ReadropsApp.kt +++ b/app/src/main/java/com/readrops/app/ReadropsApp.kt @@ -3,26 +3,38 @@ package com.readrops.app import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager +import android.content.Intent import android.os.Build -import androidx.appcompat.app.AppCompatDelegate -import androidx.preference.PreferenceManager +import androidx.core.app.NotificationManagerCompat +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.disk.DiskCache import com.readrops.api.apiModule -import com.readrops.app.utils.SharedPreferencesManager +import com.readrops.app.util.CrashActivity import com.readrops.db.dbModule -import io.reactivex.plugins.RxJavaPlugins import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger +import org.koin.core.component.KoinComponent +import org.koin.core.component.get import org.koin.core.context.startKoin import org.koin.core.logger.Level +import kotlin.system.exitProcess -open class ReadropsApp : Application() { +open class ReadropsApp : Application(), KoinComponent, ImageLoaderFactory { override fun onCreate() { super.onCreate() - RxJavaPlugins.setErrorHandler { e: Throwable? -> } - createNotificationChannels() - PreferenceManager.setDefaultValues(this, R.xml.preferences, false) + Thread.setDefaultUncaughtExceptionHandler { _, throwable -> + val intent = Intent(this, CrashActivity::class.java).apply { + putExtra(CrashActivity.THROWABLE_KEY, throwable) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + + startActivity(intent) + exitProcess(0) + } startKoin { androidLogger(Level.ERROR) @@ -31,41 +43,37 @@ open class ReadropsApp : Application() { modules(apiModule, dbModule, appModule) } - val theme = when (SharedPreferencesManager.readString(SharedPreferencesManager.SharedPrefKey.DARK_THEME)) { - getString(R.string.theme_value_light) -> AppCompatDelegate.MODE_NIGHT_NO - getString(R.string.theme_value_dark) -> AppCompatDelegate.MODE_NIGHT_YES - else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - } + createNotificationChannels() + } - AppCompatDelegate.setDefaultNightMode(theme) + override fun newImageLoader(): ImageLoader { + return ImageLoader.Builder(this) + .okHttpClient { get() } + .diskCache { + DiskCache.Builder() + .directory(this.cacheDir.resolve("image_cache")) + .maxSizePercent(0.05) + .build() + } + .crossfade(true) + .build() } private fun createNotificationChannels() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val feedsColorsChannel = NotificationChannel(FEEDS_COLORS_CHANNEL_ID, - getString(R.string.feeds_colors), NotificationManager.IMPORTANCE_DEFAULT) - feedsColorsChannel.description = getString(R.string.get_feeds_colors) - - val opmlExportChannel = NotificationChannel(OPML_EXPORT_CHANNEL_ID, - getString(R.string.opml_export), NotificationManager.IMPORTANCE_DEFAULT) - opmlExportChannel.description = getString(R.string.opml_export_description) - - val syncChannel = NotificationChannel(SYNC_CHANNEL_ID, - getString(R.string.auto_synchro), NotificationManager.IMPORTANCE_LOW) + val syncChannel = NotificationChannel( + SYNC_CHANNEL_ID, + getString(R.string.auto_synchro), + NotificationManager.IMPORTANCE_LOW + ) syncChannel.description = getString(R.string.account_synchro) - val manager = getSystemService(NotificationManager::class.java)!! - - manager.createNotificationChannel(feedsColorsChannel) - manager.createNotificationChannel(opmlExportChannel) - manager.createNotificationChannel(syncChannel) + NotificationManagerCompat.from(this) + .createNotificationChannel(syncChannel) } } companion object { - const val FEEDS_COLORS_CHANNEL_ID = "feedsColorsChannel" - const val OPML_EXPORT_CHANNEL_ID = "opmlExportChannel" const val SYNC_CHANNEL_ID = "syncChannel" } - } \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/account/AccountScreenModel.kt b/app/src/main/java/com/readrops/app/account/AccountScreenModel.kt new file mode 100644 index 00000000..3009c7f0 --- /dev/null +++ b/app/src/main/java/com/readrops/app/account/AccountScreenModel.kt @@ -0,0 +1,237 @@ +package com.readrops.app.account + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.Stable +import androidx.core.net.toFile +import cafe.adriel.voyager.core.model.screenModelScope +import com.readrops.api.opml.OPMLParser +import com.readrops.app.base.TabScreenModel +import com.readrops.app.repositories.ErrorResult +import com.readrops.app.repositories.GetFoldersWithFeeds +import com.readrops.app.util.components.TextFieldError +import com.readrops.app.util.components.dialog.TextFieldDialogState +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.account.Account +import com.readrops.db.entities.account.AccountType +import com.readrops.db.filters.MainFilter +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.get + +class AccountScreenModel( + private val database: Database, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : TabScreenModel(database) { + + private val _closeHome = MutableStateFlow(false) + val closeHome = _closeHome.asStateFlow() + + private val _accountState = MutableStateFlow(AccountState()) + val accountState = _accountState.asStateFlow() + + init { + screenModelScope.launch(dispatcher) { + accountEvent.collect { account -> + _accountState.update { + it.copy( + account = account + ) + } + } + } + + screenModelScope.launch(dispatcher) { + database.accountDao().selectAllAccounts() + .map { it.filter { account -> !account.isCurrentAccount } } + .collect { accounts -> + _accountState.update { it.copy(accounts = accounts) } + } + } + } + + fun openDialog(dialog: DialogState) { + if (dialog is DialogState.RenameAccount) { + _accountState.update { it.copy(renameAccountState = TextFieldDialogState(value = dialog.name)) } + } + + _accountState.update { it.copy(dialog = dialog) } + } + + fun closeDialog(dialog: DialogState? = null) { + if (dialog is DialogState.ErrorList) { + _accountState.update { it.copy(synchronizationErrors = null) } + } else if (dialog is DialogState.Error) { + _accountState.update { it.copy(error = null) } + } + + _accountState.update { it.copy(dialog = null) } + } + + fun deleteAccount() { + screenModelScope.launch(dispatcher) { + database.accountDao() + .delete(currentAccount!!) + + if (_accountState.value.accounts.isNotEmpty()) { + database.accountDao().updateCurrentAccount(_accountState.value.accounts.first().id) + } else { + _closeHome.update { true } + } + } + } + + fun exportOPMLFile(uri: Uri, context: Context) { + screenModelScope.launch(dispatcher) { + val stream = context.contentResolver.openOutputStream(uri) + if (stream == null) { + _accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) } + return@launch + } + + val foldersAndFeeds = + GetFoldersWithFeeds(database).get( + currentAccount!!.id, + MainFilter.ALL, + currentAccount!!.config.useSeparateState + ).first() + + OPMLParser.write(foldersAndFeeds, stream) + + _accountState.update { + it.copy( + opmlExportSuccess = true, + opmlExportUri = uri + ) + } + } + } + + fun parseOPMLFile(uri: Uri, context: Context) { + screenModelScope.launch(dispatcher) { + val foldersAndFeeds: Map> + + try { + val stream = context.contentResolver.openInputStream(uri) + if (stream == null) { + _accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) } + return@launch + } + + foldersAndFeeds = OPMLParser.read(stream) + } catch (e: Exception) { + _accountState.update { it.copy(error = e) } + return@launch + } + + openDialog( + DialogState.OPMLImport( + currentFeed = foldersAndFeeds.values.first().first().name!!, + feedCount = 0, + feedMax = foldersAndFeeds.values.flatten().size + ) + ) + + val errors = repository?.insertOPMLFoldersAndFeeds( + foldersAndFeeds = foldersAndFeeds, + onUpdate = { feed -> + _accountState.update { + val dialog = (it.dialog as DialogState.OPMLImport) + + it.copy( + dialog = dialog.copy( + currentFeed = feed.name!!, + feedCount = dialog.feedCount + 1 + ) + ) + } + } + ) + + closeDialog() + + _accountState.update { + it.copy(synchronizationErrors = if (errors!!.isNotEmpty()) errors else null) + } + } + } + + fun resetOPMLState() = + _accountState.update { it.copy(opmlExportUri = null, opmlExportSuccess = false) } + + fun resetCloseHome() = _closeHome.update { false } + + fun updateCurrentAccount(account: Account) { + screenModelScope.launch(dispatcher) { + database.accountDao().updateCurrentAccount(account.id) + } + } + + fun createLocalAccount() { + val context = get() + val account = Account( + accountName = context.getString(AccountType.LOCAL.typeName), + accountType = AccountType.LOCAL, + isCurrentAccount = true + ) + + screenModelScope.launch(dispatcher) { + database.accountDao().insert(account) + } + } + + fun setAccountRenameStateName(name: String) = _accountState.update { + it.copy( + renameAccountState = it.renameAccountState.copy( + value = name, + textFieldError = null + ) + ) + } + + fun renameAccount() = with(_accountState) { + if (value.renameAccountState.value.isEmpty()) { + update { it.copy(renameAccountState = it.renameAccountState.copy(textFieldError = TextFieldError.EmptyField)) } + return@with + } + + screenModelScope.launch(dispatcher) { + database.accountDao().renameAccount(value.account.id, value.renameAccountState.value) + closeDialog() + } + } +} + +@Stable +data class AccountState( + val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL), + val dialog: DialogState? = null, + val synchronizationErrors: ErrorResult? = null, + val error: Exception? = null, + val opmlExportSuccess: Boolean = false, + val opmlExportUri: Uri? = null, + val accounts: List = emptyList(), + val renameAccountState: TextFieldDialogState = TextFieldDialogState() +) + +sealed interface DialogState { + data object DeleteAccount : DialogState + data object NewAccount : DialogState + data class OPMLImport(val currentFeed: String, val feedCount: Int, val feedMax: Int) : + DialogState + + data class ErrorList(val errorResult: ErrorResult) : DialogState + data class Error(val exception: Exception) : DialogState + + data object OPMLChoice : DialogState + + data class RenameAccount(val name: String) : DialogState +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/account/AccountTab.kt b/app/src/main/java/com/readrops/app/account/AccountTab.kt new file mode 100644 index 00000000..482e2ac5 --- /dev/null +++ b/app/src/main/java/com/readrops/app/account/AccountTab.kt @@ -0,0 +1,428 @@ +package com.readrops.app.account + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import cafe.adriel.voyager.koin.getScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabOptions +import com.readrops.api.utils.ApiUtils +import com.readrops.app.R +import com.readrops.app.account.credentials.AccountCredentialsScreen +import com.readrops.app.account.credentials.AccountCredentialsScreenMode +import com.readrops.app.account.selection.AccountSelectionDialog +import com.readrops.app.account.selection.AccountSelectionScreen +import com.readrops.app.account.selection.adaptiveIconPainterResource +import com.readrops.app.notifications.NotificationsScreen +import com.readrops.app.repositories.ErrorResult +import com.readrops.app.timelime.ErrorListDialog +import com.readrops.app.util.components.SelectableIconText +import com.readrops.app.util.components.SelectableImageText +import com.readrops.app.util.components.ThreeDotsMenu +import com.readrops.app.util.components.dialog.ErrorDialog +import com.readrops.app.util.components.dialog.TextFieldDialog +import com.readrops.app.util.components.dialog.TwoChoicesDialog +import com.readrops.app.util.theme.LargeSpacer +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.VeryShortSpacer +import com.readrops.app.util.theme.spacing +import com.readrops.db.entities.account.Account +import com.readrops.db.entities.account.AccountType + +object AccountTab : Tab { + + override val options: TabOptions + @Composable + get() = TabOptions( + index = 3u, + title = stringResource(R.string.account) + ) + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val context = LocalContext.current + val screenModel = getScreenModel() + + val closeHome by screenModel.closeHome.collectAsStateWithLifecycle() + val state by screenModel.accountState.collectAsStateWithLifecycle() + + val snackbarHostState = remember { SnackbarHostState() } + + if (closeHome) { + navigator.replaceAll(AccountSelectionScreen()) + screenModel.resetCloseHome() + } + + LaunchedEffect(state.error) { + if (state.error != null) { + val action = snackbarHostState.showSnackbar( + message = context.resources.getQuantityString( + R.plurals.error_occurred, + 1 + ), + actionLabel = context.getString(R.string.details), + duration = SnackbarDuration.Short + ) + + if (action == SnackbarResult.ActionPerformed) { + screenModel.openDialog(DialogState.Error(state.error!!)) + } else { + screenModel.closeDialog(DialogState.Error(state.error!!)) + } + } + } + + LaunchedEffect(state.synchronizationErrors) { + if (state.synchronizationErrors != null) { + val action = snackbarHostState.showSnackbar( + message = context.resources.getQuantityString( + R.plurals.error_occurred, + state.synchronizationErrors!!.size + ), + actionLabel = context.getString(R.string.details), + duration = SnackbarDuration.Short + ) + + if (action == SnackbarResult.ActionPerformed) { + screenModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!)) + } else { + screenModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!)) + } + } + } + + LaunchedEffect(state.opmlExportSuccess) { + if (state.opmlExportSuccess) { + val action = snackbarHostState.showSnackbar( + message = context.getString(R.string.opml_export_success), + actionLabel = context.resources.getString(R.string.open) + ) + + if (action == SnackbarResult.ActionPerformed) { + Intent().apply { + this.action = Intent.ACTION_VIEW + setDataAndType(state.opmlExportUri, "text/xml") + }.also { + context.startActivity(Intent.createChooser(it, null)) + } + + screenModel.resetOPMLState() + } else { + screenModel.resetOPMLState() + } + } + } + + AccountDialogs( + state = state, + screenModel = screenModel + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.account)) } + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { screenModel.openDialog(DialogState.NewAccount) } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_add_account), + contentDescription = null + ) + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(start = MaterialTheme.spacing.mediumSpacing) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Image( + painter = adaptiveIconPainterResource(id = state.account.accountType!!.iconRes), + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + + MediumSpacer() + + Column { + Text( + text = state.account.accountName!!, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (state.account.displayedName != null) { + VeryShortSpacer() + + Text( + text = state.account.displayedName!!, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + if (state.account.isLocal) { + ThreeDotsMenu( + items = mapOf(1 to stringResource(id = R.string.rename_account)), + onItemClick = { + screenModel.openDialog(DialogState.RenameAccount(state.account.accountName!!)) + }, + ) + } + } + + LargeSpacer() + + if (!state.account.isLocal) { + SelectableIconText( + icon = painterResource(id = R.drawable.ic_person), + text = stringResource(R.string.credentials), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), + spacing = MaterialTheme.spacing.largeSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + tint = MaterialTheme.colorScheme.primary, + iconSize = 24.dp, + onClick = { + navigator.push( + AccountCredentialsScreen( + state.account, + AccountCredentialsScreenMode.EDIT_CREDENTIALS + ) + ) + } + ) + } + + SelectableIconText( + icon = painterResource(id = R.drawable.ic_notifications), + text = stringResource(R.string.notifications), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), + spacing = MaterialTheme.spacing.largeSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + tint = MaterialTheme.colorScheme.primary, + iconSize = 24.dp, + onClick = { navigator.push(NotificationsScreen(state.account)) } + ) + + if (state.account.isLocal) { + SelectableIconText( + icon = painterResource(id = R.drawable.ic_import_export), + text = stringResource(R.string.opml_import_export), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), + spacing = MaterialTheme.spacing.largeSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + tint = MaterialTheme.colorScheme.primary, + iconSize = 24.dp, + onClick = { screenModel.openDialog(DialogState.OPMLChoice) } + ) + } + + SelectableIconText( + icon = rememberVectorPainter(image = Icons.Default.AccountCircle), + text = stringResource(R.string.delete_account), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), + spacing = MaterialTheme.spacing.largeSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + color = MaterialTheme.colorScheme.error, + tint = MaterialTheme.colorScheme.error, + iconSize = 24.dp, + onClick = { screenModel.openDialog(DialogState.DeleteAccount) } + ) + + if (state.accounts.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing) + ) + + Text( + text = stringResource(id = R.string.other_accounts), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing) + ) + + VeryShortSpacer() + + for (account in state.accounts) { + SelectableImageText( + image = adaptiveIconPainterResource(id = account.accountType!!.iconRes), + text = account.accountName!!, + style = MaterialTheme.typography.titleMedium, + padding = MaterialTheme.spacing.mediumSpacing, + spacing = MaterialTheme.spacing.mediumSpacing, + imageSize = 24.dp, + onClick = { screenModel.updateCurrentAccount(account) } + ) + } + } + } + } + } + + @Composable + private fun AccountDialogs(state: AccountState, screenModel: AccountScreenModel) { + val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + + val opmlImportLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { screenModel.parseOPMLFile(uri, context) } + } + + val opmlExportLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/xml")) { uri -> + uri?.let { screenModel.exportOPMLFile(uri, context) } + } + + when (val dialog = state.dialog) { + is DialogState.DeleteAccount -> { + TwoChoicesDialog( + title = stringResource(R.string.delete_account), + text = stringResource(R.string.delete_account_question), + icon = rememberVectorPainter(image = Icons.Default.Delete), + confirmText = stringResource(R.string.delete), + dismissText = stringResource(R.string.cancel), + onDismiss = { screenModel.closeDialog() }, + onConfirm = { + screenModel.closeDialog() + screenModel.deleteAccount() + } + ) + } + + is DialogState.NewAccount -> { + AccountSelectionDialog( + onDismiss = { screenModel.closeDialog() }, + onValidate = { accountType -> + screenModel.closeDialog() + + if (accountType == AccountType.LOCAL) { + screenModel.createLocalAccount() + } else { + val account = Account( + accountType = accountType, + accountName = context.resources.getString(accountType.typeName) + ) + navigator.push( + AccountCredentialsScreen( + account, + AccountCredentialsScreenMode.NEW_CREDENTIALS + ) + ) + } + + } + ) + } + + is DialogState.OPMLImport -> { + OPMLImportProgressDialog( + currentFeed = dialog.currentFeed, + feedCount = dialog.feedCount, + feedMax = dialog.feedMax + ) + } + + is DialogState.ErrorList -> { + ErrorListDialog( + errorResult = dialog.errorResult as ErrorResult, // cast needed by assembleRelease + onDismiss = { screenModel.closeDialog(dialog) } + ) + } + + is DialogState.Error -> { + ErrorDialog( + exception = dialog.exception, + onDismiss = { screenModel.closeDialog(dialog) } + ) + } + + is DialogState.OPMLChoice -> { + OPMLChoiceDialog( + onChoice = { + if (it == OPML.IMPORT) { + opmlImportLauncher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray()) + } else { + opmlExportLauncher.launch("subscriptions.opml") + } + + screenModel.closeDialog() + }, + onDismiss = { screenModel.closeDialog() } + ) + } + + is DialogState.RenameAccount -> { + TextFieldDialog( + title = stringResource(id = R.string.rename_account), + icon = painterResource(id = R.drawable.ic_person), + label = stringResource(id = R.string.name), + state = state.renameAccountState, + onValueChange = { screenModel.setAccountRenameStateName(it) }, + onValidate = { screenModel.renameAccount() }, + onDismiss = { screenModel.closeDialog() } + ) + } + + else -> {} + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/account/AccountTypeListActivity.java b/app/src/main/java/com/readrops/app/account/AccountTypeListActivity.java deleted file mode 100644 index 39bbd89c..00000000 --- a/app/src/main/java/com/readrops/app/account/AccountTypeListActivity.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.readrops.app.account; - -import static com.readrops.app.utils.OPMLHelper.OPEN_OPML_FILE_REQUEST; -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT; -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT_TYPE; -import static com.readrops.app.utils.ReadropsKeys.FROM_MAIN_ACTIVITY; - -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.os.Parcelable; -import android.util.Log; -import android.view.MenuItem; -import android.view.View; -import android.widget.LinearLayout; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; - -import com.afollestad.materialdialogs.MaterialDialog; -import com.readrops.app.R; -import com.readrops.app.databinding.ActivityAccountTypeListBinding; -import com.readrops.app.itemslist.MainActivity; -import com.readrops.app.utils.OPMLHelper; -import com.readrops.app.utils.Utils; -import com.readrops.db.entities.account.Account; -import com.readrops.db.entities.account.AccountType; - -import org.koin.android.compat.ViewModelCompat; - -import java.util.ArrayList; -import java.util.List; - -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.observers.DisposableCompletableObserver; -import io.reactivex.observers.DisposableSingleObserver; -import io.reactivex.schedulers.Schedulers; - -public class AccountTypeListActivity extends AppCompatActivity { - - private static final String TAG = AccountTypeListActivity.class.getSimpleName(); - - private ActivityAccountTypeListBinding binding; - private AccountTypeListAdapter adapter; - private AccountViewModel viewModel; - - private boolean fromMainActivity; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - binding = ActivityAccountTypeListBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - viewModel = ViewModelCompat.getViewModel(this, AccountViewModel.class); - - setTitle(R.string.new_account); - - binding.accountTypeRecyclerview.setLayoutManager(new LinearLayoutManager(this)); - binding.accountTypeRecyclerview.addItemDecoration(new DividerItemDecoration(this, LinearLayout.VERTICAL)); - - fromMainActivity = getIntent().getBooleanExtra(FROM_MAIN_ACTIVITY, false); - - if (fromMainActivity) - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - - adapter = new AccountTypeListAdapter(accountType -> { - if (accountType != AccountType.LOCAL) { - Intent intent = new Intent(getApplicationContext(), AddAccountActivity.class); - - if (fromMainActivity) - intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT); - - intent.putExtra(ACCOUNT_TYPE, (Parcelable) accountType); - - startActivity(intent); - finish(); - } else { - Account account = new Account(null, getString(AccountType.LOCAL.getTypeName()), AccountType.LOCAL); - account.setCurrentAccount(true); - - viewModel.insert(account) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new DisposableSingleObserver() { - @Override - public void onSuccess(Long id) { - account.setId(id.intValue()); - goToNextActivity(account); - } - - @Override - public void onError(Throwable e) { - Log.e(TAG, e.getMessage()); - Utils.showSnackbar(binding.accountTypeListRoot, e.getMessage()); - } - }); - } - - }); - - binding.accountTypeRecyclerview.setAdapter(adapter); - adapter.setAccountTypes(getData()); - } - - private List getData() { - List accountTypes = new ArrayList<>(); - - accountTypes.add(AccountType.LOCAL); - accountTypes.add(AccountType.NEXTCLOUD_NEWS); - accountTypes.add(AccountType.FRESHRSS); - accountTypes.add(AccountType.FEVER); - - return accountTypes; - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - return true; - } - - return super.onOptionsItemSelected(item); - } - - public void openOPMLFile(View view) { - OPMLHelper.openFileIntent(this); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (requestCode == OPEN_OPML_FILE_REQUEST && resultCode == RESULT_OK && data != null) { - Uri uri = data.getData(); - - MaterialDialog dialog = new MaterialDialog.Builder(this) - .title(R.string.opml_processing) - .content(R.string.operation_takes_time) - .progress(true, 100) - .cancelable(false) - .show(); - - parseOPMLFile(uri, dialog); - } - - super.onActivityResult(requestCode, resultCode, data); - } - - private void parseOPMLFile(Uri uri, MaterialDialog dialog) { - Account account = new Account(null, getString(AccountType.LOCAL.getTypeName()), AccountType.LOCAL); - account.setCurrentAccount(true); - - viewModel.insert(account) - .flatMapCompletable(id -> { - account.setId(id.intValue()); - viewModel.setAccount(account); - - return viewModel.parseOPMLFile(uri, this); - }) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new DisposableCompletableObserver() { - @Override - public void onComplete() { - dialog.dismiss(); - goToNextActivity(account); - } - - @Override - public void onError(Throwable e) { - Log.e(TAG, e.getMessage()); - - dialog.dismiss(); - Utils.showSnackbar(binding.accountTypeListRoot, e.getMessage()); - } - }); - } - - private void goToNextActivity(Account account) { - if (fromMainActivity) { - Intent intent = new Intent(); - intent.putExtra(ACCOUNT, account); - setResult(RESULT_OK, intent); - } else { - Intent intent = new Intent(getApplicationContext(), MainActivity.class); - intent.putExtra(ACCOUNT, account); - - startActivity(intent); - } - - finish(); - } -} diff --git a/app/src/main/java/com/readrops/app/account/AccountTypeListAdapter.java b/app/src/main/java/com/readrops/app/account/AccountTypeListAdapter.java deleted file mode 100644 index 1d03e17d..00000000 --- a/app/src/main/java/com/readrops/app/account/AccountTypeListAdapter.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.readrops.app.account; - -import android.view.LayoutInflater; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import com.readrops.app.databinding.AccountTypeItemBinding; -import com.readrops.db.entities.account.AccountType; - -import java.util.List; - -public class AccountTypeListAdapter extends RecyclerView.Adapter { - - private List accountTypes; - private OnItemClickListener listener; - - public AccountTypeListAdapter(OnItemClickListener listener) { - this.listener = listener; - } - - @NonNull - @Override - public AccountTypeViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - AccountTypeItemBinding binding = AccountTypeItemBinding.inflate(LayoutInflater.from(parent.getContext()), - parent, false); - - return new AccountTypeViewHolder(binding); - } - - @Override - public void onBindViewHolder(@NonNull AccountTypeViewHolder holder, int position) { - AccountType accountType = accountTypes.get(position); - - holder.binding.accountTypeName.setText(accountType.getTypeName()); - holder.binding.accountTypeLogo.setImageResource(accountType.getIconRes()); - - holder.binding.getRoot().setOnClickListener(v -> listener.onItemClick(accountType)); - } - - @Override - public int getItemCount() { - return accountTypes.size(); - } - - public void setAccountTypes(List accountTypes) { - this.accountTypes = accountTypes; - notifyDataSetChanged(); - } - - public interface OnItemClickListener { - void onItemClick(AccountType accountType); - } - - public class AccountTypeViewHolder extends RecyclerView.ViewHolder { - - private AccountTypeItemBinding binding; - - public AccountTypeViewHolder(AccountTypeItemBinding binding) { - super(binding.getRoot()); - - this.binding = binding; - } - } -} diff --git a/app/src/main/java/com/readrops/app/account/AccountViewModel.java b/app/src/main/java/com/readrops/app/account/AccountViewModel.java deleted file mode 100644 index 57e2175c..00000000 --- a/app/src/main/java/com/readrops/app/account/AccountViewModel.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.readrops.app.account; - -import android.content.Context; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.lifecycle.ViewModel; - -import com.readrops.api.opml.OPMLParser; -import com.readrops.app.repositories.ARepository; -import com.readrops.db.Database; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.account.Account; - -import org.koin.core.parameter.ParametersHolderKt; -import org.koin.java.KoinJavaComponent; - -import java.io.FileNotFoundException; -import java.util.List; -import java.util.Map; - -import io.reactivex.Completable; -import io.reactivex.Single; - -public class AccountViewModel extends ViewModel { - - private ARepository repository; - private final Database database; - - public AccountViewModel(@NonNull Database database) { - this.database = database; - } - - public void setAccount(Account account) { - repository = KoinJavaComponent.get(ARepository.class, null, - () -> ParametersHolderKt.parametersOf(account)); - } - - public Completable login(Account account, boolean insert) { - setAccount(account); - return repository.login(account, insert); - } - - public Single insert(Account account) { - return database.accountDao().insert(account); - } - - public Completable update(Account account) { - return database.accountDao().update(account); - } - - public Completable delete(Account account) { - return database.accountDao().delete(account); - } - - public Single getAccountCount() { - return database.accountDao().getAccountCount(); - } - - @SuppressWarnings("unchecked") - public Single>> getFoldersWithFeeds() { - return repository.getFoldersWithFeeds(); - } - - public Completable parseOPMLFile(Uri uri, Context context) throws FileNotFoundException { - return OPMLParser.read(context.getContentResolver().openInputStream(uri)) - .flatMapCompletable(foldersAndFeeds -> repository.insertOPMLFoldersAndFeeds(foldersAndFeeds)); - } -} diff --git a/app/src/main/java/com/readrops/app/account/AddAccountActivity.java b/app/src/main/java/com/readrops/app/account/AddAccountActivity.java deleted file mode 100644 index b68ff641..00000000 --- a/app/src/main/java/com/readrops/app/account/AddAccountActivity.java +++ /dev/null @@ -1,239 +0,0 @@ -package com.readrops.app.account; - -import android.content.Intent; -import android.os.Bundle; -import android.util.Log; -import android.util.Patterns; -import android.view.KeyEvent; -import android.view.MenuItem; -import android.view.View; - -import androidx.appcompat.app.AppCompatActivity; - -import com.readrops.app.R; -import com.readrops.app.databinding.ActivityAddAccountBinding; -import com.readrops.app.itemslist.MainActivity; -import com.readrops.app.utils.SharedPreferencesManager; -import com.readrops.app.utils.Utils; -import com.readrops.db.entities.account.Account; -import com.readrops.db.entities.account.AccountType; - -import io.reactivex.CompletableObserver; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; -import io.reactivex.schedulers.Schedulers; - -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT; -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT_TYPE; -import static com.readrops.app.utils.ReadropsKeys.EDIT_ACCOUNT; - -import org.koin.android.compat.ViewModelCompat; - -public class AddAccountActivity extends AppCompatActivity { - - private static final String TAG = AddAccountActivity.class.getSimpleName(); - - private ActivityAddAccountBinding binding; - private AccountViewModel viewModel; - - private AccountType accountType; - private boolean forwardResult, editAccount; - - private Account accountToEdit; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - binding = ActivityAddAccountBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - viewModel = ViewModelCompat.getViewModel(this, AccountViewModel.class); - - accountType = getIntent().getParcelableExtra(ACCOUNT_TYPE); - - int flag = getIntent().getFlags(); - forwardResult = flag == Intent.FLAG_ACTIVITY_FORWARD_RESULT; - - accountToEdit = getIntent().getParcelableExtra(EDIT_ACCOUNT); - - if (forwardResult || accountToEdit != null) - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - - if (accountToEdit != null) { - editAccount = true; - fillFields(); - } else { - binding.providerImage.setImageResource(accountType.getIconRes()); - binding.providerName.setText(accountType.getTypeName()); - binding.addAccountName.setText(accountType.getTypeName()); - - if (accountType == AccountType.FRESHRSS) { - binding.addAccountPasswordLayout.setHelperText(getString(R.string.password_helper)); - } - } - } - - public void createAccount(View view) { - if (fieldsAreValid()) { - String url = binding.addAccountUrl.getText().toString().trim(); - String name = binding.addAccountName.getText().toString().trim(); - String login = binding.addAccountLogin.getText().toString().trim(); - String password = binding.addAccountPassword.getText().toString().trim(); - - if (!(url.toLowerCase().contains(Utils.HTTP_PREFIX) || url.toLowerCase().contains(Utils.HTTPS_PREFIX))) { - url = Utils.HTTPS_PREFIX + url; - } - - if (editAccount) { - accountToEdit.setUrl(url); - accountToEdit.setAccountName(name); - accountToEdit.setLogin(login); - accountToEdit.setPassword(password); - - updateAccount(); - } else { - Account account = new Account(url, name, accountType); - account.setLogin(login); - account.setPassword(password); - - viewModel.login(account, true) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new CompletableObserver() { - - @Override - public void onSubscribe(Disposable d) { - binding.addAccountLoading.setVisibility(View.VISIBLE); - binding.addAccountValidate.setEnabled(false); - } - - @Override - public void onComplete() { - saveLoginPassword(account); - - if (forwardResult) { - Intent intent = new Intent(); - intent.putExtra(ACCOUNT, account); - setResult(RESULT_OK, intent); - } else { - Intent intent = new Intent(getApplicationContext(), MainActivity.class); - intent.putExtra(ACCOUNT, account); - startActivity(intent); - } - - finish(); - } - - @Override - public void onError(Throwable e) { - Log.d(TAG, e.getMessage()); - binding.addAccountLoading.setVisibility(View.GONE); - binding.addAccountValidate.setEnabled(true); - - Utils.showSnackbar(binding.addAccountRoot, e.getMessage()); - } - }); - } - - } - } - - private boolean fieldsAreValid() { - boolean valid = true; - - if (binding.addAccountUrl.getText().toString().trim().isEmpty()) { - binding.addAccountUrl.setError(getString(R.string.empty_field)); - valid = false; - } else if (!Patterns.WEB_URL.matcher(binding.addAccountUrl.getText().toString().trim()).matches()) { - binding.addAccountUrl.setError(getString(R.string.wrong_url)); - valid = false; - } - - if (binding.addAccountName.getText().toString().trim().isEmpty()) { - binding.addAccountName.setError(getString(R.string.empty_field)); - valid = false; - } - - if (binding.addAccountLogin.getText().toString().trim().isEmpty()) { - binding.addAccountLogin.setError(getString(R.string.empty_field)); - valid = false; - } - - if (binding.addAccountPassword.getText().toString().trim().isEmpty()) { - binding.addAccountPassword.setError(getString(R.string.empty_field)); - valid = false; - } - - return valid; - } - - private void saveLoginPassword(Account account) { - SharedPreferencesManager.writeValue(account.getLoginKey(), account.getLogin()); - SharedPreferencesManager.writeValue(account.getPasswordKey(), account.getPassword()); - - account.setLogin(null); - account.setPassword(null); - } - - private void fillFields() { - binding.providerImage.setImageResource(accountToEdit.getAccountType().getIconRes()); - binding.providerName.setText(accountToEdit.getAccountType().getTypeName()); - - binding.addAccountUrl.setText(accountToEdit.getUrl()); - binding.addAccountName.setText(accountToEdit.getAccountName()); - binding.addAccountLogin.setText(SharedPreferencesManager.readString(accountToEdit.getLoginKey())); - binding.addAccountPassword.setText(SharedPreferencesManager.readString(accountToEdit.getPasswordKey())); - } - - private void updateAccount() { - viewModel.login(accountToEdit, false) - .doOnError(throwable -> Utils.showSnackbar(binding.addAccountRoot, throwable.getMessage())) - .doAfterTerminate(() -> saveLoginPassword(accountToEdit)) - .andThen(viewModel.update(accountToEdit)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new CompletableObserver() { - @Override - public void onSubscribe(Disposable d) { - binding.addAccountLoading.setVisibility(View.VISIBLE); - binding.addAccountValidate.setEnabled(false); - } - - @Override - public void onComplete() { - finish(); - } - - @Override - public void onError(Throwable e) { - binding.addAccountLoading.setVisibility(View.GONE); - binding.addAccountValidate.setEnabled(true); - - Utils.showSnackbar(binding.addAccountRoot, e.getMessage()); - } - }); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - return true; - } - - return super.onOptionsItemSelected(item); - } - - @Override - public boolean onKeyUp(int keyCode, KeyEvent event) { - switch (keyCode) { - case KeyEvent.KEYCODE_ENTER: - createAccount(null); - return true; - } - - return super.onKeyUp(keyCode, event); - } -} diff --git a/app/src/main/java/com/readrops/app/account/OPMLChoiceDialog.kt b/app/src/main/java/com/readrops/app/account/OPMLChoiceDialog.kt new file mode 100644 index 00000000..bb1f02a5 --- /dev/null +++ b/app/src/main/java/com/readrops/app/account/OPMLChoiceDialog.kt @@ -0,0 +1,56 @@ +package com.readrops.app.account + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.readrops.app.R +import com.readrops.app.util.components.dialog.BaseDialog +import com.readrops.app.util.theme.spacing + +enum class OPML { + IMPORT, + EXPORT +} + +@Composable +fun OPMLChoiceDialog( + onChoice: (OPML) -> Unit, + onDismiss: () -> Unit +) { + BaseDialog( + title = stringResource(id = R.string.opml_import_export), + icon = painterResource(id = R.drawable.ic_import_export), + onDismiss = onDismiss + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { onChoice(OPML.IMPORT) } + ) { + Text( + text = stringResource(id = R.string.opml_import), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing) + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { onChoice(OPML.EXPORT) } + ) { + Text( + text = stringResource(id = R.string.opml_export), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/account/OPMLImportProgressDialog.kt b/app/src/main/java/com/readrops/app/account/OPMLImportProgressDialog.kt new file mode 100644 index 00000000..82b9038b --- /dev/null +++ b/app/src/main/java/com/readrops/app/account/OPMLImportProgressDialog.kt @@ -0,0 +1,27 @@ +package com.readrops.app.account + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.readrops.app.R +import com.readrops.app.util.components.dialog.BaseDialog +import com.readrops.app.util.components.RefreshIndicator + +@Composable +fun OPMLImportProgressDialog( + currentFeed: String, + feedCount: Int, + feedMax: Int, +) { + BaseDialog( + title = stringResource(id = R.string.opml_import), + icon = painterResource(R.drawable.ic_import_export), + onDismiss = {} + ) { + RefreshIndicator( + currentFeed = currentFeed, + feedCount = feedCount, + feedMax = feedMax + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreen.kt b/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreen.kt new file mode 100644 index 00000000..5e30b7fb --- /dev/null +++ b/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreen.kt @@ -0,0 +1,237 @@ +package com.readrops.app.account.credentials + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import cafe.adriel.voyager.koin.getScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.readrops.app.R +import com.readrops.app.home.HomeScreen +import com.readrops.app.util.ErrorMessage +import com.readrops.app.util.components.AndroidScreen +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.app.util.theme.spacing +import com.readrops.db.entities.account.Account +import org.koin.core.parameter.parametersOf + +enum class AccountCredentialsScreenMode { + NEW_CREDENTIALS, + EDIT_CREDENTIALS +} + +class AccountCredentialsScreen( + private val account: Account, + private val mode: AccountCredentialsScreenMode +) : AndroidScreen() { + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val screenModel = + getScreenModel(parameters = { parametersOf(account, mode) }) + + val state by screenModel.state.collectAsStateWithLifecycle() + + if (state.exitScreen) { + if (mode == AccountCredentialsScreenMode.NEW_CREDENTIALS) { + navigator.replaceAll(HomeScreen) + } else { + navigator.pop() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = if (mode == AccountCredentialsScreenMode.EDIT_CREDENTIALS) + stringResource(id = R.string.credentials) + else + stringResource(id = R.string.new_account) + ) + }, + navigationIcon = { + IconButton( + onClick = { navigator.pop() } + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = null + ) + } + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .padding(paddingValues) + .imePadding() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize() + .padding(MaterialTheme.spacing.mediumSpacing) + .verticalScroll(rememberScrollState()) + ) { + Image( + painter = painterResource(id = account.accountType!!.iconRes), + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + + ShortSpacer() + + Text( + text = stringResource(id = account.accountType!!.typeName), + style = MaterialTheme.typography.headlineMedium + ) + + MediumSpacer() + + OutlinedTextField( + value = state.name, + onValueChange = { screenModel.onEvent(Event.NameEvent(it)) }, + label = { Text(text = stringResource(id = R.string.account_name)) }, + singleLine = true, + isError = state.isNameError, + supportingText = { Text(text = state.nameError?.errorText().orEmpty()) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth() + ) + + ShortSpacer() + + OutlinedTextField( + value = state.url, + onValueChange = { screenModel.onEvent(Event.URLEvent(it)) }, + label = { Text(text = stringResource(id = R.string.account_url)) }, + singleLine = true, + isError = state.isUrlError, + supportingText = { Text(text = state.urlError?.errorText().orEmpty()) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth() + ) + + ShortSpacer() + + OutlinedTextField( + value = state.login, + onValueChange = { screenModel.onEvent(Event.LoginEvent(it)) }, + label = { Text(text = stringResource(id = R.string.login)) }, + singleLine = true, + isError = state.isLoginError, + supportingText = { Text(text = state.loginError?.errorText().orEmpty()) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth() + ) + + ShortSpacer() + + OutlinedTextField( + value = state.password, + onValueChange = { screenModel.onEvent(Event.PasswordEvent(it)) }, + label = { Text(text = stringResource(id = R.string.password)) }, + trailingIcon = { + IconButton( + onClick = { screenModel.setPasswordVisibility(!state.isPasswordVisible) } + ) { + Icon( + painter = painterResource( + id = if (state.isPasswordVisible) { + R.drawable.ic_visible_off + } else R.drawable.ic_visible + ), + contentDescription = null + ) + } + }, + singleLine = true, + visualTransformation = if (state.isPasswordVisible) + VisualTransformation.None + else + PasswordVisualTransformation(), + isError = state.isPasswordError, + supportingText = { + Text( + text = state.passwordError?.errorText().orEmpty() + ) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + modifier = Modifier.fillMaxWidth() + ) + + ShortSpacer() + + Button( + onClick = { screenModel.login() }, + modifier = Modifier.fillMaxWidth() + ) { + if (state.isLoginOnGoing) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp) + ) + } else { + Text(text = stringResource(id = R.string.validate)) + } + } + + if (state.loginException != null) { + ShortSpacer() + + Text( + text = ErrorMessage.get(state.loginException!!, LocalContext.current), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreenModel.kt b/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreenModel.kt new file mode 100644 index 00000000..60ab3758 --- /dev/null +++ b/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreenModel.kt @@ -0,0 +1,161 @@ +package com.readrops.app.account.credentials + +import android.content.SharedPreferences +import android.util.Patterns +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.readrops.app.repositories.BaseRepository +import com.readrops.app.util.components.TextFieldError +import com.readrops.db.Database +import com.readrops.db.entities.account.Account +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.parameter.parametersOf + +class AccountCredentialsScreenModel( + private val account: Account, + private val mode: AccountCredentialsScreenMode, + private val database: Database, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : StateScreenModel(AccountCredentialsState()), KoinComponent { + + init { + if (mode == AccountCredentialsScreenMode.EDIT_CREDENTIALS) { + mutableState.update { + it.copy( + name = account.accountName!!, + url = account.url!!, + login = account.login!!, + password = account.password!! + ) + } + } else { + mutableState.update { it.copy(name = account.accountName!!) } + } + } + + fun onEvent(event: Event): Unit = with(mutableState) { + when (event) { + is Event.LoginEvent -> update { it.copy(login = event.value, loginError = null) } + is Event.NameEvent -> update { it.copy(name = event.value, nameError = null) } + is Event.PasswordEvent -> update { it.copy(password = event.value, passwordError = null) } + is Event.URLEvent -> update { it.copy(url = event.value, urlError = null) } + } + } + + fun setPasswordVisibility(isVisible: Boolean) { + mutableState.update { it.copy(isPasswordVisible = isVisible) } + } + + fun login() { + if (validateFields()) { + mutableState.update { it.copy(isLoginOnGoing = true) } + + with(state.value) { + val newAccount = account.copy( + url = url, + accountName = name, + login = login, + password = password, + accountType = account.accountType, + isCurrentAccount = true + ) + + val repository = get { parametersOf(newAccount) } + + screenModelScope.launch(dispatcher) { + try { + repository.login(newAccount) + } catch (e: Exception) { + mutableState.update { + it.copy( + loginException = e, + isLoginOnGoing = false + ) + } + + return@launch + } + + if (mode == AccountCredentialsScreenMode.NEW_CREDENTIALS) { + newAccount.id = database.accountDao().insert(newAccount).toInt() + + get().edit() + .putString(newAccount.loginKey, newAccount.login) + .putString(newAccount.passwordKey, newAccount.password) + .apply() + } else { + database.accountDao().update(newAccount) + } + + mutableState.update { it.copy(exitScreen = true) } + } + } + } + } + + private fun validateFields(): Boolean = with(mutableState.value) { + var validate = true + + if (url.isEmpty()) { + mutableState.update { it.copy(urlError = TextFieldError.EmptyField) } + validate = false + } + + if (name.isEmpty()) { + mutableState.update { it.copy(nameError = TextFieldError.EmptyField) } + validate = false + } + + if (login.isEmpty()) { + mutableState.update { it.copy(loginError = TextFieldError.EmptyField) } + validate = false + } + + if (password.isEmpty()) { + mutableState.update { it.copy(passwordError = TextFieldError.EmptyField) } + validate = false + } + + if (url.isNotEmpty() && !Patterns.WEB_URL.matcher(url).matches()) { + mutableState.update { it.copy(urlError = TextFieldError.BadUrl) } + validate = false + } + + return validate + } +} + +data class AccountCredentialsState( + val url: String = "https://", + val urlError: TextFieldError? = null, + val name: String = "", + val nameError: TextFieldError? = null, + val login: String = "", + val loginError: TextFieldError? = null, + val password: String = "", + val passwordError: TextFieldError? = null, + val isPasswordVisible: Boolean = false, + val isLoginOnGoing: Boolean = false, + val exitScreen: Boolean = false, + val loginException: Exception? = null +) { + val isUrlError = urlError != null + + val isNameError = nameError != null + + val isLoginError = loginError != null + + val isPasswordError = passwordError != null +} + +sealed class Event(val value: String) { + class URLEvent(value: String) : Event(value) + class NameEvent(value: String) : Event(value) + class LoginEvent(value: String) : Event(value) + class PasswordEvent(value: String) : Event(value) +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/account/selection/AccountSelectionDialog.kt b/app/src/main/java/com/readrops/app/account/selection/AccountSelectionDialog.kt new file mode 100644 index 00000000..2a5b63f6 --- /dev/null +++ b/app/src/main/java/com/readrops/app/account/selection/AccountSelectionDialog.kt @@ -0,0 +1,36 @@ +package com.readrops.app.account.selection + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.readrops.app.R +import com.readrops.app.util.components.dialog.BaseDialog +import com.readrops.app.util.components.SelectableImageText +import com.readrops.app.util.theme.spacing +import com.readrops.db.entities.account.AccountType + +@Composable +fun AccountSelectionDialog( + onDismiss: () -> Unit, + onValidate: (AccountType) -> Unit, +) { + BaseDialog( + title = stringResource(R.string.new_account), + icon = painterResource(id = R.drawable.ic_add_account), + onDismiss = onDismiss + ) { + AccountType.entries.forEach { type -> + SelectableImageText( + image = adaptiveIconPainterResource(id = type.iconRes), + text = stringResource(id = type.typeName), + style = MaterialTheme.typography.titleMedium, + spacing = MaterialTheme.spacing.mediumSpacing, + padding = MaterialTheme.spacing.shortSpacing, + imageSize = 36.dp, + onClick = { onValidate(type) } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreen.kt b/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreen.kt new file mode 100644 index 00000000..b25f2531 --- /dev/null +++ b/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreen.kt @@ -0,0 +1,240 @@ +package com.readrops.app.account.selection + +import android.graphics.drawable.AdaptiveIconDrawable +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import cafe.adriel.voyager.koin.getScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.readrops.api.utils.ApiUtils +import com.readrops.app.BuildConfig +import com.readrops.app.R +import com.readrops.app.account.OPMLImportProgressDialog +import com.readrops.app.account.credentials.AccountCredentialsScreen +import com.readrops.app.account.credentials.AccountCredentialsScreenMode +import com.readrops.app.home.HomeScreen +import com.readrops.app.util.ErrorMessage +import com.readrops.app.util.components.AndroidScreen +import com.readrops.app.util.components.SelectableImageText +import com.readrops.app.util.theme.LargeSpacer +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.app.util.theme.spacing +import com.readrops.db.entities.account.Account +import com.readrops.db.entities.account.AccountType + +class AccountSelectionScreen : AndroidScreen() { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val context = LocalContext.current + + val screenModel = getScreenModel() + val state by screenModel.state.collectAsStateWithLifecycle() + + val opmlImportLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { screenModel.parseOPMLFile(uri, context) } + } + + val snackbarHostState = remember { SnackbarHostState() } + + if (state.showOPMLImportDialog) { + OPMLImportProgressDialog( + currentFeed = state.currentFeed, + feedCount = state.feedCount, + feedMax = state.feedMax + ) + } + + LaunchedEffect(state.exception) { + if (state.exception != null) { + snackbarHostState.showSnackbar(ErrorMessage.get(state.exception!!, context)) + screenModel.resetException() + } + } + + when (state.navState) { + is NavState.GoToHomeScreen -> { + // using replace makes the app crash due to a screen key conflict + navigator.replaceAll(HomeScreen) + } + + is NavState.GoToAccountCredentialsScreen -> { + val accountType = + (state.navState as NavState.GoToAccountCredentialsScreen).accountType + val account = Account( + accountType = accountType, + accountName = stringResource(id = accountType.typeName) + ) + + navigator.push( + AccountCredentialsScreen(account, AccountCredentialsScreenMode.NEW_CREDENTIALS) + ) + screenModel.resetNavState() + } + + else -> {} + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .weight(1f) + .padding(MaterialTheme.spacing.mediumSpacing) + ) { + Image( + painter = adaptiveIconPainterResource(id = R.mipmap.ic_launcher), + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + + ShortSpacer() + + Text( + text = stringResource(id = R.string.app_name), + style = MaterialTheme.typography.headlineLarge, + ) + + LargeSpacer() + + Card { + Column { + Text( + text = stringResource(id = R.string.choose_account), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = MaterialTheme.spacing.mediumSpacing) + ) + + MediumSpacer() + + Text( + text = stringResource(id = R.string.local), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = MaterialTheme.spacing.mediumSpacing) + ) + + SelectableImageText( + image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher), + text = stringResource(id = AccountType.LOCAL.typeName), + style = MaterialTheme.typography.bodyLarge, + spacing = MaterialTheme.spacing.mediumSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + imageSize = 24.dp, + onClick = { screenModel.createAccount(AccountType.LOCAL) } + ) + + SelectableImageText( + image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher), + text = stringResource(id = R.string.opml_import), + style = MaterialTheme.typography.bodyLarge, + spacing = MaterialTheme.spacing.mediumSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + imageSize = 24.dp, + onClick = { opmlImportLauncher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray()) } + ) + + MediumSpacer() + + Text( + text = stringResource(R.string.external), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = MaterialTheme.spacing.mediumSpacing) + ) + + AccountType.entries.filter { it != AccountType.LOCAL } + .forEach { accountType -> + SelectableImageText( + image = adaptiveIconPainterResource(id = accountType.iconRes), + text = stringResource(id = accountType.typeName), + style = MaterialTheme.typography.bodyLarge, + imageSize = 24.dp, + spacing = MaterialTheme.spacing.mediumSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + onClick = { screenModel.createAccount(accountType) } + ) + } + + + } + } + } + + Text( + text = "v${BuildConfig.VERSION_NAME}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = MaterialTheme.spacing.veryShortSpacing) + ) + } + + } + + } +} + +// from https://gist.github.com/tkuenneth/ddf598663f041dc79960cda503d14448 +@Composable +fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter { + val res = LocalContext.current.resources + val theme = LocalContext.current.theme + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Android O supports adaptive icons, try loading this first (even though this is least likely to be the format). + val adaptiveIcon = ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable + if (adaptiveIcon != null) { + BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap()) + } else { + // We couldn't load the drawable as an Adaptive Icon, just use painterResource + painterResource(id) + } + } else { + // We're not on Android O or later, just use painterResource + painterResource(id) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreenModel.kt b/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreenModel.kt new file mode 100644 index 00000000..c864e090 --- /dev/null +++ b/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreenModel.kt @@ -0,0 +1,133 @@ +package com.readrops.app.account.selection + +import android.content.Context +import android.net.Uri +import androidx.core.net.toFile +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.readrops.api.opml.OPMLParser +import com.readrops.app.repositories.BaseRepository +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.account.Account +import com.readrops.db.entities.account.AccountType +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.parameter.parametersOf + +class AccountSelectionScreenModel( + private val database: Database, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : StateScreenModel(AccountSelectionState()), KoinComponent { + + fun accountExists(): Boolean { + val accountCount = runBlocking { + database.accountDao().selectAccountCount() + } + + return accountCount > 0 + } + + fun createAccount(accountType: AccountType) { + if (accountType == AccountType.LOCAL) { + screenModelScope.launch(dispatcher) { + createLocalAccount() + mutableState.update { it.copy(navState = NavState.GoToHomeScreen) } + } + } else { + mutableState.update { + it.copy(navState = NavState.GoToAccountCredentialsScreen(accountType)) + } + } + } + + fun resetNavState() { + mutableState.update { it.copy(navState = NavState.Idle) } + } + + private suspend fun createLocalAccount(): Account { + val context = get() + val account = Account( + url = null, + accountName = context.getString(AccountType.LOCAL.typeName), + accountType = AccountType.LOCAL, + isCurrentAccount = true + ) + + account.id = database.accountDao().insert(account).toInt() + return account + } + + fun parseOPMLFile(uri: Uri, context: Context) { + screenModelScope.launch(dispatcher) { + val foldersAndFeeds: Map> + + try { + val stream = context.contentResolver.openInputStream(uri) + if (stream == null) { + mutableState.update { it.copy(exception = NoSuchFileException(uri.toFile())) } + return@launch + } + + foldersAndFeeds = OPMLParser.read(stream) + } catch (e: Exception) { + mutableState.update { it.copy(exception = e) } + return@launch + } + + mutableState.update { + it.copy( + showOPMLImportDialog = true, + currentFeed = foldersAndFeeds.values.first().first().name!!, + feedCount = 0, + feedMax = foldersAndFeeds.values.flatten().size + ) + } + + val account = createLocalAccount() + val repository = get { parametersOf(account) } + + repository.insertOPMLFoldersAndFeeds( + foldersAndFeeds = foldersAndFeeds, + onUpdate = { feed -> + mutableState.update { + it.copy( + currentFeed = feed.name!!, + feedCount = it.feedCount + 1 + ) + } + } + ) + + mutableState.update { + it.copy( + showOPMLImportDialog = false, + navState = NavState.GoToHomeScreen + ) + } + } + } + + fun resetException() = mutableState.update { it.copy(exception = null) } +} + +data class AccountSelectionState( + val showOPMLImportDialog: Boolean = false, + val navState: NavState = NavState.Idle, + val exception: Exception? = null, + val currentFeed: String = "", + val feedCount: Int = 0, + val feedMax: Int = 0 +) + +sealed class NavState { + data object Idle : NavState() + data object GoToHomeScreen : NavState() + class GoToAccountCredentialsScreen(val accountType: AccountType) : NavState() +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/addfeed/AccountArrayAdapter.java b/app/src/main/java/com/readrops/app/addfeed/AccountArrayAdapter.java deleted file mode 100644 index 5be62acf..00000000 --- a/app/src/main/java/com/readrops/app/addfeed/AccountArrayAdapter.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.readrops.app.addfeed; - -import android.content.Context; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.readrops.app.R; -import com.readrops.db.entities.account.Account; - -import java.util.List; - -public class AccountArrayAdapter extends ArrayAdapter { - - public AccountArrayAdapter(@NonNull Context context, @NonNull List objects) { - super(context, 0, objects); - } - - @Override - public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { - return createItemView(position, convertView, parent); - } - - @NonNull - @Override - public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { - return createItemView(position, convertView, parent); - } - - private View createItemView(int position, View convertView, ViewGroup parent) { - if (convertView == null) { - convertView = LayoutInflater.from(getContext()).inflate(R.layout.account_type_item, parent, false); - } - - Account account = getItem(position); - - ImageView accountIcon = convertView.findViewById(R.id.account_type_logo); - TextView accountName = convertView.findViewById(R.id.account_type_name); - - accountIcon.setImageResource(account.getAccountType().getIconRes()); - accountName.setText(account.getAccountType().getTypeName()); - - return convertView; - } -} diff --git a/app/src/main/java/com/readrops/app/addfeed/AddFeedActivity.java b/app/src/main/java/com/readrops/app/addfeed/AddFeedActivity.java deleted file mode 100644 index bd0dea1a..00000000 --- a/app/src/main/java/com/readrops/app/addfeed/AddFeedActivity.java +++ /dev/null @@ -1,333 +0,0 @@ -package com.readrops.app.addfeed; - -import android.annotation.SuppressLint; -import android.content.Intent; -import android.graphics.Color; -import android.os.Bundle; -import android.util.Patterns; -import android.view.KeyEvent; -import android.view.MenuItem; -import android.view.View; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.mikepenz.fastadapter.FastAdapter; -import com.mikepenz.fastadapter.adapters.ItemAdapter; -import com.mikepenz.fastadapter.commons.utils.DiffCallback; -import com.mikepenz.fastadapter.commons.utils.FastAdapterDiffUtil; -import com.readrops.app.R; -import com.readrops.app.databinding.ActivityAddFeedBinding; -import com.readrops.app.utils.customviews.ReadropsItemTouchCallback; -import com.readrops.app.utils.SharedPreferencesManager; -import com.readrops.app.utils.Utils; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.account.Account; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.observers.DisposableSingleObserver; -import io.reactivex.schedulers.Schedulers; - -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT_ID; -import static com.readrops.app.utils.ReadropsKeys.FEEDS; - -import org.koin.android.compat.ViewModelCompat; - -public class AddFeedActivity extends AppCompatActivity implements View.OnClickListener { - - private AccountArrayAdapter arrayAdapter; - - private ItemAdapter parseItemsAdapter; - private ItemAdapter insertionResultsAdapter; - FastAdapter fastAdapter; - - private AddFeedsViewModel viewModel; - private ArrayList feedsToUpdate; - - private ActivityAddFeedBinding binding; - - @SuppressLint("ClickableViewAccessibility") - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - binding = ActivityAddFeedBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - binding.addFeedLoad.setOnClickListener(this); - binding.addFeedOk.setOnClickListener(this); - binding.addFeedOk.setEnabled(false); - - viewModel = ViewModelCompat.getViewModel(this, AddFeedsViewModel.class); - - parseItemsAdapter = new ItemAdapter<>(); - fastAdapter = FastAdapter.with(parseItemsAdapter); - fastAdapter.withSelectable(true); - fastAdapter.withOnClickListener((v, adapter, item, position) -> { - item.setChecked(!item.isChecked()); - - fastAdapter.notifyAdapterItemChanged(position); - binding.addFeedOk.setEnabled(recyclerViewHasCheckedItems()); - - return true; - }); - - binding.addFeedResults.setAdapter(fastAdapter); - RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(this); - binding.addFeedResults.setLayoutManager(layoutManager); - - new ItemTouchHelper(new ReadropsItemTouchCallback(this, - new ReadropsItemTouchCallback.Config.Builder() - .swipeDirs(ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) - .leftDraw(Color.RED, R.drawable.ic_delete, null) - .rightDraw(Color.RED, R.drawable.ic_delete, null) - .swipeCallback((viewHolder, direction) -> { - parseItemsAdapter.remove(viewHolder.getAdapterPosition()); - - if (parseItemsAdapter.getAdapterItemCount() == 0) { - binding.addFeedResultsTextView.setVisibility(View.GONE); - binding.addFeedResults.setVisibility(View.GONE); - } - }) - .build())) - .attachToRecyclerView(binding.addFeedResults); - - insertionResultsAdapter = new ItemAdapter<>(); - RecyclerView.LayoutManager layoutManager1 = new LinearLayoutManager(this); - binding.addFeedInsertedResultsRecyclerview.setAdapter(FastAdapter.with(insertionResultsAdapter)); - binding.addFeedInsertedResultsRecyclerview.setLayoutManager(layoutManager1); - - viewModel.getAccounts().observe(this, accounts -> { - // set the current account at the top of the list - int currentAccountId = getIntent().getIntExtra(ACCOUNT_ID, 1); - Collections.sort(accounts, (o1, o2) -> { - if (o1.getId() == currentAccountId) { - return -1; - } else if (o2.getId() == currentAccountId) { - return 1; - } else { - return 0; - } - }); - - arrayAdapter = new AccountArrayAdapter(this, accounts); - arrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - - binding.addFeedAccountSpinner.setAdapter(arrayAdapter); - }); - - feedsToUpdate = new ArrayList<>(); - - // new feed intent - if (getIntent().getAction() != null && getIntent().getAction().equals(Intent.ACTION_SEND)) { - String text = getIntent().getStringExtra(Intent.EXTRA_TEXT); - binding.addFeedTextInput.setText(text); - onClick(binding.addFeedLoad); - } - } - - @Override - public void onClick(View v) { - if (v.getId() == R.id.add_feed_load) { - if (isValidUrl()) { - binding.addFeedLoadingMessage.setVisibility(View.GONE); - binding.addFeedLoading.setVisibility(View.VISIBLE); - loadFeed(); - } - } else if (v.getId() == R.id.add_feed_ok) { - insertionResultsAdapter.clear(); - insertFeeds(); - } - } - - private boolean isValidUrl() { - String url = binding.addFeedTextInput.getText().toString().trim(); - - if (url.isEmpty()) { - binding.addFeedTextInput.setError(getString(R.string.empty_field)); - return false; - } else if (!Patterns.WEB_URL.matcher(url).matches()) { - binding.addFeedTextInput.setError(getString(R.string.wrong_url)); - return false; - } else - return true; - } - - private boolean recyclerViewHasCheckedItems() { - for (ParsingResult result : parseItemsAdapter.getAdapterItems()) { - if (result.isChecked()) - return true; - } - - return false; - } - - private void disableParsingResult(ParsingResult parsingResult) { - for (ParsingResult result : parseItemsAdapter.getAdapterItems()) { - if (result.getUrl().equals(parsingResult.getUrl())) { - result.setChecked(false); - fastAdapter.notifyAdapterItemChanged(parseItemsAdapter.getAdapterPosition(result)); - } - } - } - - private void loadFeed() { - String url = binding.addFeedTextInput.getText().toString().trim(); - - final String finalUrl; - if (!(url.contains(Utils.HTTP_PREFIX) || url.contains(Utils.HTTPS_PREFIX))) - finalUrl = Utils.HTTPS_PREFIX + url; - else - finalUrl = url; - - viewModel.parseUrl(finalUrl) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new DisposableSingleObserver>() { - @Override - public void onSuccess(List parsingResultList) { - displayParseResults(parsingResultList); - } - - @Override - public void onError(Throwable e) { - Utils.showSnackbar(binding.addFeedRoot, e.getMessage()); - binding.addFeedLoading.setVisibility(View.GONE); - } - }); - } - - private void displayParseResults(List parsingResultList) { - binding.addFeedLoading.setVisibility(View.GONE); - - if (!parsingResultList.isEmpty()) { - binding.addFeedResultsTextView.setVisibility(View.VISIBLE); - binding.addFeedResults.setVisibility(View.VISIBLE); - - DiffUtil.DiffResult diffResult = FastAdapterDiffUtil.calculateDiff(parseItemsAdapter, parsingResultList, new DiffCallback() { - @Override - public boolean areItemsTheSame(ParsingResult oldItem, ParsingResult newItem) { - return oldItem.getUrl().equals(newItem.getUrl()); - } - - @Override - public boolean areContentsTheSame(ParsingResult oldItem, ParsingResult newItem) { - return oldItem.getUrl().equals(newItem.getUrl()) && - oldItem.isChecked() == newItem.isChecked(); - } - - @Nullable - @Override - public Object getChangePayload(ParsingResult oldItem, int oldItemPosition, ParsingResult newItem, int newItemPosition) { - newItem.setChecked(oldItem.isChecked()); - return newItem; - } - }, false); - - FastAdapterDiffUtil.set(parseItemsAdapter, diffResult); - binding.addFeedOk.setEnabled(recyclerViewHasCheckedItems()); - } else { - parseItemsAdapter.clear(); - binding.addFeedResultsTextView.setVisibility(View.GONE); - binding.addFeedResults.setVisibility(View.GONE); - - binding.addFeedLoadingMessage.setVisibility(View.VISIBLE); - binding.addFeedLoadingMessage.setText(R.string.no_feed_found); - } - } - - private void insertFeeds() { - binding.addFeedInsertProgressbar.setVisibility(View.VISIBLE); - binding.addFeedOk.setEnabled(false); - - List feedsToInsert = new ArrayList<>(); - for (ParsingResult result : parseItemsAdapter.getAdapterItems()) { - if (result.isChecked()) - feedsToInsert.add(result); - } - - Account account = (Account) binding.addFeedAccountSpinner.getSelectedItem(); - - account.setLogin(SharedPreferencesManager.readString(account.getLoginKey())); - account.setPassword(SharedPreferencesManager.readString(account.getPasswordKey())); - - viewModel.addFeeds(feedsToInsert, account) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new DisposableSingleObserver>() { - @Override - public void onSuccess(List feedInsertionResults) { - displayInsertionResults(feedInsertionResults); - } - - @Override - public void onError(Throwable e) { - binding.addFeedInsertProgressbar.setVisibility(View.GONE); - binding.addFeedOk.setEnabled(true); - Utils.showSnackbar(binding.addFeedRoot, e.getMessage()); - } - }); - } - - private void displayInsertionResults(List feedInsertionResults) { - binding.addFeedInsertProgressbar.setVisibility(View.GONE); - binding.addFeedInsertedResultsRecyclerview.setVisibility(View.VISIBLE); - - for (FeedInsertionResult feedInsertionResult : feedInsertionResults) { - if (feedInsertionResult.getFeed() != null) - feedsToUpdate.add(feedInsertionResult.getFeed()); - - disableParsingResult(feedInsertionResult.getParsingResult()); - } - - insertionResultsAdapter.add(feedInsertionResults); - binding.addFeedOk.setEnabled(recyclerViewHasCheckedItems()); - } - - - @Override - public void onBackPressed() { - finish(); - super.onBackPressed(); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (item.getItemId() == android.R.id.home) { - finish(); - return true; - } - - return super.onOptionsItemSelected(item); - } - - @Override - public void finish() { - if (!feedsToUpdate.isEmpty()) { - Intent intent = new Intent(); - intent.putParcelableArrayListExtra(FEEDS, feedsToUpdate); - - setResult(RESULT_OK, intent); - } - - super.finish(); - } - - @Override - public boolean onKeyUp(int keyCode, KeyEvent event) { - if (keyCode == KeyEvent.KEYCODE_ENTER) { - onClick(binding.addFeedLoad); - return true; - } - - return super.onKeyUp(keyCode, event); - } -} diff --git a/app/src/main/java/com/readrops/app/addfeed/AddFeedsViewModel.java b/app/src/main/java/com/readrops/app/addfeed/AddFeedsViewModel.java deleted file mode 100644 index 33dc7c66..00000000 --- a/app/src/main/java/com/readrops/app/addfeed/AddFeedsViewModel.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.readrops.app.addfeed; - -import androidx.annotation.NonNull; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.ViewModel; - -import com.readrops.api.localfeed.LocalRSSDataSource; -import com.readrops.app.repositories.ARepository; -import com.readrops.app.utils.HtmlParser; -import com.readrops.db.Database; -import com.readrops.db.entities.account.Account; - -import org.koin.core.parameter.ParametersHolderKt; -import org.koin.java.KoinJavaComponent; - -import java.util.ArrayList; -import java.util.List; - -import io.reactivex.Single; - -public class AddFeedsViewModel extends ViewModel { - - private final Database database; - private final LocalRSSDataSource localRSSDataSource; - - public AddFeedsViewModel(@NonNull Database database, @NonNull LocalRSSDataSource localRSSDataSource) { - this.database = database; - this.localRSSDataSource = localRSSDataSource; - } - - public Single> addFeeds(List results, Account account) { - ARepository repository = KoinJavaComponent.get(ARepository.class, null, - () -> ParametersHolderKt.parametersOf(account)); - - return repository.addFeeds(results); - } - - public Single> parseUrl(String url) { - return Single.create(emitter -> { - List results = new ArrayList<>(); - - if (localRSSDataSource.isUrlRSSResource(url)) { - ParsingResult parsingResult = new ParsingResult(url, null); - results.add(parsingResult); - } else { - results.addAll(HtmlParser.getFeedLink(url)); - } - - emitter.onSuccess(results); - }); - } - - public LiveData> getAccounts() { - return database.accountDao().selectAllAsync(); - } -} diff --git a/app/src/main/java/com/readrops/app/addfeed/FeedInsertionResult.java b/app/src/main/java/com/readrops/app/addfeed/FeedInsertionResult.java deleted file mode 100644 index fd2e8379..00000000 --- a/app/src/main/java/com/readrops/app/addfeed/FeedInsertionResult.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.readrops.app.addfeed; - -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; - -import com.mikepenz.fastadapter.FastAdapter; -import com.mikepenz.fastadapter.items.AbstractItem; -import com.readrops.app.R; -import com.readrops.db.entities.Feed; - -import org.jetbrains.annotations.NotNull; - -import java.util.List; - -public class FeedInsertionResult extends AbstractItem { - - private Feed feed; - - private ParsingResult parsingResult; - - private FeedInsertionError insertionError; - - public FeedInsertionResult() { - // empty constructor - } - - public Feed getFeed() { - return feed; - } - - public void setFeed(Feed feed) { - this.feed = feed; - } - - public ParsingResult getParsingResult() { - return parsingResult; - } - - public void setParsingResult(ParsingResult parsingResult) { - this.parsingResult = parsingResult; - } - - public FeedInsertionError getInsertionError() { - return insertionError; - } - - public void setInsertionError(FeedInsertionError insertionError) { - this.insertionError = insertionError; - } - - @Override - public boolean isSelectable() { - return false; - } - - @NonNull - @Override - public FeedInsertionViewHolder getViewHolder(View v) { - return new FeedInsertionViewHolder(v); - } - - @Override - public int getType() { - return 0; - } - - @Override - public int getLayoutRes() { - return R.layout.feed_insertion_result; - } - - - public enum FeedInsertionError { - ERROR, - NETWORK_ERROR, - DB_ERROR, - PARSE_ERROR, - FORMAT_ERROR, - UNKNOWN_ERROR - } - - class FeedInsertionViewHolder extends FastAdapter.ViewHolder { - - private TextView feedInsertionRes; - private ImageView feedInsertionIcon; - - public FeedInsertionViewHolder(View itemView) { - super(itemView); - - feedInsertionRes = itemView.findViewById(R.id.feed_insertion_result_text_view); - feedInsertionIcon = itemView.findViewById(R.id.feed_insertion_result_icon); - } - - @Override - public void bindView(FeedInsertionResult item, List payloads) { - if (item.getInsertionError() == null) { - setText(R.string.feed_insertion_successfull, item.parsingResult); - feedInsertionIcon.setImageResource(R.drawable.ic_check_green); - } else { - switch (item.getInsertionError()) { - case NETWORK_ERROR: - setText(R.string.feed_insertion_network_failed, item.parsingResult); - break; - case DB_ERROR: - break; - case PARSE_ERROR: - setText(R.string.feed_insertion_parse_failed, item.parsingResult); - break; - case FORMAT_ERROR: - setText(R.string.feed_insertion_wrong_format, item.parsingResult); - break; - case UNKNOWN_ERROR: - setText(R.string.feed_insertion_unknown_error, item.parsingResult); - break; - case ERROR: - setText(R.string.feed_insertion_error, item.parsingResult); - } - - feedInsertionIcon.setImageResource(R.drawable.ic_warning_red); - } - } - - private void setText(@StringRes int stringRes, ParsingResult parsingResult) { - feedInsertionRes.setText(itemView.getContext().getString(stringRes, - parsingResult.getLabel() != null ? parsingResult.getLabel() : - parsingResult.getUrl())); - } - - @Override - public void unbindView(@NotNull FeedInsertionResult item) { - // not useful - } - } -} diff --git a/app/src/main/java/com/readrops/app/addfeed/ParsingResult.java b/app/src/main/java/com/readrops/app/addfeed/ParsingResult.java deleted file mode 100644 index 09e05489..00000000 --- a/app/src/main/java/com/readrops/app/addfeed/ParsingResult.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.readrops.app.addfeed; - -import android.view.View; -import android.widget.CheckBox; -import android.widget.TextView; - -import androidx.annotation.NonNull; - -import com.mikepenz.fastadapter.FastAdapter; -import com.mikepenz.fastadapter.items.AbstractItem; -import com.readrops.app.R; -import com.readrops.db.entities.Feed; - -import org.jetbrains.annotations.NotNull; - -import java.util.ArrayList; -import java.util.List; - -public class ParsingResult extends AbstractItem { - - private String url; - - private String label; - - private boolean checked; - - private Integer folderId; - - public ParsingResult(String url, String label) { - this.url = url; - this.label = label; - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getLabel() { - return label; - } - - public static List toParsingResults(List feeds) { - List parsingResults = new ArrayList<>(); - - for (Feed feed : feeds) { - ParsingResult parsingResult = new ParsingResult(feed.getUrl(), null); - parsingResult.setFolderId(feed.getFolderId()); - parsingResults.add(parsingResult); - } - - return parsingResults; - } - - public void setLabel(String label) { - this.label = label; - } - - public void setChecked(boolean checked) { - this.checked = checked; - } - - public boolean isChecked() { - return checked; - } - - public Integer getFolderId() { - return folderId; - } - - public void setFolderId(Integer folderId) { - this.folderId = folderId; - } - - @Override - public boolean isSelectable() { - return true; - } - - @Override - public boolean isEnabled() { - return true; - } - - @NonNull - @Override - public ParsingResultViewHolder getViewHolder(View v) { - return new ParsingResultViewHolder(v); - } - - @Override - public int getType() { - return R.id.add_feed_main_layout; - } - - @Override - public int getLayoutRes() { - return R.layout.add_feed_item; - } - - class ParsingResultViewHolder extends FastAdapter.ViewHolder { - - private TextView feedLabel; - private TextView feedUrl; - private CheckBox checkBox; - - public ParsingResultViewHolder(View itemView) { - super(itemView); - - feedLabel = itemView.findViewById(R.id.add_feed_item_label); - feedUrl = itemView.findViewById(R.id.add_feed_item_url); - checkBox = itemView.findViewById(R.id.add_feed_checkbox); - } - - @Override - public void bindView(@NotNull ParsingResult item, List payloads) { - if (!payloads.isEmpty()) { - ParsingResult newItem = (ParsingResult) payloads.get(0); - - checkBox.setChecked(newItem.isChecked()); - } else { - if (item.getLabel() != null && !item.getLabel().isEmpty()) - feedLabel.setText(item.getLabel()); - else - feedLabel.setVisibility(View.GONE); - - feedUrl.setText(item.getUrl()); - - checkBox.setChecked(item.isChecked()); - checkBox.setClickable(false); - } - - - } - - @Override - public void unbindView(@NotNull ParsingResult item) { - // not useful - } - } - - @Override - public boolean equals(Object o) { - if (o == null) - return false; - else if (!(o instanceof ParsingResult)) - return false; - else { - ParsingResult parsingResult = (ParsingResult) o; - - return parsingResult.getUrl().equals(this.getUrl()); - } - } -} diff --git a/app/src/main/java/com/readrops/app/base/TabScreenModel.kt b/app/src/main/java/com/readrops/app/base/TabScreenModel.kt new file mode 100644 index 00000000..a517a93a --- /dev/null +++ b/app/src/main/java/com/readrops/app/base/TabScreenModel.kt @@ -0,0 +1,71 @@ +package com.readrops.app.base + +import android.content.SharedPreferences +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.readrops.api.services.Credentials +import com.readrops.api.utils.AuthInterceptor +import com.readrops.app.repositories.BaseRepository +import com.readrops.db.Database +import com.readrops.db.entities.account.Account +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.parameter.parametersOf + +/** + * Custom ViewModel for Tab screens handling account change + */ +abstract class TabScreenModel( + private val database: Database, + dispatcher: CoroutineDispatcher = Dispatchers.IO +) : ScreenModel, KoinComponent { + + /** + * Repository intended to be rebuilt when the current account changes + */ + protected var repository: BaseRepository? = null + + protected var currentAccount: Account? = null + + private val _accountEvent = MutableSharedFlow() + protected val accountEvent = + _accountEvent.shareIn(scope = screenModelScope, started = SharingStarted.Eagerly) + + init { + screenModelScope.launch(dispatcher) { + database.accountDao() + .selectCurrentAccount() + .distinctUntilChanged() + .collect { account -> + if (account != null) { + if (!account.isLocal) { + if (account.login == null || account.password == null) { + val encryptedPreferences = get() + + account.login = + encryptedPreferences.getString(account.loginKey, null) + account.password = + encryptedPreferences.getString(account.passwordKey, null) + } + + // very important to avoid credentials conflicts between accounts + get().credentials = Credentials.toCredentials(account) + } + + currentAccount = account + repository = get(parameters = { parametersOf(account) }) + + + _accountEvent.emit(account) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/feeds/FeedItem.kt b/app/src/main/java/com/readrops/app/feeds/FeedItem.kt new file mode 100644 index 00000000..3d96b393 --- /dev/null +++ b/app/src/main/java/com/readrops/app/feeds/FeedItem.kt @@ -0,0 +1,81 @@ +package com.readrops.app.feeds + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.readrops.app.util.components.FeedIcon +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.app.util.theme.spacing +import com.readrops.db.entities.Feed + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FeedItem( + feed: Feed, + onClick: () -> Unit, + onLongClick: () -> Unit, + displayDivider: Boolean = true +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .then( + if (displayDivider) { + Modifier.padding(start = MaterialTheme.spacing.mediumSpacing) + } else { + Modifier + } + ) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) + ) { + if (displayDivider) { + VerticalDivider() + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding( + top = MaterialTheme.spacing.shortSpacing, + bottom = MaterialTheme.spacing.shortSpacing, + end = MaterialTheme.spacing.mediumSpacing + ) + ) { + MediumSpacer() + + FeedIcon( + iconUrl = feed.iconUrl, + name = feed.name!!, + size = 16.dp + ) + + ShortSpacer() + + Text( + text = feed.name!!, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} diff --git a/app/src/main/java/com/readrops/app/feeds/FeedScreenModel.kt b/app/src/main/java/com/readrops/app/feeds/FeedScreenModel.kt new file mode 100644 index 00000000..266c94ab --- /dev/null +++ b/app/src/main/java/com/readrops/app/feeds/FeedScreenModel.kt @@ -0,0 +1,469 @@ +package com.readrops.app.feeds + +import android.content.Context +import android.content.SharedPreferences +import android.util.Patterns +import cafe.adriel.voyager.core.model.screenModelScope +import com.readrops.api.localfeed.LocalRSSDataSource +import com.readrops.api.services.Credentials +import com.readrops.api.utils.AuthInterceptor +import com.readrops.api.utils.HtmlParser +import com.readrops.app.R +import com.readrops.app.base.TabScreenModel +import com.readrops.app.repositories.BaseRepository +import com.readrops.app.repositories.GetFoldersWithFeeds +import com.readrops.app.util.components.TextFieldError +import com.readrops.app.util.components.dialog.TextFieldDialogState +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.account.Account +import com.readrops.db.filters.MainFilter +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.parameter.parametersOf +import java.net.UnknownHostException + +@OptIn(ExperimentalCoroutinesApi::class) +class FeedScreenModel( + database: Database, + private val getFoldersWithFeeds: GetFoldersWithFeeds, + private val localRSSDataSource: LocalRSSDataSource, + private val context: Context, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : TabScreenModel(database), KoinComponent { + + private val _feedState = MutableStateFlow(FeedState()) + val feedsState = _feedState.asStateFlow() + + private val _addFeedDialogState = MutableStateFlow(AddFeedDialogState()) + val addFeedDialogState = _addFeedDialogState.asStateFlow() + + private val _updateFeedDialogState = MutableStateFlow(UpdateFeedDialogState()) + val updateFeedDialogState = _updateFeedDialogState.asStateFlow() + + private val _folderState = MutableStateFlow(TextFieldDialogState()) + val folderState = _folderState.asStateFlow() + + init { + screenModelScope.launch(dispatcher) { + accountEvent + .flatMapLatest { account -> + _feedState.update { it.copy(displayFolderCreationButton = account.config.canCreateFolder) } + _updateFeedDialogState.update { + it.copy( + isFeedUrlReadOnly = account.config.isFeedUrlReadOnly, + ) + } + + getFoldersWithFeeds.get( + account.id, + MainFilter.ALL, + account.config.useSeparateState + ) + } + .catch { throwable -> + _feedState.update { + it.copy(foldersAndFeeds = FolderAndFeedsState.ErrorState(Exception(throwable))) + } + } + .collect { foldersAndFeeds -> + _feedState.update { + it.copy(foldersAndFeeds = FolderAndFeedsState.LoadedState(foldersAndFeeds)) + } + } + + } + + screenModelScope.launch(dispatcher) { + database.accountDao() + .selectAllAccounts() + .collect { accounts -> + if (accounts.isNotEmpty()) { + _addFeedDialogState.update { dialogState -> + dialogState.copy( + accounts = accounts, + selectedAccount = accounts.find { it.isCurrentAccount } + ?: accounts.first() + ) + } + } + } + } + + screenModelScope.launch(dispatcher) { + accountEvent.flatMapLatest { account -> + _updateFeedDialogState.update { + it.copy( + isFeedUrlReadOnly = account.config.isFeedUrlReadOnly, + ) + } + + database.folderDao().selectFolders(account.id) + } + .collect { folders -> + _updateFeedDialogState.update { + it.copy( + folders = if (currentAccount!!.config.addNoFolder) { + folders + listOf( + Folder( + id = 0, + name = context.resources.getString(R.string.no_folder) + ) + ) + } else { + folders + } + ) + } + } + } + } + + fun setFolderExpandState(isExpanded: Boolean) = + _feedState.update { it.copy(areFoldersExpanded = isExpanded) } + + fun closeDialog(dialog: DialogState? = null) { + when (dialog) { + is DialogState.AddFeed -> { + _addFeedDialogState.update { + it.copy( + url = "", + error = null, + exception = null, + isLoading = false + ) + } + } + + is DialogState.AddFolder, is DialogState.UpdateFolder -> { + _folderState.update { + it.copy( + value = "", + textFieldError = null, + exception = null, + isLoading = false + ) + } + } + + is DialogState.UpdateFeed -> { + _updateFeedDialogState.update { it.copy(exception = null, isLoading = false) } + } + + else -> {} + } + + _feedState.update { it.copy(dialog = null) } + } + + fun openDialog(state: DialogState) { + when (state) { + is DialogState.UpdateFeed -> { + _updateFeedDialogState.update { + it.copy( + feedId = state.feed.id, + feedName = state.feed.name!!, + feedUrl = state.feed.url!!, + selectedFolder = state.folder ?: it.folders.find { folder -> folder.id == 0 }, + feedRemoteId = state.feed.remoteId + ) + } + } + is DialogState.UpdateFolder -> { + _folderState.update { + it.copy( + value = state.folder.name.orEmpty() + ) + } + } + is DialogState.AddFeed -> { + _addFeedDialogState.update { + it.copy(url = state.url.orEmpty()) + } + } + else -> {} + } + + _feedState.update { it.copy(dialog = state) } + } + + fun deleteFeed(feed: Feed) { + screenModelScope.launch(dispatcher) { + try { + repository?.deleteFeed(feed) + } catch (e: Exception) { + _feedState.update { it.copy(exception = e) } + } + } + } + + fun deleteFolder(folder: Folder) { + screenModelScope.launch(dispatcher) { + try { + repository?.deleteFolder(folder) + } catch (e: Exception) { + _feedState.update { it.copy(exception = e) } + } + } + } + + //region Add Feed + + fun setAddFeedDialogURL(url: String) { + _addFeedDialogState.update { + it.copy( + url = url, + error = null, + ) + } + } + + fun setAddFeedDialogSelectedAccount(account: Account) { + _addFeedDialogState.update { it.copy(selectedAccount = account, isAccountDropDownExpanded = false) } + } + + fun setAccountDropDownExpanded(isExpanded: Boolean) { + _addFeedDialogState.update { it.copy(isAccountDropDownExpanded = isExpanded) } + } + + fun addFeedDialogValidate() { + val url = _addFeedDialogState.value.url + + when { + url.isEmpty() -> { + _addFeedDialogState.update { + it.copy(error = TextFieldError.EmptyField) + } + + return + } + + !Patterns.WEB_URL.matcher(url).matches() -> { + _addFeedDialogState.update { + it.copy(error = TextFieldError.BadUrl) + } + + return + } + + else -> screenModelScope.launch(dispatcher) { + _addFeedDialogState.update { it.copy(exception = null, isLoading = true) } + + try { + if (localRSSDataSource.isUrlRSSResource(url)) { + insertFeeds(listOf(Feed(url = url))) + } else { + val rssUrls = HtmlParser.getFeedLink(url, get()) + + if (rssUrls.isEmpty()) { + _addFeedDialogState.update { + it.copy(error = TextFieldError.NoRSSFeed, isLoading = false) + } + } else { + insertFeeds(rssUrls.map { Feed(url = it.url) }) + } + } + } catch (e: Exception) { + when (e) { + is UnknownHostException -> _addFeedDialogState.update { + it.copy( + error = TextFieldError.UnreachableUrl, + isLoading = false + ) + } + + else -> _addFeedDialogState.update { + it.copy( + error = TextFieldError.NoRSSFeed, + isLoading = false + ) + } + } + } + } + } + } + + private suspend fun insertFeeds(feeds: List) { + val selectedAccount = _addFeedDialogState.value.selectedAccount + + if (!selectedAccount.isLocal) { + get().apply { + selectedAccount.login = getString(selectedAccount.loginKey, null) + selectedAccount.password = getString(selectedAccount.passwordKey, null) + } + get().apply { + credentials = Credentials.toCredentials(selectedAccount) + } + } + + val repository = get { parametersOf(selectedAccount) } + + val errors = repository.insertNewFeeds( + newFeeds = feeds, + onUpdate = { /* TODO */ } + ) + + if (errors.isEmpty()) { + closeDialog(_feedState.value.dialog) + } else { + _addFeedDialogState.update { + it.copy( + exception = errors.values.first(), + isLoading = false + ) + } + } + } + + //endregion + + //region Update feed + + fun setFolderDropDownState(isExpanded: Boolean) { + _updateFeedDialogState.update { + it.copy(isFolderDropDownExpanded = isExpanded) + } + } + + fun setSelectedFolder(folder: Folder) { + _updateFeedDialogState.update { + it.copy(selectedFolder = folder) + } + } + + fun setUpdateFeedDialogStateFeedName(feedName: String) { + _updateFeedDialogState.update { + it.copy( + feedName = feedName, + feedNameError = null, + ) + } + } + + fun setUpdateFeedDialogFeedUrl(feedUrl: String) { + _updateFeedDialogState.update { + it.copy( + feedUrl = feedUrl, + feedUrlError = null, + ) + } + } + + fun updateFeedDialogValidate() { + val feedName = _updateFeedDialogState.value.feedName + val feedUrl = _updateFeedDialogState.value.feedUrl + + when { + feedName.isEmpty() -> { + _updateFeedDialogState.update { + it.copy(feedNameError = TextFieldError.EmptyField) + } + return + } + + feedUrl.isEmpty() -> { + _updateFeedDialogState.update { + it.copy(feedUrlError = TextFieldError.EmptyField) + } + return + } + + !Patterns.WEB_URL.matcher(feedUrl).matches() -> { + _updateFeedDialogState.update { + it.copy(feedUrlError = TextFieldError.BadUrl) + } + return + } + + else -> { + _updateFeedDialogState.update { it.copy(exception = null, isLoading = true) } + + screenModelScope.launch(dispatcher) { + with(_updateFeedDialogState.value) { + try { + repository?.updateFeed( + Feed( + id = feedId, + name = feedName, + url = feedUrl, + folderId = if (selectedFolder?.id != 0) + selectedFolder?.id + else null, + remoteFolderId = selectedFolder?.remoteId, + remoteId = feedRemoteId + ) + ) + } catch (e: Exception) { + _updateFeedDialogState.update { + it.copy( + exception = e, + isLoading = false + ) + } + return@launch + } + } + + closeDialog(_feedState.value.dialog) + } + } + } + } + + //endregion + + //region Add/Update folder + + fun setFolderName(name: String) = _folderState.update { + it.copy( + value = name, + textFieldError = null, + ) + } + + fun folderValidate(updateFolder: Boolean = false) { + _folderState.update { it.copy(isLoading = true) } + val name = _folderState.value.value + + if (name.isEmpty()) { + _folderState.update { + it.copy( + textFieldError = TextFieldError.EmptyField, + isLoading = false + ) + } + return + } + + screenModelScope.launch(dispatcher) { + try { + if (updateFolder) { + val folder = (_feedState.value.dialog as DialogState.UpdateFolder).folder + repository?.updateFolder(folder.copy(name = name)) + } else { + repository?.addFolder(Folder(name = name, accountId = currentAccount!!.id)) + } + } catch (e: Exception) { + _folderState.update { it.copy(exception = e, isLoading = false) } + return@launch + } + + closeDialog(_feedState.value.dialog) + } + } + + fun resetException() = _feedState.update { it.copy(exception = null) } + + //endregion +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/feeds/FeedState.kt b/app/src/main/java/com/readrops/app/feeds/FeedState.kt new file mode 100644 index 00000000..9a38a8ed --- /dev/null +++ b/app/src/main/java/com/readrops/app/feeds/FeedState.kt @@ -0,0 +1,65 @@ +package com.readrops.app.feeds + +import com.readrops.app.util.components.TextFieldError +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.account.Account + +data class FeedState( + val foldersAndFeeds: FolderAndFeedsState = FolderAndFeedsState.InitialState, + val dialog: DialogState? = null, + val areFoldersExpanded: Boolean = false, + val displayFolderCreationButton: Boolean = false, + val exception: Exception? = null +) + +sealed interface DialogState { + data class AddFeed(val url: String? = null) : DialogState + data object AddFolder : DialogState + class DeleteFeed(val feed: Feed) : DialogState + class DeleteFolder(val folder: Folder) : DialogState + class UpdateFeed(val feed: Feed, val folder: Folder?) : DialogState + class UpdateFolder(val folder: Folder) : DialogState + class FeedSheet(val feed: Feed, val folder: Folder?) : DialogState +} + +sealed class FolderAndFeedsState { + data object InitialState : FolderAndFeedsState() + data class ErrorState(val exception: Exception) : FolderAndFeedsState() + data class LoadedState(val values: Map>) : FolderAndFeedsState() +} + +data class AddFeedDialogState( + val url: String = "", + val selectedAccount: Account = Account(accountName = ""), + val accounts: List = listOf(), + val error: TextFieldError? = null, + val exception: Exception? = null, + val isLoading: Boolean = false, + val isAccountDropDownExpanded: Boolean = false +) { + val isError: Boolean get() = error != null +} + +data class UpdateFeedDialogState( + val feedId: Int = 0, + val feedRemoteId: String? = null, + val feedName: String = "", + val feedNameError: TextFieldError? = null, + val feedUrl: String = "", + val feedUrlError: TextFieldError? = null, + val selectedFolder: Folder? = null, + val folders: List = listOf(), + val isFolderDropDownExpanded: Boolean = false, + val isFeedUrlReadOnly: Boolean = true, + val exception: Exception? = null, + val isLoading: Boolean = false +) { + val isFeedNameError + get() = feedNameError != null + + val isFeedUrlError + get() = feedUrlError != null + + val hasFolders = folders.isNotEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/feeds/FeedTab.kt b/app/src/main/java/com/readrops/app/feeds/FeedTab.kt new file mode 100644 index 00000000..71d34aee --- /dev/null +++ b/app/src/main/java/com/readrops/app/feeds/FeedTab.kt @@ -0,0 +1,347 @@ +package com.readrops.app.feeds + +import android.util.Patterns +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import cafe.adriel.voyager.koin.getScreenModel +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabOptions +import com.readrops.app.R +import com.readrops.app.feeds.dialogs.AddFeedDialog +import com.readrops.app.feeds.dialogs.FeedModalBottomSheet +import com.readrops.app.feeds.dialogs.UpdateFeedDialog +import com.readrops.app.util.ErrorMessage +import com.readrops.app.util.components.CenteredProgressIndicator +import com.readrops.app.util.components.ErrorMessage +import com.readrops.app.util.components.Placeholder +import com.readrops.app.util.components.dialog.TextFieldDialog +import com.readrops.app.util.components.dialog.TwoChoicesDialog +import com.readrops.app.util.theme.spacing +import com.readrops.db.entities.Feed +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +object FeedTab : Tab { + + private val addFeedDialogChannel = Channel() + + override val options: TabOptions + @Composable + get() = TabOptions( + index = 2u, + title = stringResource(R.string.feeds) + ) + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val haptic = LocalHapticFeedback.current + val uriHandler = LocalUriHandler.current + val context = LocalContext.current + + val screenModel = getScreenModel() + val state by screenModel.feedsState.collectAsStateWithLifecycle() + + val snackbarHostState = remember { SnackbarHostState() } + val topAppBarScrollBehavior = + TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + + LaunchedEffect(state.exception) { + if (state.exception != null) { + snackbarHostState.showSnackbar(ErrorMessage.get(state.exception!!, context)) + screenModel.resetException() + } + } + + LaunchedEffect(Unit) { + addFeedDialogChannel.receiveAsFlow() + .collect { url -> + if (Patterns.WEB_URL.matcher(url).matches()) { + screenModel.openDialog(DialogState.AddFeed(url)) + } + } + } + + FeedDialogs( + state = state, + screenModel = screenModel + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.feeds)) }, + actions = { + IconButton( + onClick = { screenModel.setFolderExpandState(state.areFoldersExpanded.not()) } + ) { + Icon( + painter = painterResource( + id = if (state.areFoldersExpanded) + R.drawable.ic_unfold_less + else + R.drawable.ic_unfold_more + ), + contentDescription = null + ) + } + }, + scrollBehavior = topAppBarScrollBehavior + ) + }, + floatingActionButton = { + Column { + if (state.displayFolderCreationButton) { + SmallFloatingActionButton( + modifier = Modifier + .padding( + start = MaterialTheme.spacing.veryShortSpacing, + bottom = MaterialTheme.spacing.shortSpacing + ), + onClick = { screenModel.openDialog(DialogState.AddFolder) } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_new_folder), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } + + FloatingActionButton( + onClick = { screenModel.openDialog(DialogState.AddFeed()) } + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null + ) + } + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) + ) { + when (state.foldersAndFeeds) { + is FolderAndFeedsState.LoadedState -> { + val foldersAndFeeds = + (state.foldersAndFeeds as FolderAndFeedsState.LoadedState).values + + if (foldersAndFeeds.isNotEmpty()) { + LazyColumn { + items( + items = foldersAndFeeds.toList() + ) { folderWithFeeds -> + fun onFeedLongClick(feed: Feed) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + uriHandler.openUri(feed.siteUrl!!) + } + + if (folderWithFeeds.first != null) { + val folder = folderWithFeeds.first!! + + FolderExpandableItem( + folder = folder, + feeds = folderWithFeeds.second, + isExpanded = state.areFoldersExpanded, + onFeedClick = { feed -> + screenModel.openDialog( + DialogState.FeedSheet(feed, folder) + ) + }, + onFeedLongClick = { feed -> onFeedLongClick(feed) }, + onUpdateFolder = { + screenModel.openDialog( + DialogState.UpdateFolder(folder) + ) + }, + onDeleteFolder = { + screenModel.openDialog( + DialogState.DeleteFolder(folder) + ) + } + ) + } else { + val feeds = folderWithFeeds.second + + for (feed in feeds) { + FeedItem( + feed = feed, + onClick = { + screenModel.openDialog( + DialogState.FeedSheet(feed, null) + ) + }, + onLongClick = { onFeedLongClick(feed) }, + displayDivider = false + ) + } + } + } + } + } else { + Placeholder( + text = stringResource(R.string.no_feed), + painter = painterResource(R.drawable.ic_rss_feed_grey) + ) + } + } + + is FolderAndFeedsState.InitialState -> { + CenteredProgressIndicator() + } + + is FolderAndFeedsState.ErrorState -> { + val exception = + (state.foldersAndFeeds as FolderAndFeedsState.ErrorState).exception + ErrorMessage(exception = exception) + } + } + } + } + } + + @Composable + private fun FeedDialogs(state: FeedState, screenModel: FeedScreenModel) { + val uriHandler = LocalUriHandler.current + + val addFeedDialogState by screenModel.addFeedDialogState.collectAsStateWithLifecycle() + val folderState by screenModel.folderState.collectAsStateWithLifecycle() + + when (val dialog = state.dialog) { + is DialogState.AddFeed -> { + AddFeedDialog( + state = addFeedDialogState, + onValueChange = { screenModel.setAddFeedDialogURL(it) }, + onExpandChange = { screenModel.setAccountDropDownExpanded(it) }, + onAccountClick = { screenModel.setAddFeedDialogSelectedAccount(it) }, + onValidate = { screenModel.addFeedDialogValidate() }, + onDismiss = { screenModel.closeDialog(DialogState.AddFeed()) }, + ) + } + + is DialogState.DeleteFeed -> { + TwoChoicesDialog( + title = stringResource(R.string.delete_feed), + text = stringResource(R.string.delete_feed_question, dialog.feed.name!!), + icon = rememberVectorPainter(image = Icons.Default.Delete), + confirmText = stringResource(R.string.delete), + dismissText = stringResource(R.string.cancel), + onDismiss = { screenModel.closeDialog() }, + onConfirm = { + screenModel.deleteFeed(dialog.feed) + screenModel.closeDialog() + } + ) + } + + is DialogState.FeedSheet -> { + FeedModalBottomSheet( + feed = dialog.feed, + onDismissRequest = { screenModel.closeDialog() }, + onOpen = { + uriHandler.openUri(dialog.feed.siteUrl!!) + screenModel.closeDialog() + }, + onUpdate = { + screenModel.openDialog(DialogState.UpdateFeed(dialog.feed, dialog.folder)) + }, + onUpdateColor = {}, + onDelete = { screenModel.openDialog(DialogState.DeleteFeed(dialog.feed)) } + ) + } + + is DialogState.UpdateFeed -> { + UpdateFeedDialog( + viewModel = screenModel, + onDismissRequest = { screenModel.closeDialog(dialog) } + ) + } + + DialogState.AddFolder -> { + TextFieldDialog( + title = stringResource(id = R.string.add_folder), + icon = painterResource(id = R.drawable.ic_new_folder), + label = stringResource(id = R.string.name), + state = folderState, + onValueChange = { screenModel.setFolderName(it) }, + onValidate = { screenModel.folderValidate() }, + onDismiss = { screenModel.closeDialog(DialogState.AddFolder) } + ) + } + + is DialogState.DeleteFolder -> { + TwoChoicesDialog( + title = stringResource(R.string.delete_folder), + text = stringResource(R.string.delete_folder_question, dialog.folder.name!!), + icon = rememberVectorPainter(image = Icons.Default.Delete), + confirmText = stringResource(R.string.delete), + dismissText = stringResource(R.string.cancel), + onDismiss = { screenModel.closeDialog() }, + onConfirm = { + screenModel.deleteFolder(dialog.folder) + screenModel.closeDialog() + } + ) + } + + is DialogState.UpdateFolder -> { + TextFieldDialog( + title = stringResource(id = R.string.edit_folder), + icon = painterResource(id = R.drawable.ic_folder_grey), + label = stringResource(id = R.string.name), + state = folderState, + onValueChange = { screenModel.setFolderName(it) }, + onValidate = { screenModel.folderValidate(updateFolder = true) }, + onDismiss = { screenModel.closeDialog(DialogState.UpdateFolder(dialog.folder)) } + ) + } + + null -> {} + } + } + + suspend fun openAddFeedDialog(url: String) { + addFeedDialogChannel.send(url) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/feeds/FolderExpandableItem.kt b/app/src/main/java/com/readrops/app/feeds/FolderExpandableItem.kt new file mode 100644 index 00000000..589db16b --- /dev/null +++ b/app/src/main/java/com/readrops/app/feeds/FolderExpandableItem.kt @@ -0,0 +1,119 @@ +package com.readrops.app.feeds + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.readrops.app.R +import com.readrops.app.util.components.ThreeDotsMenu +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.spacing +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder + +@Composable +fun FolderExpandableItem( + folder: Folder, + feeds: List, + isExpanded: Boolean = false, + onFeedClick: (Feed) -> Unit, + onFeedLongClick: (Feed) -> Unit, + onUpdateFolder: () -> Unit, + onDeleteFolder: () -> Unit +) { + var isFolderExpanded by remember { mutableStateOf(false) } + + LaunchedEffect(isExpanded) { + isFolderExpanded = isExpanded + } + + Column( + modifier = Modifier + .animateContentSize( + animationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing, + ) + ) + ) { + Column( + modifier = Modifier + .clickable { isFolderExpanded = isFolderExpanded.not() } + .padding( + start = MaterialTheme.spacing.mediumSpacing, + top = MaterialTheme.spacing.veryShortSpacing, + bottom = MaterialTheme.spacing.veryShortSpacing + ) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_folder_grey), + tint = MaterialTheme.colorScheme.primary, + contentDescription = folder.name + ) + + MediumSpacer() + + Text( + text = folder.name!!, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + ThreeDotsMenu( + items = mapOf( + 1 to stringResource(id = R.string.update), + 2 to stringResource(id = R.string.delete) + ), + onItemClick = { index -> + when (index) { + 1 -> onUpdateFolder() + else -> onDeleteFolder() + } + } + ) + } + } + + Column { + if (isFolderExpanded) { + for (feed in feeds) { + FeedItem( + feed = feed, + onClick = { onFeedClick(feed) }, + onLongClick = { onFeedLongClick(feed) }, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/feeds/dialogs/AddFeedDialog.kt b/app/src/main/java/com/readrops/app/feeds/dialogs/AddFeedDialog.kt new file mode 100644 index 00000000..f9128494 --- /dev/null +++ b/app/src/main/java/com/readrops/app/feeds/dialogs/AddFeedDialog.kt @@ -0,0 +1,137 @@ +package com.readrops.app.feeds.dialogs + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.readrops.app.R +import com.readrops.app.account.selection.adaptiveIconPainterResource +import com.readrops.app.feeds.AddFeedDialogState +import com.readrops.app.util.ErrorMessage +import com.readrops.app.util.components.LoadingTextButton +import com.readrops.app.util.components.dialog.BaseDialog +import com.readrops.app.util.theme.LargeSpacer +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.db.entities.account.Account + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddFeedDialog( + state: AddFeedDialogState, + onValueChange: (String) -> Unit, + onExpandChange: (Boolean) -> Unit, + onAccountClick: (Account) -> Unit, + onValidate: () -> Unit, + onDismiss: () -> Unit +) { + BaseDialog( + title = stringResource(R.string.add_feed_item), + icon = painterResource(id = R.drawable.ic_rss_feed_grey), + onDismiss = { if (!state.isLoading) onDismiss() } + ) { + OutlinedTextField( + value = state.url, + label = { Text(text = stringResource(id = R.string.url)) }, + onValueChange = { onValueChange(it) }, + singleLine = true, + trailingIcon = { + if (state.url.isNotEmpty()) { + IconButton( + onClick = { onValueChange("") } + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = null + ) + } + } + }, + isError = state.isError, + supportingText = { Text(state.error?.errorText().orEmpty()) } + ) + + ShortSpacer() + + ExposedDropdownMenuBox( + expanded = state.isAccountDropDownExpanded, + onExpandedChange = { onExpandChange(!state.isAccountDropDownExpanded) } + ) { + ExposedDropdownMenu( + expanded = state.isAccountDropDownExpanded, + onDismissRequest = { onExpandChange(false) } + ) { + for (account in state.accounts) { + DropdownMenuItem( + text = { Text(text = account.accountName!!) }, + onClick = { + onAccountClick(account) + }, + leadingIcon = { + Image( + painter = adaptiveIconPainterResource( + id = account.accountType!!.iconRes + ), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + ) + } + } + + OutlinedTextField( + value = state.selectedAccount.accountName!!, + readOnly = true, + onValueChange = {}, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = state.isAccountDropDownExpanded) + }, + leadingIcon = { + Image( + painter = adaptiveIconPainterResource( + id = state.selectedAccount.accountType!!.iconRes + ), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + modifier = Modifier.menuAnchor() + ) + } + + if (state.exception != null) { + MediumSpacer() + + Text( + text = ErrorMessage.get(state.exception, LocalContext.current), + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + + LargeSpacer() + + LoadingTextButton( + text = stringResource(id = R.string.validate), + isLoading = state.isLoading, + onClick = onValidate, + ) + } +} diff --git a/app/src/main/java/com/readrops/app/feeds/dialogs/FeedBottomSheet.kt b/app/src/main/java/com/readrops/app/feeds/dialogs/FeedBottomSheet.kt new file mode 100644 index 00000000..1f575f2f --- /dev/null +++ b/app/src/main/java/com/readrops/app/feeds/dialogs/FeedBottomSheet.kt @@ -0,0 +1,159 @@ +package com.readrops.app.feeds.dialogs + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Create +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import coil.compose.AsyncImage +import com.readrops.app.R +import com.readrops.app.util.theme.LargeSpacer +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.VeryShortSpacer +import com.readrops.app.util.theme.spacing +import com.readrops.db.entities.Feed + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FeedModalBottomSheet( + feed: Feed, + onDismissRequest: () -> Unit, + onOpen: () -> Unit, + onUpdate: () -> Unit, + onUpdateColor: () -> Unit, + onDelete: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = { onDismissRequest() } + ) { + Column { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding( + horizontal = MaterialTheme.spacing.largeSpacing + ) + ) { + AsyncImage( + model = feed.iconUrl, + contentDescription = feed.name!!, + placeholder = painterResource(id = R.drawable.ic_rss_feed_grey), + error = painterResource(id = R.drawable.ic_rss_feed_grey), + modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing) + ) + + MediumSpacer() + + Column { + Text( + text = feed.name!!, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (feed.description != null) { + VeryShortSpacer() + + Text( + text = feed.description!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + MediumSpacer() + + HorizontalDivider( + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing) + ) + + MediumSpacer() + + BottomSheetOption( + text = stringResource(R.string.open), + icon = ImageVector.vectorResource(id = R.drawable.ic_open_in_browser), + onClick = onOpen + ) + + BottomSheetOption( + text = stringResource(id = R.string.update), + icon = Icons.Default.Create, + onClick = onUpdate + ) + + BottomSheetOption( + text = stringResource(R.string.update_color), + icon = ImageVector.vectorResource(R.drawable.ic_color), + onClick = onUpdateColor + ) + + BottomSheetOption( + text = stringResource(R.string.delete), + icon = Icons.Default.Delete, + onClick = onDelete + ) + } + + LargeSpacer() + } +} + +@Composable +fun BottomSheetOption( + text: String, + icon: ImageVector, + onClick: () -> Unit, +) { + Box( + modifier = Modifier.clickable { onClick() } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = MaterialTheme.spacing.mediumSpacing, + vertical = MaterialTheme.spacing.shortSpacing + ) + + ) { + Icon( + imageVector = icon, + contentDescription = text, + tint = MaterialTheme.colorScheme.primary + ) + + MediumSpacer() + + Text( + text = text, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/feeds/dialogs/UpdateFeedDialog.kt b/app/src/main/java/com/readrops/app/feeds/dialogs/UpdateFeedDialog.kt new file mode 100644 index 00000000..e2dc7d3e --- /dev/null +++ b/app/src/main/java/com/readrops/app/feeds/dialogs/UpdateFeedDialog.kt @@ -0,0 +1,142 @@ +package com.readrops.app.feeds.dialogs + +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.readrops.app.R +import com.readrops.app.feeds.FeedScreenModel +import com.readrops.app.util.ErrorMessage +import com.readrops.app.util.components.LoadingTextButton +import com.readrops.app.util.components.dialog.BaseDialog +import com.readrops.app.util.theme.LargeSpacer +import com.readrops.app.util.theme.MediumSpacer + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UpdateFeedDialog( + viewModel: FeedScreenModel, + onDismissRequest: () -> Unit +) { + val state by viewModel.updateFeedDialogState.collectAsStateWithLifecycle() + + BaseDialog( + title = stringResource(R.string.edit_feed), + icon = painterResource(id = R.drawable.ic_rss_feed_grey), + onDismiss = onDismissRequest + ) { + OutlinedTextField( + value = state.feedName, + onValueChange = { viewModel.setUpdateFeedDialogStateFeedName(it) }, + label = { Text(text = stringResource(R.string.feed_name)) }, + singleLine = true, + isError = state.isFeedNameError, + supportingText = { + if (state.isFeedNameError) { + Text( + text = state.feedNameError?.errorText().orEmpty() + ) + } + } + ) + + MediumSpacer() + + OutlinedTextField( + value = state.feedUrl, + onValueChange = { viewModel.setUpdateFeedDialogFeedUrl(it) }, + label = { Text(text = stringResource(R.string.feed_url)) }, + singleLine = true, + readOnly = state.isFeedUrlReadOnly, + enabled = !state.isFeedUrlReadOnly, + isError = state.isFeedUrlError, + supportingText = { + if (state.isFeedUrlError) { + Text( + text = state.feedUrlError?.errorText().orEmpty() + ) + } else if (state.isFeedUrlReadOnly) { + Text( + text = stringResource(id = R.string.feed_url_read_only) + ) + } + } + ) + + MediumSpacer() + + ExposedDropdownMenuBox( + expanded = state.isFolderDropDownExpanded && state.hasFolders, + onExpandedChange = { viewModel.setFolderDropDownState(state.isFolderDropDownExpanded.not()) } + ) { + ExposedDropdownMenu( + expanded = state.isFolderDropDownExpanded && state.hasFolders, + onDismissRequest = { viewModel.setFolderDropDownState(false) } + ) { + for (folder in state.folders) { + DropdownMenuItem( + text = { Text(text = folder.name!!) }, + onClick = { + viewModel.setSelectedFolder(folder) + viewModel.setFolderDropDownState(false) + }, + leadingIcon = { + Icon( + painterResource(id = R.drawable.ic_folder_grey), + contentDescription = null, + ) + } + ) + } + } + + OutlinedTextField( + value = state.selectedFolder?.name.orEmpty(), + readOnly = true, + enabled = state.hasFolders, + onValueChange = {}, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = state.isFolderDropDownExpanded) + }, + leadingIcon = { + if (state.selectedFolder != null) { + Icon( + painterResource(id = R.drawable.ic_folder_grey), + contentDescription = null, + ) + } + }, + modifier = Modifier.menuAnchor() + ) + } + + if (state.exception != null) { + MediumSpacer() + + Text( + text = ErrorMessage.get(state.exception!!, LocalContext.current), + color = MaterialTheme.colorScheme.error + ) + } + + LargeSpacer() + + LoadingTextButton( + text = stringResource(R.string.validate), + isLoading = state.isLoading, + onClick = { viewModel.updateFeedDialogValidate() }, + ) + } +} diff --git a/app/src/main/java/com/readrops/app/feedsfolders/ManageFeedsFoldersActivity.java b/app/src/main/java/com/readrops/app/feedsfolders/ManageFeedsFoldersActivity.java deleted file mode 100644 index 741b0196..00000000 --- a/app/src/main/java/com/readrops/app/feedsfolders/ManageFeedsFoldersActivity.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.readrops.app.feedsfolders; - -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuItem; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentPagerAdapter; - -import com.afollestad.materialdialogs.MaterialDialog; -import com.readrops.api.utils.exceptions.ConflictException; -import com.readrops.api.utils.exceptions.UnknownFormatException; -import com.readrops.app.R; -import com.readrops.app.databinding.ActivityManageFeedsFoldersBinding; -import com.readrops.app.feedsfolders.feeds.FeedsFragment; -import com.readrops.app.feedsfolders.folders.FoldersFragment; -import com.readrops.app.utils.Utils; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.account.Account; - -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; - -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT; - -import org.koin.android.compat.ViewModelCompat; - -public class ManageFeedsFoldersActivity extends AppCompatActivity { - - private ActivityManageFeedsFoldersBinding binding; - private ManageFeedsFoldersViewModel viewModel; - - private Account account; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - binding = ActivityManageFeedsFoldersBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - setSupportActionBar(binding.manageFeedsFoldersToolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - - account = getIntent().getParcelableExtra(ACCOUNT); - - FeedsFoldersPageAdapter pageAdapter = new FeedsFoldersPageAdapter(getSupportFragmentManager()); - - binding.manageFeedsFoldersViewpager.setAdapter(pageAdapter); - binding.manageFeedsFoldersTablayout.setupWithViewPager(binding.manageFeedsFoldersViewpager); - - viewModel = ViewModelCompat.getViewModel(this, ManageFeedsFoldersViewModel.class); - viewModel.setAccount(account); - } - - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - if (account.getAccountType().getAccountConfig().isFolderCreation()) - getMenuInflater().inflate(R.menu.feeds_menu, menu); - - return super.onCreateOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - return true; - case R.id.add_folder: - addFolder(); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - @Override - public void onBackPressed() { - finish(); - super.onBackPressed(); - } - - private void addFolder() { - new MaterialDialog.Builder(ManageFeedsFoldersActivity.this) - .title(R.string.add_folder) - .positiveText(R.string.validate) - .input(R.string.folder, 0, (dialog, input) -> { - Folder folder = new Folder(); - folder.setName(input.toString()); - folder.setAccountId(account.getId()); - - viewModel.addFolder(folder) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(throwable -> { - String message; - if (throwable instanceof ConflictException) - message = getString(R.string.folder_already_exists); - else if (throwable instanceof UnknownFormatException) - message = getString(R.string.folder_bad_format); - else - message = getString(R.string.error_occured); - - Utils.showSnackbar(binding.manageFeedsFoldersRoot, message); - }) - .subscribe(); - }) - .show(); - } - - public class FeedsFoldersPageAdapter extends FragmentPagerAdapter { - - private FeedsFoldersPageAdapter(FragmentManager fragmentManager) { - super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); - } - - @Override - public int getCount() { - return 2; - } - - @Nullable - @Override - public CharSequence getPageTitle(int position) { - switch (position) { - case 0: - return getApplicationContext().getString(R.string.feeds); - case 1: - return getApplicationContext().getString(R.string.folders); - default: - return null; - } - } - - @Override - public Fragment getItem(int position) { - Fragment fragment = null; - - switch (position) { - case 0: - fragment = FeedsFragment.newInstance(account); - break; - case 1: - fragment = FoldersFragment.newInstance(account); - break; - } - - return fragment; - } - } -} diff --git a/app/src/main/java/com/readrops/app/feedsfolders/ManageFeedsFoldersViewModel.java b/app/src/main/java/com/readrops/app/feedsfolders/ManageFeedsFoldersViewModel.java deleted file mode 100644 index 344de635..00000000 --- a/app/src/main/java/com/readrops/app/feedsfolders/ManageFeedsFoldersViewModel.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.readrops.app.feedsfolders; - -import androidx.annotation.NonNull; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.ViewModel; - -import com.readrops.app.repositories.ARepository; -import com.readrops.db.Database; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.account.Account; -import com.readrops.db.pojo.FeedWithFolder; -import com.readrops.db.pojo.FolderWithFeedCount; - -import org.koin.core.parameter.ParametersHolderKt; -import org.koin.java.KoinJavaComponent; - -import java.util.List; - -import io.reactivex.Completable; -import io.reactivex.Single; - -public class ManageFeedsFoldersViewModel extends ViewModel { - - private final Database database; - private LiveData> feedsWithFolder; - private LiveData> folders; - private ARepository repository; - - private Account account; - - public ManageFeedsFoldersViewModel(@NonNull Database database) { - this.database = database; - } - - private void setup() { - repository = KoinJavaComponent.get(ARepository.class, null, - () -> ParametersHolderKt.parametersOf(account)); - - feedsWithFolder = database.feedDao().getAllFeedsWithFolder(account.getId()); - folders = database.folderDao().getAllFolders(account.getId()); - } - - public LiveData> getFeedsWithFolder() { - return feedsWithFolder; - } - - public Completable updateFeedWithFolder(Feed feed) { - return repository.updateFeed(feed); - } - - public Account getAccount() { - return account; - } - - public void setAccount(Account account) { - this.account = account; - setup(); - } - - public LiveData> getFolders() { - return folders; - } - - public LiveData> getFoldersWithFeedCount() { - return database.folderDao().getFoldersWithFeedCount(account.getId()); - } - - public Single addFolder(Folder folder) { - return repository.addFolder(folder); - } - - public Completable updateFolder(Folder folder) { - return repository.updateFolder(folder); - } - - public Completable deleteFolder(Folder folder) { - return repository.deleteFolder(folder); - } - - public Completable deleteFeed(Feed feed) { - return repository.deleteFeed(feed); - } - - public Single getFeedCountByAccount() { - return database.feedDao().getFeedCount(account.getId()); - } -} diff --git a/app/src/main/java/com/readrops/app/feedsfolders/feeds/EditFeedDialogFragment.java b/app/src/main/java/com/readrops/app/feedsfolders/feeds/EditFeedDialogFragment.java deleted file mode 100644 index 9f8c64d5..00000000 --- a/app/src/main/java/com/readrops/app/feedsfolders/feeds/EditFeedDialogFragment.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.readrops.app.feedsfolders.feeds; - -import android.app.AlertDialog; -import android.app.Dialog; -import android.os.Bundle; -import android.view.View; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.Spinner; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.DialogFragment; - -import com.google.android.material.textfield.TextInputEditText; -import com.readrops.app.R; -import com.readrops.app.feedsfolders.ManageFeedsFoldersViewModel; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.account.Account; -import com.readrops.db.pojo.FeedWithFolder; - -import java.util.ArrayList; -import java.util.Map; -import java.util.TreeMap; - -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; - -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT; - -import org.koin.android.compat.SharedViewModelCompat; - -public class EditFeedDialogFragment extends DialogFragment implements AdapterView.OnItemSelectedListener { - - private TextInputEditText feedName; - private TextInputEditText feedUrl; - private Spinner folder; - - private Map values; - - private FeedWithFolder feedWithFolder; - private Account account; - private ManageFeedsFoldersViewModel viewModel; - - public EditFeedDialogFragment() { - } - - public static EditFeedDialogFragment newInstance(FeedWithFolder feedWithFolder, Account account) { - Bundle args = new Bundle(); - args.putParcelable("feedWithFolder", feedWithFolder); - args.putParcelable(ACCOUNT, account); - - EditFeedDialogFragment fragment = new EditFeedDialogFragment(); - fragment.setArguments(args); - return fragment; - } - - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - viewModel = SharedViewModelCompat.getSharedViewModel(this, ManageFeedsFoldersViewModel.class); - - feedWithFolder = getArguments().getParcelable("feedWithFolder"); - account = getArguments().getParcelable(ACCOUNT); - - viewModel.setAccount(account); - - View v = getActivity().getLayoutInflater().inflate(R.layout.edit_feed_layout, null); - - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()) - .setTitle(R.string.edit_feed) - .setPositiveButton(R.string.validate, (dialog, which) -> { - Feed feed = feedWithFolder.getFeed(); - feed.setName(feedName.getText().toString().trim()); - feed.setUrl(feedUrl.getText().toString().trim()); - - viewModel.updateFeedWithFolder(feed) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(); - }); - - builder.setView(v); - fillData(v); - - viewModel.getFolders().observe(this, folders -> { - values = new TreeMap<>(String::compareTo); - - if (!account.getAccountType().getAccountConfig().isNoFolderCase()) - values.put(getString(R.string.no_folder), 0); - - for (Folder folder : folders) { - values.put(folder.getName(), folder.getId()); - } - - ArrayAdapter adapter = new ArrayAdapter<>(getActivity(), - android.R.layout.simple_spinner_dropdown_item, new ArrayList<>(values.keySet())); - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - - folder.setAdapter(adapter); - folder.setOnItemSelectedListener(this); - - if (feedWithFolder.getFolder() != null) - folder.setSelection(adapter.getPosition(feedWithFolder.getFolder().getName())); - else - folder.setSelection(adapter.getPosition(getString(R.string.no_folder))); - }); - - return builder.create(); - } - - private void fillData(View v) { - feedName = v.findViewById(R.id.edit_feed_name_edit_text); - feedUrl = v.findViewById(R.id.edit_feed_url_edit_text); - folder = v.findViewById(R.id.edit_feed_folder_spinner); - - if (!account.getAccountType().getAccountConfig().isFeedUrlEditable()) - feedUrl.setEnabled(false); - - feedName.setText(feedWithFolder.getFeed().getName()); - feedUrl.setText(feedWithFolder.getFeed().getUrl()); - } - - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - String folderName = (String) parent.getAdapter().getItem(position); - int folderId = values.get(folderName); - - feedWithFolder.getFeed().setFolderId(folderId == 0 ? null : folderId); - } - - @Override - public void onNothingSelected(AdapterView parent) { - - } -} diff --git a/app/src/main/java/com/readrops/app/feedsfolders/feeds/FeedOptionsDialogFragment.kt b/app/src/main/java/com/readrops/app/feedsfolders/feeds/FeedOptionsDialogFragment.kt deleted file mode 100644 index eea856e6..00000000 --- a/app/src/main/java/com/readrops/app/feedsfolders/feeds/FeedOptionsDialogFragment.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.readrops.app.feedsfolders.feeds - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.readrops.app.databinding.FeedOptionsLayoutBinding -import com.readrops.app.utils.ReadropsKeys.ACCOUNT -import com.readrops.db.entities.account.Account -import com.readrops.db.pojo.FeedWithFolder - -class FeedOptionsDialogFragment : BottomSheetDialogFragment() { - - private lateinit var feedWithFolder: FeedWithFolder - private lateinit var account: Account - - private var _binding: FeedOptionsLayoutBinding? = null - private val binding get() = _binding!! - - companion object { - const val FEED_KEY = "FEED_KEY" - - fun newInstance(feedWithFolder: FeedWithFolder, account: Account): FeedOptionsDialogFragment { - val bundle = Bundle() - bundle.putParcelable(FEED_KEY, feedWithFolder) - bundle.putParcelable(ACCOUNT, account) - - val feedsOptionsDialogFragment = FeedOptionsDialogFragment() - feedsOptionsDialogFragment.arguments = bundle - - return feedsOptionsDialogFragment - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - feedWithFolder = arguments?.getParcelable(FEED_KEY)!! - account = arguments?.getParcelable(ACCOUNT)!! - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - _binding = FeedOptionsLayoutBinding.inflate(inflater, container, false) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.feedOptionsTitle.text = feedWithFolder.feed.name - - binding.feedOptionsEditLayout.setOnClickListener { openEditFeedDialog() } - binding.feedOptionsOpenRootLayout.setOnClickListener { openFeedRootUrl() } - binding.feedOptionsDeleteLayout.setOnClickListener { deleteFeed() } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun openEditFeedDialog() { - dismiss() - val editFeedDialogFragment = EditFeedDialogFragment.newInstance(feedWithFolder, account) - - activity - ?.supportFragmentManager - ?.beginTransaction() - ?.add(editFeedDialogFragment, "") - ?.commit() - } - - private fun openFeedRootUrl() { - dismiss() - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(feedWithFolder.feed.siteUrl))) - } - - private fun deleteFeed() { - dismiss() - (parentFragment as FeedsFragment).deleteFeed(feedWithFolder.feed) - } - -} diff --git a/app/src/main/java/com/readrops/app/feedsfolders/feeds/FeedsAdapter.java b/app/src/main/java/com/readrops/app/feedsfolders/feeds/FeedsAdapter.java deleted file mode 100644 index 597a7c8f..00000000 --- a/app/src/main/java/com/readrops/app/feedsfolders/feeds/FeedsAdapter.java +++ /dev/null @@ -1,130 +0,0 @@ -package com.readrops.app.feedsfolders.feeds; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.ListAdapter; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.readrops.app.R; -import com.readrops.app.databinding.FeedLayoutBinding; -import com.readrops.app.utils.GlideRequests; -import com.readrops.db.pojo.FeedWithFolder; - -import org.koin.java.KoinJavaComponent; - -import java.util.List; - -public class FeedsAdapter extends ListAdapter { - - private ManageFeedsListener listener; - - private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { - @Override - public boolean areItemsTheSame(@NonNull FeedWithFolder feedWithFolder, @NonNull FeedWithFolder t1) { - return feedWithFolder.getFeed().getId() == t1.getFeed().getId(); - } - - @Override - public boolean areContentsTheSame(@NonNull FeedWithFolder feedWithFolder, @NonNull FeedWithFolder t1) { - boolean folder = false; - if (feedWithFolder.getFolder() != null && t1.getFolder() != null) - folder = feedWithFolder.getFolder().getName().equals(t1.getFolder().getName()); - - return feedWithFolder.getFeed().getName().equals(t1.getFeed().getName()) - && folder; - } - - @Nullable - @Override - public Object getChangePayload(@NonNull FeedWithFolder oldItem, @NonNull FeedWithFolder newItem) { - return newItem; - } - }; - - public FeedsAdapter(ManageFeedsListener listener) { - super(DIFF_CALLBACK); - - this.listener = listener; - } - - @NonNull - @Override - public FeedViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { - FeedLayoutBinding binding = FeedLayoutBinding.inflate(LayoutInflater.from(viewGroup.getContext()), viewGroup, false); - - return new FeedViewHolder(binding); - } - - @Override - public void onBindViewHolder(@NonNull FeedViewHolder viewHolder, int i) { - FeedWithFolder feedWithFolder = getItem(i); - - if (feedWithFolder.getFeed().getIconUrl() != null) { - KoinJavaComponent.get(GlideRequests.class) - .load(feedWithFolder.getFeed().getIconUrl()) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .placeholder(R.drawable.ic_rss_feed_grey) - .into(viewHolder.binding.feedLayoutIcon); - } else - viewHolder.binding.feedLayoutIcon.setImageResource(R.drawable.ic_rss_feed_grey); - - viewHolder.binding.feedLayoutName.setText(feedWithFolder.getFeed().getName()); - if (feedWithFolder.getFeed().getDescription() != null) { - viewHolder.binding.feedLayoutDescription.setVisibility(View.VISIBLE); - viewHolder.binding.feedLayoutDescription.setText(feedWithFolder.getFeed().getDescription()); - } else - viewHolder.binding.feedLayoutDescription.setVisibility(View.GONE); - - if (feedWithFolder.getFolder() != null) - viewHolder.binding.feedLayoutFolder.setText(feedWithFolder.getFolder().getName()); - else - viewHolder.binding.feedLayoutFolder.setText(R.string.no_folder); - - viewHolder.itemView.setOnClickListener(v -> listener.onEdit(feedWithFolder)); - viewHolder.itemView.setOnLongClickListener(v -> { - listener.onOpenLink(feedWithFolder); - return true; - }); - } - - - @Override - public void onBindViewHolder(@NonNull FeedViewHolder holder, int position, @NonNull List payloads) { - if (!payloads.isEmpty()) { - FeedWithFolder feedWithFolder = (FeedWithFolder) payloads.get(0); - - holder.binding.feedLayoutName.setText(feedWithFolder.getFeed().getName()); - - if (feedWithFolder.getFolder() != null) - holder.binding.feedLayoutName.setText(feedWithFolder.getFolder().getName()); - else - holder.binding.feedLayoutName.setText(R.string.no_folder); - - } else - onBindViewHolder(holder, position); - } - - public interface ManageFeedsListener { - void onOpenLink(FeedWithFolder feedWithFolder); - - void onEdit(FeedWithFolder feedWithFolder); - } - - - protected class FeedViewHolder extends RecyclerView.ViewHolder { - - private FeedLayoutBinding binding; - - public FeedViewHolder(FeedLayoutBinding binding) { - super(binding.getRoot()); - - this.binding = binding; - } - } -} diff --git a/app/src/main/java/com/readrops/app/feedsfolders/feeds/FeedsFragment.java b/app/src/main/java/com/readrops/app/feedsfolders/feeds/FeedsFragment.java deleted file mode 100644 index d498f813..00000000 --- a/app/src/main/java/com/readrops/app/feedsfolders/feeds/FeedsFragment.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.readrops.app.feedsfolders.feeds; - - -import android.content.res.Resources; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; - -import com.afollestad.materialdialogs.MaterialDialog; -import com.readrops.app.R; -import com.readrops.app.databinding.FragmentFeedsBinding; -import com.readrops.app.utils.SharedPreferencesManager; -import com.readrops.app.utils.Utils; -import com.readrops.app.feedsfolders.ManageFeedsFoldersViewModel; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.account.Account; -import com.readrops.db.pojo.FeedWithFolder; - -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.observers.DisposableCompletableObserver; -import io.reactivex.schedulers.Schedulers; - -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT; - -import org.koin.android.compat.SharedViewModelCompat; - - -public class FeedsFragment extends Fragment { - - private FeedsAdapter adapter; - private ManageFeedsFoldersViewModel viewModel; - private Account account; - - private FragmentFeedsBinding binding; - - public FeedsFragment() { - // Required empty public constructor - } - - public static FeedsFragment newInstance(Account account) { - FeedsFragment fragment = new FeedsFragment(); - Bundle args = new Bundle(); - - args.putParcelable(ACCOUNT, account); - fragment.setArguments(args); - - return fragment; - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - account = getArguments().getParcelable(ACCOUNT); - - if (account.getLogin() == null) - account.setLogin(SharedPreferencesManager.readString(account.getLoginKey())); - if (account.getPassword() == null) - account.setPassword(SharedPreferencesManager.readString(account.getPasswordKey())); - - viewModel = SharedViewModelCompat.getSharedViewModel(this, ManageFeedsFoldersViewModel.class); - viewModel.setAccount(account); - - viewModel.getFeedsWithFolder().observe(this, feedWithFolders -> { - adapter.submitList(feedWithFolders); - - if (feedWithFolders.size() > 0) { - binding.feedsEmptyList.setVisibility(View.GONE); - } else { - binding.feedsEmptyList.setVisibility(View.VISIBLE); - } - }); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - binding = FragmentFeedsBinding.inflate(inflater, container, false); - - return binding.getRoot(); - } - - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - binding.feedsRecyclerview.setLayoutManager(new LinearLayoutManager(getActivity())); - - adapter = new FeedsAdapter(new FeedsAdapter.ManageFeedsListener() { - @Override - public void onEdit(FeedWithFolder feedWithFolder) { - openFeedOptionsFragment(feedWithFolder); - } - - @Override - public void onOpenLink(FeedWithFolder feedWithFolder) { - } - }); - - binding.feedsRecyclerview.setAdapter(adapter); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - public void deleteFeed(Feed feed) { - new MaterialDialog.Builder(getContext()) - .title(R.string.delete_feed) - .positiveText(R.string.validate) - .negativeText(R.string.cancel) - .onPositive((dialog, which) -> viewModel.deleteFeed(feed) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new DisposableCompletableObserver() { - @Override - public void onComplete() { - Utils.showSnackbar(binding.feedsRoot, - getString(R.string.feed_deleted, feed.getName())); - } - - @Override - public void onError(Throwable e) { - String message; - if (e instanceof Resources.NotFoundException) - message = getString(R.string.feed_doesnt_exist, feed.getName()); - else - message = getString(R.string.error_occured); - - Utils.showSnackbar(binding.feedsRoot, message); - } - })) - .show(); - } - - private void openFeedOptionsFragment(FeedWithFolder feedWithFolder) { - FeedOptionsDialogFragment dialogFragment = FeedOptionsDialogFragment.Companion.newInstance(feedWithFolder, account); - - getChildFragmentManager() - .beginTransaction() - .add(dialogFragment, "") - .commit(); - } -} diff --git a/app/src/main/java/com/readrops/app/feedsfolders/folders/FolderOptionsDialogFragment.kt b/app/src/main/java/com/readrops/app/feedsfolders/folders/FolderOptionsDialogFragment.kt deleted file mode 100644 index 853e6ce5..00000000 --- a/app/src/main/java/com/readrops/app/feedsfolders/folders/FolderOptionsDialogFragment.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.readrops.app.feedsfolders.folders - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.readrops.app.databinding.FolderOptionsLayoutBinding -import com.readrops.db.entities.Folder - -class FolderOptionsDialogFragment : BottomSheetDialogFragment() { - - private lateinit var folder: Folder - - private var _binding: FolderOptionsLayoutBinding? = null - private val binding get() = _binding!! - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - folder = arguments?.getParcelable(FOLDER_KEY)!! - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - _binding = FolderOptionsLayoutBinding.inflate(inflater, container, false) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.folderOptionsTitle.text = folder.name - binding.folderOptionsEdit.setOnClickListener { openEditFolderDialog() } - binding.folderOptionsDelete.setOnClickListener { deleteFolder() } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun openEditFolderDialog() { - dismiss() - (parentFragment as FoldersFragment).editFolder(folder) - } - - private fun deleteFolder() { - dismiss() - (parentFragment as FoldersFragment).deleteFolder(folder) - } - - companion object { - const val FOLDER_KEY = "FOLDER_KEY" - - fun newInstance(folder: Folder): FolderOptionsDialogFragment { - val args = Bundle() - args.putParcelable(FOLDER_KEY, folder) - - val fragment = FolderOptionsDialogFragment() - fragment.arguments = args - - return fragment - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/feedsfolders/folders/FoldersAdapter.java b/app/src/main/java/com/readrops/app/feedsfolders/folders/FoldersAdapter.java deleted file mode 100644 index 85266e92..00000000 --- a/app/src/main/java/com/readrops/app/feedsfolders/folders/FoldersAdapter.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.readrops.app.feedsfolders.folders; - -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.ListAdapter; -import androidx.recyclerview.widget.RecyclerView; - -import com.readrops.app.R; -import com.readrops.app.databinding.FolderLayoutBinding; -import com.readrops.db.entities.Folder; -import com.readrops.db.pojo.FolderWithFeedCount; - -import java.util.List; - -public class FoldersAdapter extends ListAdapter { - - private ManageFoldersListener listener; - private int totalFeedCount; - - public FoldersAdapter(ManageFoldersListener listener) { - super(DIFF_CALLBACK); - - this.listener = listener; - } - - public void setTotalFeedCount(int totalFeedCount) { - this.totalFeedCount = totalFeedCount; - } - - private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { - @Override - public boolean areItemsTheSame(@NonNull FolderWithFeedCount oldItem, @NonNull FolderWithFeedCount newItem) { - return oldItem.getFolder().getId() == newItem.getFolder().getId(); - } - - @Override - public boolean areContentsTheSame(@NonNull FolderWithFeedCount oldItem, @NonNull FolderWithFeedCount newItem) { - return TextUtils.equals(oldItem.getFolder().getName(), newItem.getFolder().getName()) && - oldItem.getFeedCount() == newItem.getFeedCount(); - } - - @Nullable - @Override - public Object getChangePayload(@NonNull FolderWithFeedCount oldItem, @NonNull FolderWithFeedCount newItem) { - return newItem; - } - }; - - @NonNull - @Override - public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - FolderLayoutBinding binding = FolderLayoutBinding.inflate(LayoutInflater.from(parent.getContext()), - parent, false); - - return new FolderViewHolder(binding); - } - - @Override - public void onBindViewHolder(@NonNull FolderViewHolder holder, int position, @NonNull List payloads) { - if (!payloads.isEmpty()) { - FolderWithFeedCount folderWithFeedCount = (FolderWithFeedCount) payloads.get(0); - - holder.bind(folderWithFeedCount); - } else - onBindViewHolder(holder, position); - - } - - @Override - public void onBindViewHolder(@NonNull FolderViewHolder holder, int position) { - FolderWithFeedCount folderWithFeedCount = getItem(position); - - holder.bind(folderWithFeedCount); - holder.itemView.setOnClickListener(v -> listener.onClick(folderWithFeedCount.getFolder())); - } - - public interface ManageFoldersListener { - void onClick(Folder folder); - } - - public class FolderViewHolder extends RecyclerView.ViewHolder { - - private FolderLayoutBinding binding; - - public FolderViewHolder(FolderLayoutBinding binding) { - super(binding.getRoot()); - - this.binding = binding; - } - - private void bind(FolderWithFeedCount folderWithFeedCount) { - binding.folderName.setText(folderWithFeedCount.getFolder().getName()); - - int stringRes = folderWithFeedCount.getFeedCount() > 1 ? R.string.feeds_number : R.string.feed_number; - binding.folderFeedsCount.setText(itemView.getContext().getString(stringRes, String.valueOf(folderWithFeedCount.getFeedCount()))); - - binding.folderProgressBar.setMax(totalFeedCount); - binding.folderProgressBar.setProgress(folderWithFeedCount.getFeedCount()); - } - } -} diff --git a/app/src/main/java/com/readrops/app/feedsfolders/folders/FoldersFragment.java b/app/src/main/java/com/readrops/app/feedsfolders/folders/FoldersFragment.java deleted file mode 100644 index d8d64ab0..00000000 --- a/app/src/main/java/com/readrops/app/feedsfolders/folders/FoldersFragment.java +++ /dev/null @@ -1,174 +0,0 @@ -package com.readrops.app.feedsfolders.folders; - - -import android.content.res.Resources; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; - -import com.afollestad.materialdialogs.MaterialDialog; -import com.readrops.api.utils.exceptions.ConflictException; -import com.readrops.api.utils.exceptions.UnknownFormatException; -import com.readrops.app.R; -import com.readrops.app.databinding.FragmentFoldersBinding; -import com.readrops.app.feedsfolders.ManageFeedsFoldersViewModel; -import com.readrops.app.utils.SharedPreferencesManager; -import com.readrops.app.utils.Utils; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.account.Account; - -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.observers.DisposableSingleObserver; -import io.reactivex.schedulers.Schedulers; - -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT; - -import org.koin.android.compat.SharedViewModelCompat; - -public class FoldersFragment extends Fragment { - - private FoldersAdapter adapter; - private FragmentFoldersBinding binding; - private ManageFeedsFoldersViewModel viewModel; - - private Account account; - - public FoldersFragment() { - // Required empty public constructor - } - - public static FoldersFragment newInstance(Account account) { - FoldersFragment fragment = new FoldersFragment(); - - Bundle args = new Bundle(); - args.putParcelable(ACCOUNT, account); - fragment.setArguments(args); - - return fragment; - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - account = getArguments().getParcelable(ACCOUNT); - - if (account.getLogin() == null) - account.setLogin(SharedPreferencesManager.readString(account.getLoginKey())); - if (account.getPassword() == null) - account.setPassword(SharedPreferencesManager.readString(account.getPasswordKey())); - - adapter = new FoldersAdapter(this::openFolderOptionsDialog); - viewModel = SharedViewModelCompat.getSharedViewModel(this, ManageFeedsFoldersViewModel.class); - - viewModel.setAccount(account); - viewModel.getFeedCountByAccount() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new DisposableSingleObserver() { - @Override - public void onSuccess(Integer feedCount) { - adapter.setTotalFeedCount(feedCount); - getFoldersWithFeedCount(); - } - - @Override - public void onError(Throwable e) { - Utils.showSnackbar(binding.foldersRoot, e.getMessage()); - } - }); - } - - private void getFoldersWithFeedCount() { - viewModel.getFoldersWithFeedCount().observe(this, folders -> { - adapter.submitList(folders); - - if (!folders.isEmpty()) { - binding.foldersEmptyList.setVisibility(View.GONE); - } else { - binding.foldersEmptyList.setVisibility(View.VISIBLE); - } - }); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - binding = FragmentFoldersBinding.inflate(inflater, container, false); - - return binding.getRoot(); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - binding.foldersList.setLayoutManager(new LinearLayoutManager(getContext())); - binding.foldersList.setAdapter(adapter); - } - - public void editFolder(Folder folder) { - new MaterialDialog.Builder(getActivity()) - .title(R.string.edit_folder) - .positiveText(R.string.validate) - .input(getString(R.string.folder), folder.getName(), false, (dialog, input) -> { - folder.setName(input.toString()); - - viewModel.updateFolder(folder) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(throwable -> { - String message; - if (throwable instanceof ConflictException) - message = getString(R.string.folder_already_exists); - else if (throwable instanceof UnknownFormatException) - message = getString(R.string.folder_bad_format); - else if (throwable instanceof Resources.NotFoundException) - message = getString(R.string.folder_doesnt_exist); - else - message = getString(R.string.error_occured); - - Utils.showSnackbar(binding.foldersRoot, message); - }) - .subscribe(); - }) - .show(); - } - - public void deleteFolder(Folder folder) { - new MaterialDialog.Builder(getActivity()) - .title(R.string.delete_folder) - .negativeText(R.string.cancel) - .positiveText(R.string.validate) - .onPositive((dialog, which) -> viewModel.deleteFolder(folder) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(throwable -> { - String message; - if (throwable instanceof Resources.NotFoundException) - message = getString(R.string.folder_doesnt_exist); - else - message = throwable.getMessage(); - - Utils.showSnackbar(binding.foldersRoot, message); - }) - .subscribe()) - .show(); - } - - private void openFolderOptionsDialog(Folder folder) { - FolderOptionsDialogFragment fragment = FolderOptionsDialogFragment.Companion.newInstance(folder); - - getChildFragmentManager() - .beginTransaction() - .add(fragment, "") - .commit(); - } -} - diff --git a/app/src/main/java/com/readrops/app/home/HomeScreen.kt b/app/src/main/java/com/readrops/app/home/HomeScreen.kt new file mode 100644 index 00000000..df6cf2fd --- /dev/null +++ b/app/src/main/java/com/readrops/app/home/HomeScreen.kt @@ -0,0 +1,153 @@ +package com.readrops.app.home + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cafe.adriel.voyager.navigator.tab.CurrentTab +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabNavigator +import com.readrops.app.R +import com.readrops.app.account.AccountTab +import com.readrops.app.feeds.FeedTab +import com.readrops.app.item.ItemScreen +import com.readrops.app.more.MoreTab +import com.readrops.app.timelime.TimelineTab +import com.readrops.app.util.components.AndroidScreen +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +object HomeScreen : AndroidScreen() { + + private val itemChannel = Channel() + private val tabChannel = Channel() + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val scaffoldInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) + + LaunchedEffect(Unit) { + itemChannel.receiveAsFlow() + .collect { + navigator.push(ItemScreen(it)) + } + } + + TabNavigator( + tab = TimelineTab + ) { tabNavigator -> + CompositionLocalProvider(LocalNavigator provides navigator) { + Scaffold( + bottomBar = { + BottomAppBar { + NavigationBarItem( + selected = tabNavigator.current.key == TimelineTab.key, + onClick = { tabNavigator.current = TimelineTab }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_timeline), + contentDescription = null + ) + }, + label = { Text(stringResource(id = R.string.timeline)) } + ) + + NavigationBarItem( + selected = tabNavigator.current.key == FeedTab.key, + onClick = { tabNavigator.current = FeedTab }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_rss_feed_grey), + contentDescription = null + ) + }, + label = { Text(text = stringResource(R.string.feeds)) } + ) + + NavigationBarItem( + selected = tabNavigator.current.key == AccountTab.key, + onClick = { tabNavigator.current = AccountTab }, + icon = { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = null, + ) + }, + label = { Text(text = stringResource(R.string.account)) } + ) + + NavigationBarItem( + selected = tabNavigator.current.key == MoreTab.key, + onClick = { tabNavigator.current = MoreTab }, + icon = { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null, + ) + }, + label = { Text(stringResource(id = R.string.more)) } + ) + } + }, + contentWindowInsets = scaffoldInsets + ) { paddingValues -> + LaunchedEffect(Unit) { + tabChannel.receiveAsFlow() + .collect { + tabNavigator.current = it + } + } + + BackHandler( + enabled = tabNavigator.current != TimelineTab, + onBack = { tabNavigator.current = TimelineTab } + ) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + CurrentTab() + } + } + } + } + } + + suspend fun openItemScreen(itemId: Int) { + itemChannel.send(itemId) + } + + suspend fun openTab(tab: Tab) { + tabChannel.send(tab) + } + + suspend fun openAddFeedDialog(url: String) { + tabChannel.send(FeedTab) + FeedTab.openAddFeedDialog(url) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/item/ItemActivity.java b/app/src/main/java/com/readrops/app/item/ItemActivity.java deleted file mode 100644 index de201cab..00000000 --- a/app/src/main/java/com/readrops/app/item/ItemActivity.java +++ /dev/null @@ -1,429 +0,0 @@ -package com.readrops.app.item; - -import android.Manifest; -import android.app.DownloadManager; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.res.ColorStateList; -import android.content.res.TypedArray; -import android.graphics.Bitmap; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.os.Bundle; -import android.os.Environment; -import android.provider.Settings; -import android.util.Log; -import android.view.ContextMenu; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.webkit.WebView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.browser.customtabs.CustomTabsIntent; -import androidx.core.app.ActivityCompat; -import androidx.core.app.ShareCompat; - -import com.afollestad.materialdialogs.MaterialDialog; -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.request.target.CustomTarget; -import com.bumptech.glide.request.transition.Transition; -import com.readrops.api.utils.DateUtils; -import com.readrops.app.R; -import com.readrops.app.databinding.ActivityItemBinding; -import com.readrops.app.utils.GlideRequests; -import com.readrops.app.utils.PermissionManager; -import com.readrops.app.utils.SharedPreferencesManager; -import com.readrops.app.utils.Utils; -import com.readrops.db.entities.Item; -import com.readrops.db.entities.account.Account; -import com.readrops.db.pojo.ItemWithFeed; - -import org.koin.android.compat.ViewModelCompat; -import org.koin.java.KoinJavaComponent; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; - -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT; -import static com.readrops.app.utils.ReadropsKeys.ACTION_BAR_COLOR; -import static com.readrops.app.utils.ReadropsKeys.IMAGE_URL; -import static com.readrops.app.utils.ReadropsKeys.ITEM_ID; -import static com.readrops.app.utils.ReadropsKeys.WEB_URL; - -public class ItemActivity extends AppCompatActivity { - - private static final String TAG = ItemActivity.class.getSimpleName(); - private static final int WRITE_EXTERNAL_STORAGE_REQUEST = 1; - - private ActivityItemBinding binding; - private ItemViewModel viewModel; - - private ItemWithFeed itemWithFeed; - - private boolean appBarCollapsed; - - private String urlToDownload; - private String imageTitle; - - private boolean uiBinded; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - binding = ActivityItemBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - Intent intent = getIntent(); - int itemId = intent.getIntExtra(ITEM_ID, 0); - String imageUrl = intent.getStringExtra(IMAGE_URL); - Account account = intent.getParcelableExtra(ACCOUNT); - - setSupportActionBar(binding.collapsingLayoutToolbar); - - if (getSupportActionBar() != null) { - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - } - - registerForContextMenu(binding.itemWebview); - - if (imageUrl == null) { - getSupportActionBar().setDisplayShowTitleEnabled(false); - binding.collapsingLayout.setTitleEnabled(false); - binding.collapsingLayoutScrim.setVisibility(View.GONE); - } else { - binding.appBarLayout.setExpanded(true); - binding.collapsingLayout.setTitleEnabled(true); - - KoinJavaComponent.get(GlideRequests.class) - .load(imageUrl) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .into(binding.collapsingLayoutImage); - } - - final TypedArray styledAttributes = getTheme().obtainStyledAttributes( - new int[]{android.R.attr.actionBarSize}); - int actionBarSize = (int) styledAttributes.getDimension(0, 0); - styledAttributes.recycle(); - - binding.appBarLayout.addOnOffsetChangedListener(((appBarLayout1, i) -> { - appBarCollapsed = Math.abs(i) >= (binding.appBarLayout.getTotalScrollRange() - - actionBarSize - ((8 * binding.appBarLayout.getTotalScrollRange()) / 100)); - - invalidateOptionsMenu(); - })); - - viewModel = ViewModelCompat.getViewModel(this, ItemViewModel.class); - viewModel.setAccount(account); - viewModel.getItemById(itemId).observe(this, itemWithFeed1 -> { - if (!uiBinded) { - bindUI(itemWithFeed1); - uiBinded = true; - } - }); - - binding.activityItemFab.setOnClickListener(v -> openInNavigator()); - - binding.itemStarFab.setOnClickListener(v -> { - Item item = itemWithFeed.getItem(); - - if (item.isStarred()) { - binding.itemStarFab.setImageResource(R.drawable.ic_empty_star); - } else { - binding.itemStarFab.setImageResource(R.drawable.ic_star); - } - - item.setStarred(!item.isStarred()); - viewModel.setStarState(item) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(throwable -> Utils.showSnackbar(binding.itemRoot, throwable.getMessage())) - .subscribe(); - }); - - } - - private void bindUI(ItemWithFeed itemWithFeed) { - this.itemWithFeed = itemWithFeed; - Item item = itemWithFeed.getItem(); - - if (item.isStarred()) { - binding.itemStarFab.setImageResource(R.drawable.ic_star); - } - - binding.activityItemDate.setText(DateUtils.formattedDateTimeByLocal(item.getPubDate())); - - if (item.getImageLink() == null) - binding.collapsingLayoutToolbar.setTitle(itemWithFeed.getFeedName()); - else - binding.collapsingLayout.setTitle(itemWithFeed.getFeedName()); - - if (itemWithFeed.getFolder() != null) { - binding.collapsingLayoutToolbar.setSubtitle(itemWithFeed.getFolder().getName()); - } - - binding.activityItemTitle.setText(item.getTitle()); - - if (itemWithFeed.getBgColor() != 0) { - binding.activityItemTitle.setTextColor(itemWithFeed.getBgColor()); - Utils.setDrawableColor(binding.activityItemDateLayout.getBackground(), itemWithFeed.getBgColor()); - } else if (itemWithFeed.getColor() != 0) { - binding.activityItemTitle.setTextColor(itemWithFeed.getColor()); - Utils.setDrawableColor(binding.activityItemDateLayout.getBackground(), itemWithFeed.getColor()); - } - - if (item.getAuthor() != null && !item.getAuthor().isEmpty()) { - binding.activityItemAuthor.setText(getString(R.string.by_author, item.getAuthor())); - binding.activityItemAuthor.setVisibility(View.VISIBLE); - } - - if (item.getReadTime() > 0) { - int minutes = (int) Math.round(item.getReadTime()); - if (minutes < 1) - binding.activityItemReadtime.setText(getResources().getString(R.string.read_time_lower_than_1)); - else if (minutes > 1) - binding.activityItemReadtime.setText(getResources().getString(R.string.read_time, String.valueOf(minutes))); - else - binding.activityItemReadtime.setText(getResources().getString(R.string.read_time_one_minute)); - - binding.activityItemReadtimeLayout.setVisibility(View.VISIBLE); - } - - if (itemWithFeed.getBgColor() != 0) { - binding.collapsingLayout.setBackgroundColor(itemWithFeed.getBgColor()); - binding.collapsingLayout.setContentScrimColor(itemWithFeed.getBgColor()); - binding.collapsingLayout.setStatusBarScrimColor(itemWithFeed.getBgColor()); - - getWindow().setStatusBarColor(itemWithFeed.getBgColor()); - binding.activityItemFab.setBackgroundTintList(ColorStateList.valueOf(itemWithFeed.getBgColor())); - binding.itemStarFab.setBackgroundTintList(ColorStateList.valueOf(itemWithFeed.getBgColor())); - } else if (itemWithFeed.getColor() != 0) { - binding.collapsingLayout.setBackgroundColor(itemWithFeed.getColor()); - binding.collapsingLayout.setContentScrimColor(itemWithFeed.getColor()); - binding.collapsingLayout.setStatusBarScrimColor(itemWithFeed.getColor()); - - getWindow().setStatusBarColor(itemWithFeed.getColor()); - binding.activityItemFab.setBackgroundTintList(ColorStateList.valueOf(itemWithFeed.getColor())); - binding.itemStarFab.setBackgroundTintList(ColorStateList.valueOf(itemWithFeed.getColor())); - } - - binding.itemWebview.setItem(itemWithFeed); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.item_menu, menu); - return true; - } - - @Override - public boolean onPrepareOptionsMenu(Menu menu) { - MenuItem item = menu.findItem(R.id.item_open); - item.setVisible(appBarCollapsed); - - return super.onPrepareOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - return true; - case R.id.item_share: - shareArticle(); - return true; - case R.id.item_open: - openUrl(); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - @Override - public void onBackPressed() { - finish(); - super.onBackPressed(); - } - - private void openUrl() { - int value = Integer.parseInt(SharedPreferencesManager.readString( - SharedPreferencesManager.SharedPrefKey.OPEN_ITEMS_IN)); - switch (value) { - case 0: - openInNavigator(); - break; - case 1: - openInWebView(); - break; - default: - openInCustomTab(); - break; - } - } - - private void openInNavigator() { - Intent urlIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(itemWithFeed.getItem().getLink())); - startActivity(urlIntent); - } - - private void openInWebView() { - Intent intent = new Intent(this, WebViewActivity.class); - intent.putExtra(WEB_URL, itemWithFeed.getItem().getLink()); - intent.putExtra(ACTION_BAR_COLOR, itemWithFeed.getBgColor() != 0 ? itemWithFeed.getBgColor() : itemWithFeed.getColor()); - - startActivity(intent); - } - - private void openInCustomTab() { - boolean darkTheme = Boolean.parseBoolean(SharedPreferencesManager.readString(SharedPreferencesManager.SharedPrefKey.DARK_THEME)); - int color = itemWithFeed.getBgColor() != 0 ? itemWithFeed.getBgColor() : itemWithFeed.getColor(); - - CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() - .addDefaultShareMenuItem() - .setToolbarColor(color) - .setSecondaryToolbarColor(color) - .setColorScheme(darkTheme ? CustomTabsIntent.COLOR_SCHEME_DARK : CustomTabsIntent.COLOR_SCHEME_LIGHT) - .enableUrlBarHiding() - .setShowTitle(true) - .build(); - - customTabsIntent.launchUrl(this, Uri.parse(itemWithFeed.getItem().getLink())); - } - - private void shareArticle() { - Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setType("text/plain"); - shareIntent.putExtra(Intent.EXTRA_TEXT, itemWithFeed.getItem().getTitle() + " - " + itemWithFeed.getItem().getLink()); - startActivity(Intent.createChooser(shareIntent, getString(R.string.share_article))); - } - - @Override - public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { - WebView.HitTestResult hitTestResult = binding.itemWebview.getHitTestResult(); - - if (hitTestResult.getType() == WebView.HitTestResult.IMAGE_TYPE || - hitTestResult.getType() == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE) { - new MaterialDialog.Builder(this) - .title(R.string.image_options) - .items(R.array.image_options) - .itemsCallback((dialog, itemView, position, text) -> { - switch (position) { - case 0: - shareImage(hitTestResult.getExtra()); - break; - case 1: - if (PermissionManager.isPermissionGranted(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)) - downloadImage(hitTestResult.getExtra()); - else { - urlToDownload = hitTestResult.getExtra(); - PermissionManager.requestPermissions(this, WRITE_EXTERNAL_STORAGE_REQUEST, Manifest.permission.WRITE_EXTERNAL_STORAGE); - } - break; - case 2: - urlToDownload = hitTestResult.getExtra(); - String content = binding.itemWebview.getItemContent(); - - Pattern p = Pattern.compile("()"); - Matcher m = p.matcher(content); - if (m.matches()) { - Pattern p2 = Pattern.compile(""); - Matcher m2 = p2.matcher(content); - if (m2.matches()) { - imageTitle = m2.group(2); - } else { - imageTitle = ""; - } - } - new MaterialDialog.Builder(this) - .title(urlToDownload) - .content(imageTitle) - .show(); - break; - default: - throw new IllegalStateException("Unexpected value: " + position); - } - - }) - .show(); - } - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - if (requestCode == WRITE_EXTERNAL_STORAGE_REQUEST) { - if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { - downloadImage(urlToDownload); - } else { - if (ActivityCompat.shouldShowRequestPermissionRationale(this, permissions[0])) { - Utils.showSnackBarWithAction(binding.itemRoot, getString(R.string.download_image_permission), - getString(R.string.try_again), - v -> PermissionManager.requestPermissions(this, WRITE_EXTERNAL_STORAGE_REQUEST, - Manifest.permission.WRITE_EXTERNAL_STORAGE)); - } else { - Utils.showSnackBarWithAction(binding.itemRoot, getString(R.string.download_image_permission), - getString(R.string.permissions), v -> { - Intent intent = new Intent(); - intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - intent.setData(Uri.fromParts("package", getPackageName(), null)); - startActivity(intent); - }); - } - - } - } - - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - } - - private void downloadImage(String url) { - DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url)) - .setTitle(getString(R.string.download_image)) - .setMimeType("image/png") - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "image.png"); - - request.allowScanningByMediaScanner(); - - DownloadManager downloadManager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE); - downloadManager.enqueue(request); - } - - private void shareImage(String url) { - KoinJavaComponent.get(GlideRequests.class) - .asBitmap() - .diskCacheStrategy(DiskCacheStrategy.ALL) - .load(url) - .into(new CustomTarget() { - @Override - public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { - try { - Uri uri = viewModel.saveImageInCache(resource, ItemActivity.this); - Intent intent = ShareCompat.IntentBuilder.from(ItemActivity.this) - .setType("image/png") - .setStream(uri) - .setChooserTitle(R.string.share_image) - .createChooserIntent() - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - - startActivity(intent); - } catch (Exception e) { - Log.e(TAG, e.getMessage()); - } - } - - @Override - public void onLoadCleared(@Nullable Drawable placeholder) { - // not useful - } - }); - - } -} diff --git a/app/src/main/java/com/readrops/app/item/ItemImageDialog.kt b/app/src/main/java/com/readrops/app/item/ItemImageDialog.kt new file mode 100644 index 00000000..cc47f80c --- /dev/null +++ b/app/src/main/java/com/readrops/app/item/ItemImageDialog.kt @@ -0,0 +1,54 @@ +package com.readrops.app.item + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.readrops.app.R +import com.readrops.app.util.components.dialog.BaseDialog +import com.readrops.app.util.components.SelectableImageText +import com.readrops.app.util.theme.spacing + +enum class ItemImageChoice { + SHARE, + DOWNLOAD +} + +@Composable +fun ItemImageDialog( + onChoice: (ItemImageChoice) -> Unit, + onDismiss: () -> Unit +) { + BaseDialog( + title = stringResource(id = R.string.image_options), + icon = painterResource(id = R.drawable.ic_image), + onDismiss = onDismiss + ) { + Column { + SelectableImageText( + image = rememberVectorPainter(image = Icons.Default.Share), + text = stringResource(id = R.string.share_image), + style = MaterialTheme.typography.titleMedium, + spacing = MaterialTheme.spacing.shortSpacing, + padding = MaterialTheme.spacing.shortSpacing, + imageSize = 16.dp, + onClick = { onChoice(ItemImageChoice.SHARE) } + ) + + SelectableImageText( + image = painterResource(id = R.drawable.ic_download), + text = stringResource(id = R.string.download_image), + style = MaterialTheme.typography.titleMedium, + spacing = MaterialTheme.spacing.shortSpacing, + padding = MaterialTheme.spacing.shortSpacing, + imageSize = 16.dp, + onClick = { onChoice(ItemImageChoice.DOWNLOAD) } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/item/ItemScreen.kt b/app/src/main/java/com/readrops/app/item/ItemScreen.kt new file mode 100644 index 00000000..e96b40f4 --- /dev/null +++ b/app/src/main/java/com/readrops/app/item/ItemScreen.kt @@ -0,0 +1,366 @@ +package com.readrops.app.item + +import android.content.Intent +import android.net.Uri +import android.widget.RelativeLayout +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.net.toUri +import androidx.core.view.children +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import cafe.adriel.voyager.koin.getScreenModel +import coil.compose.AsyncImage +import com.readrops.app.R +import com.readrops.app.item.view.ItemNestedScrollView +import com.readrops.app.item.view.ItemWebView +import com.readrops.app.util.components.AndroidScreen +import com.readrops.app.util.components.CenteredProgressIndicator +import com.readrops.app.util.components.IconText +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.app.util.theme.spacing +import com.readrops.db.pojo.ItemWithFeed +import com.readrops.db.util.DateUtils +import org.koin.core.parameter.parametersOf +import kotlin.math.roundToInt + +class ItemScreen( + private val itemId: Int +) : AndroidScreen() { + + @Composable + override fun Content() { + val context = LocalContext.current + val density = LocalDensity.current + + val screenModel = + getScreenModel(parameters = { parametersOf(itemId) }) + val state by screenModel.state.collectAsStateWithLifecycle() + + val primaryColor = MaterialTheme.colorScheme.primary + val backgroundColor = MaterialTheme.colorScheme.background + val onBackgroundColor = MaterialTheme.colorScheme.onBackground + + val snackbarHostState = remember { SnackbarHostState() } + var isScrollable by remember { mutableStateOf(true) } + var refreshAndroidView by remember { mutableStateOf(true) } + + // https://developer.android.com/develop/ui/compose/touch-input/pointer-input/scroll#parent-compose-child-view + val bottomBarHeight = 64.dp + val bottomBarHeightPx = with(density) { bottomBarHeight.roundToPx().toFloat() } + val bottomBarOffsetHeightPx = remember { mutableFloatStateOf(0f) } + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.y + val newOffset = bottomBarOffsetHeightPx.floatValue + delta + bottomBarOffsetHeightPx.floatValue = newOffset.coerceIn(-bottomBarHeightPx, 0f) + + return Offset.Zero + } + } + } + + if (state.imageDialogUrl != null) { + ItemImageDialog( + onChoice = { + if (it == ItemImageChoice.SHARE) { + screenModel.shareImage(state.imageDialogUrl!!, context) + } else { + screenModel.downloadImage(state.imageDialogUrl!!, context) + + } + + screenModel.closeImageDialog() + }, + onDismiss = { screenModel.closeImageDialog() } + ) + } + + LaunchedEffect(state.fileDownloadedEvent) { + if (state.fileDownloadedEvent) { + snackbarHostState.showSnackbar("Downloaded file!") + } + } + + if (state.itemWithFeed != null) { + val itemWithFeed = state.itemWithFeed!! + val item = itemWithFeed.item + + val accentColor = if (itemWithFeed.color != 0) { + Color(itemWithFeed.color) + } else { + primaryColor + } + + fun openUrl(url: String) { + if (state.openInExternalBrowser) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + } else { + CustomTabsIntent.Builder() + .setDefaultColorSchemeParams( + CustomTabColorSchemeParams + .Builder() + .setToolbarColor(accentColor.toArgb()) + .build() + ) + .setShareState(CustomTabsIntent.SHARE_STATE_ON) + .setUrlBarHidingEnabled(true) + .build() + .launchUrl(context, url.toUri()) + } + } + + Scaffold( + modifier = Modifier + .nestedScroll(nestedScrollConnection), + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + ItemScreenBottomBar( + state = state.bottomBarState, + accentColor = accentColor, + modifier = Modifier + .navigationBarsPadding() + .height(bottomBarHeight) + .offset { + if (isScrollable) { + IntOffset( + x = 0, + y = -bottomBarOffsetHeightPx.floatValue.roundToInt() + ) + } else { + IntOffset(0, 0) + } + }, + onShare = { screenModel.shareItem(item, context) }, + onOpenUrl = { openUrl(item.link!!) }, + onChangeReadState = { + screenModel.setItemReadState(item.apply { isRead = it }) + }, + onChangeStarState = { + screenModel.setItemStarState(item.apply { isStarred = it }) + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier.padding(paddingValues) + ) { + AndroidView( + factory = { context -> + ItemNestedScrollView( + context = context, + onGlobalLayoutListener = { viewHeight, contentHeight -> + isScrollable = viewHeight - contentHeight < 0 + }, + onUrlClick = { url -> openUrl(url) }, + onImageLongPress = { url -> screenModel.openImageDialog(url) } + ) { + if (item.imageLink != null) { + BackgroundTitle(itemWithFeed = itemWithFeed) + } else { + val tintColor = if (itemWithFeed.color != 0) { + Color(itemWithFeed.color) + } else { + MaterialTheme.colorScheme.onBackground + } + + SimpleTitle( + itemWithFeed = itemWithFeed, + titleColor = tintColor, + accentColor = tintColor, + baseColor = MaterialTheme.colorScheme.onBackground, + bottomPadding = true + ) + } + } + }, + update = { nestedScrollView -> + if (refreshAndroidView) { + val relativeLayout = + (nestedScrollView.children.toList()[0] as RelativeLayout) + val webView = relativeLayout.children.toList()[1] as ItemWebView + + webView.loadText( + itemWithFeed = itemWithFeed, + accentColor = accentColor, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor + ) + + refreshAndroidView = false + } + } + ) + } + } + } else { + CenteredProgressIndicator() + } + } +} + +@Composable +fun BackgroundTitle( + itemWithFeed: ItemWithFeed, +) { + val onScrimColor = Color.White.copy(alpha = 0.85f) + val accentColor = if (itemWithFeed.color != 0) { + Color(itemWithFeed.color) + } else { + onScrimColor + } + + Surface( + shape = RoundedCornerShape( + bottomStart = 24.dp, + bottomEnd = 24.dp + ), + modifier = Modifier.height(IntrinsicSize.Max) + ) { + AsyncImage( + model = itemWithFeed.item.imageLink, + contentDescription = null, + contentScale = ContentScale.Crop, + error = painterResource(id = R.drawable.ic_broken_image), + modifier = Modifier + .fillMaxSize() + ) + + Surface( + color = Color.Black.copy(alpha = 0.6f), + modifier = Modifier + .fillMaxSize() + ) { + SimpleTitle( + itemWithFeed = itemWithFeed, + titleColor = onScrimColor, + accentColor = accentColor, + baseColor = onScrimColor, + bottomPadding = true + ) + } + } + + MediumSpacer() +} + +@Composable +fun SimpleTitle( + itemWithFeed: ItemWithFeed, + titleColor: Color, + accentColor: Color, + baseColor: Color, + bottomPadding: Boolean, +) { + val item = itemWithFeed.item + val spacing = MaterialTheme.spacing.mediumSpacing + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding( + start = spacing, + end = spacing, + top = spacing, + bottom = if (bottomPadding) spacing else 0.dp + ) + ) { + AsyncImage( + model = itemWithFeed.feedIconUrl, + contentDescription = itemWithFeed.feedName, + placeholder = painterResource(id = R.drawable.ic_rss_feed_grey), + error = painterResource(id = R.drawable.ic_rss_feed_grey), + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + ) + + ShortSpacer() + + Text( + text = itemWithFeed.feedName, + style = MaterialTheme.typography.labelLarge, + color = baseColor, + textAlign = TextAlign.Center + ) + + ShortSpacer() + + Text( + text = item.title!!, + style = MaterialTheme.typography.headlineMedium, + color = titleColor, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + if (item.author != null) { + ShortSpacer() + + IconText( + icon = painterResource(id = R.drawable.ic_person), + text = itemWithFeed.item.author!!, + style = MaterialTheme.typography.labelMedium, + color = baseColor, + tint = accentColor + ) + } + + ShortSpacer() + + val readTime = if (item.readTime > 1) { + stringResource(id = R.string.read_time, item.readTime.roundToInt()) + } else { + stringResource(id = R.string.read_time_lower_than_1) + } + Text( + text = "${DateUtils.formattedDate(item.pubDate!!)} · $readTime", + style = MaterialTheme.typography.labelMedium, + color = baseColor + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/item/ItemScreenBottomBar.kt b/app/src/main/java/com/readrops/app/item/ItemScreenBottomBar.kt new file mode 100644 index 00000000..c15af3e0 --- /dev/null +++ b/app/src/main/java/com/readrops/app/item/ItemScreenBottomBar.kt @@ -0,0 +1,99 @@ +package com.readrops.app.item + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.painterResource +import com.readrops.app.R +import com.readrops.app.util.FeedColors +import com.readrops.app.util.theme.spacing + +data class BottomBarState( + val isRead: Boolean = false, + val isStarred: Boolean = false +) + +@Composable +fun ItemScreenBottomBar( + state: BottomBarState, + accentColor: Color, + onShare: () -> Unit, + onOpenUrl: () -> Unit, + onChangeReadState: (Boolean) -> Unit, + onChangeStarState: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val tint = if (FeedColors.isColorDark(accentColor.toArgb())) + Color.White + else + Color.Black + + Surface( + color = accentColor, + modifier = modifier.fillMaxWidth() + ) { + Row( + horizontalArrangement = Arrangement.SpaceAround, + modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing) + ) { + IconButton( + onClick = { onChangeReadState(!state.isRead) } + ) { + Icon( + painter = painterResource( + id = if (state.isRead) + R.drawable.ic_remove_done + else R.drawable.ic_done_all + ), + tint = tint, + contentDescription = null + ) + } + + IconButton( + onClick = { onChangeStarState(!state.isStarred) } + ) { + Icon( + painter = painterResource( + id = if (state.isStarred) + R.drawable.ic_star + else R.drawable.ic_star_outline + ), + tint = tint, + contentDescription = null + ) + } + + IconButton( + onClick = onShare + ) { + Icon( + imageVector = Icons.Default.Share, + tint = tint, + contentDescription = null + ) + } + + IconButton( + onClick = onOpenUrl + ) { + Icon( + painter = painterResource(id = R.drawable.ic_open_in_browser), + tint = tint, + contentDescription = null + ) + } + } + } +} diff --git a/app/src/main/java/com/readrops/app/item/ItemScreenModel.kt b/app/src/main/java/com/readrops/app/item/ItemScreenModel.kt new file mode 100644 index 00000000..239f4db7 --- /dev/null +++ b/app/src/main/java/com/readrops/app/item/ItemScreenModel.kt @@ -0,0 +1,179 @@ +package com.readrops.app.item + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Environment +import androidx.compose.runtime.Stable +import androidx.core.content.FileProvider +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import coil.imageLoader +import coil.request.ImageRequest +import com.readrops.app.repositories.BaseRepository +import com.readrops.app.util.Preferences +import com.readrops.db.Database +import com.readrops.db.entities.Item +import com.readrops.db.entities.account.Account +import com.readrops.db.pojo.ItemWithFeed +import com.readrops.db.queries.ItemSelectionQueryBuilder +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.parameter.parametersOf +import java.io.File +import java.io.FileOutputStream + +class ItemScreenModel( + private val database: Database, + private val itemId: Int, + private val preferences: Preferences, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : StateScreenModel(ItemState()), KoinComponent { + + //TODO Is this really the best solution? + lateinit var account: Account + lateinit var repository: BaseRepository + + init { + screenModelScope.launch(dispatcher) { + database.accountDao().selectCurrentAccount() + .collect { account -> + this@ItemScreenModel.account = account!! + repository = get { parametersOf(account) } + + val query = ItemSelectionQueryBuilder.buildQuery( + itemId = itemId, + separateState = account.config.useSeparateState + ) + + database.itemDao().selectItemById(query) + .collect { itemWithFeed -> + mutableState.update { + it.copy( + itemWithFeed = itemWithFeed, + bottomBarState = BottomBarState( + isRead = itemWithFeed.item.isRead, + isStarred = itemWithFeed.item.isStarred + ) + ) + } + } + } + } + + screenModelScope.launch(dispatcher) { + preferences.openLinksWith.flow + .collect { value -> + mutableState.update { + it.copy( + openInExternalBrowser = when (value) { + "external_navigator" -> true + else -> false + } + ) + } + } + } + } + + fun shareItem(item: Item, context: Context) { + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, item.link) + }.also { + context.startActivity(Intent.createChooser(it, null)) + } + } + + fun setItemReadState(item: Item) { + //TODO support separateState + screenModelScope.launch(dispatcher) { + repository.setItemReadState(item) + } + } + + fun setItemStarState(item: Item) { + //TODO support separateState + screenModelScope.launch(dispatcher) { + repository.setItemStarState(item) + } + } + + fun openImageDialog(url: String) = mutableState.update { it.copy(imageDialogUrl = url) } + + fun closeImageDialog() = mutableState.update { it.copy(imageDialogUrl = null) } + + fun downloadImage(url: String, context: Context) { + screenModelScope.launch(dispatcher) { + val bitmap = getImage(url, context) + + val target = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + url.substringAfterLast('/') + ) + FileOutputStream(target).apply { + bitmap.compress(Bitmap.CompressFormat.PNG, 90, this) + flush() + close() + } + + mutableState.update { it.copy(fileDownloadedEvent = true) } + } + } + + fun shareImage(url: String, context: Context) { + screenModelScope.launch(dispatcher) { + val bitmap = getImage(url, context) + val uri = saveImageInCache(bitmap, url, context) + + Intent().apply { + action = Intent.ACTION_SEND + type = "image/*" + putExtra(Intent.EXTRA_STREAM, uri) + }.also { + context.startActivity(Intent.createChooser(it, null)) + } + } + } + + private suspend fun getImage(url: String, context: Context): Bitmap { + val downloader = context.imageLoader + + return (downloader.execute( + ImageRequest.Builder(context) + .data(url) + .allowHardware(false) + .build() + ).drawable as BitmapDrawable).bitmap + } + + private fun saveImageInCache(bitmap: Bitmap, url: String, context: Context): Uri { + val imagesFolder = File(context.cacheDir.absolutePath, "images") + if (!imagesFolder.exists()) imagesFolder.mkdirs() + + val image = File(imagesFolder, url.substringAfterLast('/')) + FileOutputStream(image).apply { + bitmap.compress(Bitmap.CompressFormat.PNG, 90, this) + flush() + close() + } + + return FileProvider.getUriForFile(context, context.packageName, image) + } +} + +@Stable +data class ItemState( + val itemWithFeed: ItemWithFeed? = null, + val bottomBarState: BottomBarState = BottomBarState(), + val imageDialogUrl: String? = null, + val fileDownloadedEvent: Boolean = false, + val openInExternalBrowser: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/item/ItemViewModel.java b/app/src/main/java/com/readrops/app/item/ItemViewModel.java deleted file mode 100644 index 33127ea1..00000000 --- a/app/src/main/java/com/readrops/app/item/ItemViewModel.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.readrops.app.item; - -import android.content.Context; -import android.graphics.Bitmap; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.core.content.FileProvider; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.ViewModel; - -import com.readrops.app.repositories.ARepository; -import com.readrops.db.Database; -import com.readrops.db.entities.Item; -import com.readrops.db.entities.account.Account; -import com.readrops.db.pojo.ItemWithFeed; -import com.readrops.db.queries.ItemSelectionQueryBuilder; - -import org.koin.core.parameter.ParametersHolderKt; -import org.koin.java.KoinJavaComponent; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; - -import io.reactivex.Completable; - -public class ItemViewModel extends ViewModel { - - private final Database database; - private Account account; - - public ItemViewModel(@NonNull Database database) { - this.database = database; - } - - public void setAccount(Account account) { - this.account = account; - } - - public LiveData getItemById(int id) { - return database.itemDao().getItemById(ItemSelectionQueryBuilder.buildQuery(id, - account.getConfig().getUseSeparateState())); - } - - public Completable setStarState(Item item) { - ARepository repository = KoinJavaComponent.get(ARepository.class, null, - () -> ParametersHolderKt.parametersOf(account)); - - return repository.setItemStarState(item); - } - - public Uri saveImageInCache(Bitmap bitmap, Context context) throws IOException { - File imagesFolder = new File(context.getCacheDir().getAbsolutePath(), "images"); - - if (!imagesFolder.exists()) - imagesFolder.mkdirs(); - - File image = new File(imagesFolder, "shared_image.png"); - OutputStream stream = new FileOutputStream(image); - bitmap.compress(Bitmap.CompressFormat.PNG, 90, stream); - - stream.flush(); - stream.close(); - - return FileProvider.getUriForFile(context, context.getPackageName(), image); - } -} diff --git a/app/src/main/java/com/readrops/app/item/WebViewActivity.kt b/app/src/main/java/com/readrops/app/item/WebViewActivity.kt deleted file mode 100644 index 8e5f0eab..00000000 --- a/app/src/main/java/com/readrops/app/item/WebViewActivity.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.readrops.app.item - -import android.annotation.SuppressLint -import android.content.Intent -import android.content.res.ColorStateList -import android.graphics.Bitmap -import android.graphics.drawable.ColorDrawable -import android.net.Uri -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.webkit.* -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import com.readrops.app.R -import com.readrops.app.databinding.ActivityWebViewBinding -import com.readrops.app.utils.ReadropsKeys -import com.readrops.app.utils.ReadropsKeys.ACTION_BAR_COLOR - -class WebViewActivity : AppCompatActivity() { - - private lateinit var binding: ActivityWebViewBinding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityWebViewBinding.inflate(layoutInflater) - setContentView(binding.root) - - setSupportActionBar(binding.activityWebViewToolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - title = "" - - val actionBarColor = intent.getIntExtra(ACTION_BAR_COLOR, ContextCompat.getColor(this, R.color.colorPrimary)) - supportActionBar?.setBackgroundDrawable(ColorDrawable(actionBarColor)) - setWebViewSettings() - - with(binding) { - activityWebViewSwipe.setOnRefreshListener { binding.webView.reload() } - activityWebViewProgress.progressTintList = ColorStateList.valueOf(actionBarColor) - activityWebViewProgress.max = 100 - - val url: String = intent.getStringExtra(ReadropsKeys.WEB_URL)!! - webView.loadUrl(url) - } - - } - - @SuppressLint("SetJavaScriptEnabled") - fun setWebViewSettings() { - val settings: WebSettings = binding.webView.settings - - settings.javaScriptEnabled = true - settings.setSupportZoom(true) - - binding.webView.webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { - binding.webView.loadUrl(request?.url.toString()) - return true - } - - override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { - with(binding) { - activityWebViewSwipe.isRefreshing = false - activityWebViewProgress.progress = 0 - activityWebViewProgress.visibility = View.VISIBLE - } - - super.onPageStarted(view, url, favicon) - } - } - - binding.webView.webChromeClient = object : WebChromeClient() { - override fun onReceivedTitle(view: WebView?, title: String?) { - setTitle(title) - supportActionBar?.subtitle = Uri.parse(view?.url).host - - super.onReceivedTitle(view, title) - } - - override fun onProgressChanged(view: WebView?, newProgress: Int) { - with(binding) { - activityWebViewProgress.progress = newProgress - if (newProgress == 100) activityWebViewProgress.visibility = View.GONE - } - - - super.onProgressChanged(view, newProgress) - } - } - } - - override fun onBackPressed() { - if (binding.webView.canGoBack()) - binding.webView.goBack() - else - super.onBackPressed() - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - if (binding.webView.canGoBack()) - binding.webView.goBack() - else - finish() - return true - } - R.id.web_view_refresh -> { - binding.webView.reload() - } - R.id.web_view_share -> { - shareLink() - } - } - - return super.onOptionsItemSelected(item) - } - - private fun shareLink() { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, binding.webView.url.toString()) - } - - startActivity(Intent.createChooser(intent, getString(R.string.share_url))) - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.webview_menu, menu) - return true - } -} diff --git a/app/src/main/java/com/readrops/app/item/view/ItemNestedScrollView.kt b/app/src/main/java/com/readrops/app/item/view/ItemNestedScrollView.kt new file mode 100644 index 00000000..82d3d710 --- /dev/null +++ b/app/src/main/java/com/readrops/app/item/view/ItemNestedScrollView.kt @@ -0,0 +1,69 @@ +package com.readrops.app.item.view + +import android.annotation.SuppressLint +import android.content.Context +import android.widget.RelativeLayout +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.core.view.ViewCompat +import androidx.core.widget.NestedScrollView + +@SuppressLint("ResourceType", "ViewConstructor") +class ItemNestedScrollView( + context: Context, + onGlobalLayoutListener: (viewHeight: Int, contentHeight: Int) -> Unit, + onUrlClick: (String) -> Unit, + onImageLongPress: (String) -> Unit, + composeViewContent: @Composable () -> Unit +) : NestedScrollView(context) { + + init { + addView( + RelativeLayout(context).apply { + ViewCompat.setNestedScrollingEnabled(this, true) + + val composeView = ComposeView(context).apply { + id = 1 + + setContent { + composeViewContent() + } + } + + val composeViewParams = RelativeLayout.LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT + ) + composeViewParams.addRule(RelativeLayout.CENTER_HORIZONTAL) + composeView.layoutParams = composeViewParams + + val webView = ItemWebView( + context = context, + onUrlClick = onUrlClick, + onImageLongPress = onImageLongPress + ).apply { + id = 2 + ViewCompat.setNestedScrollingEnabled(this, true) + } + + val webViewParams = RelativeLayout.LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT + ) + + webViewParams.addRule(RelativeLayout.BELOW, composeView.id) + webView.layoutParams = webViewParams + + addView(composeView) + addView(webView) + } + ) + + viewTreeObserver.addOnGlobalLayoutListener { + val viewHeight = this.measuredHeight + val contentHeight = getChildAt(0).height + + onGlobalLayoutListener(viewHeight, contentHeight) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/item/view/ItemWebView.kt b/app/src/main/java/com/readrops/app/item/view/ItemWebView.kt new file mode 100644 index 00000000..2c5e3319 --- /dev/null +++ b/app/src/main/java/com/readrops/app/item/view/ItemWebView.kt @@ -0,0 +1,85 @@ +package com.readrops.app.item.view + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.readrops.app.R +import com.readrops.app.util.Utils +import com.readrops.db.pojo.ItemWithFeed +import org.jsoup.Jsoup +import org.jsoup.parser.Parser + +@SuppressLint("SetJavaScriptEnabled", "ViewConstructor") +class ItemWebView( + context: Context, + onUrlClick: (String) -> Unit, + onImageLongPress: (String) -> Unit, + attrs: AttributeSet? = null, +) : WebView(context, attrs) { + + init { + settings.javaScriptEnabled = true + settings.builtInZoomControls = true + settings.displayZoomControls = false + settings.setSupportZoom(false) + isVerticalScrollBarEnabled = false + + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { + url?.let { onUrlClick(it) } + return true + } + } + + setOnLongClickListener { + val type = hitTestResult.type + if (type == HitTestResult.IMAGE_TYPE || type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE) { + hitTestResult.extra?.let { onImageLongPress(it) } + } + + false + } + } + + fun loadText( + itemWithFeed: ItemWithFeed, + accentColor: Color, + backgroundColor: Color, + onBackgroundColor: Color + ) { + val string = context.getString( + R.string.webview_html_template, + Utils.getCssColor(accentColor.toArgb()), + Utils.getCssColor(onBackgroundColor.toArgb()), + Utils.getCssColor(backgroundColor.toArgb()), + formatText(itemWithFeed) + ) + + loadDataWithBaseURL( + "file:///android_asset/", + string, + "text/html; charset=utf-8", + "UTF-8", + null + ) + } + + private fun formatText(itemWithFeed: ItemWithFeed): String { + return if (itemWithFeed.item.text != null) { + val document = if (itemWithFeed.websiteUrl != null) Jsoup.parse( + Parser.unescapeEntities(itemWithFeed.item.text, false), itemWithFeed.websiteUrl + ) else Jsoup.parse( + Parser.unescapeEntities(itemWithFeed.item.text, false) + ) + + document.select("div,span").forEach { it.clearAttributes() } + return document.body().html() + } else { + "" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/itemslist/DrawerManager.java b/app/src/main/java/com/readrops/app/itemslist/DrawerManager.java deleted file mode 100644 index a8dda955..00000000 --- a/app/src/main/java/com/readrops/app/itemslist/DrawerManager.java +++ /dev/null @@ -1,412 +0,0 @@ -package com.readrops.app.itemslist; - -import static com.readrops.app.utils.Utils.drawableWithColor; - -import android.app.Activity; -import android.graphics.drawable.Drawable; -import android.view.View; -import android.widget.ImageView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.Toolbar; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.request.target.CustomTarget; -import com.bumptech.glide.request.transition.Transition; -import com.mikepenz.fastadapter.FastAdapter; -import com.mikepenz.fastadapter.expandable.ExpandableExtension; -import com.mikepenz.fastadapter.listeners.ClickEventHook; -import com.mikepenz.fastadapter.select.SelectExtension; -import com.mikepenz.materialdrawer.AccountHeader; -import com.mikepenz.materialdrawer.AccountHeaderBuilder; -import com.mikepenz.materialdrawer.Drawer; -import com.mikepenz.materialdrawer.DrawerBuilder; -import com.mikepenz.materialdrawer.holder.ImageHolder; -import com.mikepenz.materialdrawer.model.DividerDrawerItem; -import com.mikepenz.materialdrawer.model.PrimaryDrawerItem; -import com.mikepenz.materialdrawer.model.ProfileDrawerItem; -import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem; -import com.mikepenz.materialdrawer.model.SecondaryDrawerItem; -import com.mikepenz.materialdrawer.model.interfaces.IDrawerItem; -import com.mikepenz.materialdrawer.model.interfaces.IProfile; -import com.readrops.app.R; -import com.readrops.app.utils.SharedPreferencesManager; -import com.readrops.app.utils.customviews.CustomExpandableBadgeDrawerItem; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.account.Account; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class DrawerManager { - - public static final int ARTICLES_ITEM_ID = -5; - public static final int READ_LATER_ID = -6; - public static final int STARS_ID = -10; - public static final int ADD_ACCOUNT_ID = -4; - public static final int ABOUT_ID = -7; - public static final int SETTINGS_ID = -8; - public static final int ACCOUNT_SETTINGS_ID = -9; - - private Activity activity; - private Toolbar toolbar; - private Drawer drawer; - private FastAdapter adapter; - - private AccountHeader header; - private Drawer.OnDrawerItemClickListener listener; - private AccountHeader.OnAccountHeaderListener headerListener; - - public DrawerManager(Activity activity, Toolbar toolbar, Drawer.OnDrawerItemClickListener listener) { - this.activity = activity; - this.listener = listener; - this.toolbar = toolbar; - } - - public void setHeaderListener(AccountHeader.OnAccountHeaderListener headerListener) { - this.headerListener = headerListener; - } - - public Drawer buildDrawer(List accounts, int currentAccountId) { - createAccountHeader(accounts, currentAccountId); - - drawer = new DrawerBuilder() - .withActivity(activity) - .withToolbar(toolbar) - .withAccountHeader(header) - .withSelectedItem(DrawerManager.ARTICLES_ITEM_ID) - .withOnDrawerItemClickListener(listener) - .build(); - - adapter = drawer.getAdapter(); - buildFastAdapter(); - - addDefaultPlaces(); - - return drawer; - } - - public void buildFastAdapter() { - // Folder click - adapter.withEventHook(new ClickEventHook() { - @Override - public void onClick(@NonNull View v, int position, @NonNull FastAdapter fastAdapter, @NonNull IDrawerItem item) { - SelectExtension selectExtension = adapter.getExtension(SelectExtension.class); - - selectExtension.deselect(selectExtension.getSelections()); - - if (!item.isSelected()) { - selectExtension.select(position); - } - - listener.onItemClick(v, position, item); - } - - @Override - public List onBindMany(@NonNull RecyclerView.ViewHolder viewHolder) { - if (viewHolder instanceof CustomExpandableBadgeDrawerItem.ViewHolder) { - CustomExpandableBadgeDrawerItem.ViewHolder expandableViewHolder = (CustomExpandableBadgeDrawerItem.ViewHolder) viewHolder; - - return Arrays.asList(new View[]{ - expandableViewHolder.itemView.findViewById(R.id.expandable_item_container), - expandableViewHolder.itemView.findViewById(R.id.material_drawer_icon), - expandableViewHolder.itemView.findViewById(R.id.material_drawer_name), - expandableViewHolder.itemView.findViewById(R.id.material_drawer_description) - }.clone()); - - } else { - return Collections.emptyList(); - } - } - }); - - // Expandable click - adapter.withEventHook(new ClickEventHook() { - @Override - public void onClick(@NonNull View v, int position, @NonNull FastAdapter fastAdapter, @NonNull IDrawerItem item) { - ExpandableExtension expandableExtension = adapter.getExtension(ExpandableExtension.class); - - expandableExtension.toggleExpandable(position); - } - - @Override - public List onBindMany(@NonNull RecyclerView.ViewHolder viewHolder) { - if (viewHolder instanceof CustomExpandableBadgeDrawerItem.ViewHolder) { - CustomExpandableBadgeDrawerItem.ViewHolder expandableViewHolder = (CustomExpandableBadgeDrawerItem.ViewHolder) viewHolder; - - return Arrays.asList(new View[]{ - expandableViewHolder.badge, - expandableViewHolder.badgeContainer, - expandableViewHolder.arrow, - expandableViewHolder.itemView.findViewById(R.id.material_drawer_arrow_container) - }.clone()); - - } else { - return Collections.emptyList(); - } - } - }); - } - - public void updateDrawer(Map> folderListMap) { - drawer.removeAllItems(); - drawer.removeAllStickyFooterItems(); - - addDefaultPlaces(); - - Map feedsWithoutFolder = new HashMap<>(); - boolean showFeedsWithNoUnreadItems = Boolean.parseBoolean(SharedPreferencesManager - .readString(SharedPreferencesManager.SharedPrefKey.HIDE_SHOW_FEEDS)); - - for (Map.Entry> entry : folderListMap.entrySet()) { - Folder folder = entry.getKey(); - if (folder != null) { - CustomExpandableBadgeDrawerItem badgeDrawerItem = new CustomExpandableBadgeDrawerItem() - .withIdentifier(folder.getId() * 1000L) // to avoid any id conflict with other items - .withName(folder.getName()) - .withIcon(R.drawable.ic_folder_grey); - - List secondaryDrawerItems = new ArrayList<>(); - int expandableUnreadCount = 0; - - for (Feed feed : entry.getValue()) { - expandableUnreadCount += feed.getUnreadCount(); - - SecondaryDrawerItem secondaryDrawerItem = createSecondaryItem(feed); - - if (!showFeedsWithNoUnreadItems) { - if (feed.getUnreadCount() > 0) { - secondaryDrawerItems.add(secondaryDrawerItem); - } - } else { - secondaryDrawerItems.add(secondaryDrawerItem); - } - - loadItemIcon(secondaryDrawerItem, feed); - } - - boolean showItem; - if (!showFeedsWithNoUnreadItems) { - showItem = expandableUnreadCount > 0; - } else { - showItem = true; - } - - if (!secondaryDrawerItems.isEmpty() && showItem) { - badgeDrawerItem.withSubItems(secondaryDrawerItems); - badgeDrawerItem.withBadge(String.valueOf(expandableUnreadCount)); - drawer.addItem(badgeDrawerItem); - } - } else { // no folder case, items to add after the folders - for (Feed feed : folderListMap.get(folder)) { - SecondaryDrawerItem secondaryItem = createSecondaryItem(feed); - feedsWithoutFolder.put(secondaryItem, feed); - } - } - } - - // work-around as MaterialDrawer doesn't accept an item list - for (Map.Entry entry : feedsWithoutFolder.entrySet()) { - drawer.addItem(entry.getKey()); - loadItemIcon(entry.getKey(), entry.getValue()); - } - } - - private void createAccountHeader(List accounts, int currentAccountId) { - ProfileDrawerItem[] profileItems = new ProfileDrawerItem[accounts.size()]; - - for (int i = 0; i < accounts.size(); i++) { - Account account = accounts.get(i); - - // if currentAccount > 0, it means that the current account is no longer - if (account.isCurrentAccount() && currentAccountId == 0) - currentAccountId = account.getId(); - - ProfileDrawerItem profileItem = createProfileItem(account); - profileItems[i] = profileItem; - } - - header = new AccountHeaderBuilder() - .withActivity(activity) - .addProfiles(profileItems) - .withDividerBelowHeader(false) - .withAlternativeProfileHeaderSwitching(true) - .withCurrentProfileHiddenInList(true) - .withTextColorRes(R.color.colorBackground) - .withHeaderBackground(R.drawable.header_background) - .withHeaderBackgroundScaleType(ImageView.ScaleType.CENTER_CROP) - .withOnAccountHeaderListener(headerListener) - .build(); - - addProfileSettingItems(); - - header.setActiveProfile(currentAccountId); - } - - private ProfileDrawerItem createProfileItem(Account account) { - return new ProfileDrawerItem() - .withIcon(account.getAccountType().getIconRes()) - .withName(account.getDisplayedName()) - .withEmail(account.getAccountName()) - .withIdentifier(account.getId()); - } - - private SecondaryDrawerItem createSecondaryItem(Feed feed) { - int color = feed.getTextColor(); - - return new SecondaryDrawerItem() - .withName(feed.getName()) - .withBadge(String.valueOf(feed.getUnreadCount())) - .withIcon(color != 0 ? drawableWithColor(color) : drawableWithColor(activity.getResources().getColor(R.color.colorPrimary))) - .withIdentifier(feed.getId()); - } - - private void loadItemIcon(SecondaryDrawerItem secondaryDrawerItem, Feed feed) { - Glide.with(activity) - .load(feed.getIconUrl()) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .into(new CustomTarget() { - @Override - public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { - drawer.updateIcon(secondaryDrawerItem.getIdentifier(), new ImageHolder(resource)); - } - - @Override - public void onLoadCleared(@Nullable Drawable placeholder) { - // no need of this method - } - }); - } - - private void addDefaultPlaces() { - PrimaryDrawerItem articles = new PrimaryDrawerItem() - .withName(R.string.articles) - .withIcon(R.drawable.ic_rss_feed_grey) - .withSelectable(true) - .withIdentifier(ARTICLES_ITEM_ID); - - PrimaryDrawerItem toReadLater = new PrimaryDrawerItem() - .withName(R.string.read_later) - .withIcon(R.drawable.ic_read_later) - .withSelectable(true) - .withIdentifier(READ_LATER_ID); - - PrimaryDrawerItem favorites = new PrimaryDrawerItem() - .withName(R.string.favorites) - .withIcon(R.drawable.ic_star) - .withSelectable(true) - .withIdentifier(STARS_ID); - - PrimaryDrawerItem aboutItem = new PrimaryDrawerItem() - .withName(R.string.about) - .withIcon(R.drawable.ic_about_grey) - .withSelectable(false) - .withIdentifier(ABOUT_ID); - - PrimaryDrawerItem settingsItem = new PrimaryDrawerItem() - .withName(R.string.settings) - .withIcon(R.drawable.ic_settings) - .withSelectable(false) - .withIdentifier(SETTINGS_ID); - - drawer.addStickyFooterItem(settingsItem); - drawer.addStickyFooterItem(aboutItem); - - drawer.addItem(articles); - drawer.addItem(favorites); - drawer.addItem(toReadLater); - drawer.addItem(new DividerDrawerItem()); - } - - private void addProfileSettingItems() { - ProfileSettingDrawerItem accountSettingsItem = new ProfileSettingDrawerItem() - .withName(R.string.account_settings) - .withIcon(R.drawable.ic_settings) - .withIdentifier(ACCOUNT_SETTINGS_ID); - - ProfileSettingDrawerItem addAccountSettingsItem = new ProfileSettingDrawerItem() - .withName(R.string.add_account) - .withIcon(R.drawable.ic_add_account_grey) - .withIdentifier(ADD_ACCOUNT_ID); - - header.addProfiles(accountSettingsItem, addAccountSettingsItem); - } - - public void addAccount(Account account, boolean currentProfile) { - ProfileDrawerItem profileItem = createProfileItem(account); - - header.addProfiles(profileItem); - - if (currentProfile) - header.setActiveProfile(profileItem.getIdentifier()); - } - - public void setAccount(int accountId) { - header.setActiveProfile(accountId); - } - - public void updateHeader(List accounts) { - header.clear(); - addProfileSettingItems(); - - for (Account account : accounts) { - addAccount(account, account.isCurrentAccount()); - } - } - - public int getNumberOfProfiles() { - List profiles = header.getProfiles(); - - int number = 0; - for (IProfile profile : profiles) { - if (profile instanceof ProfileDrawerItem) - number++; - } - - return number; - } - - public void resetItems() { - drawer.removeAllItems(); - drawer.removeAllStickyFooterItems(); - addDefaultPlaces(); - } - - public void disableAccountSelection() { - List profiles = header.getProfiles(); - - for (IProfile profile : profiles) { - if (profile.getIdentifier() != header.getActiveProfile().getIdentifier() && !(profile instanceof ProfileSettingDrawerItem)) { - profile.withSelectable(false); - header.updateProfile(profile); - } - } - } - - public void enableAccountSelection() { - List profiles = header.getProfiles(); - - for (IProfile profile : profiles) { - if (profile.getIdentifier() != header.getActiveProfile().getIdentifier() && !(profile instanceof ProfileSettingDrawerItem)) { - profile.withSelectable(true); - header.updateProfile(profile); - } - } - } - - public void setDrawerSelection(long identifier) { - drawer.setSelection(identifier); - } - - public long getCurrentSelection() { - return drawer.getCurrentSelection(); - } -} diff --git a/app/src/main/java/com/readrops/app/itemslist/MainActivity.java b/app/src/main/java/com/readrops/app/itemslist/MainActivity.java deleted file mode 100644 index b89e32c9..00000000 --- a/app/src/main/java/com/readrops/app/itemslist/MainActivity.java +++ /dev/null @@ -1,868 +0,0 @@ -package com.readrops.app.itemslist; - -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT; -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT_ID; -import static com.readrops.app.utils.ReadropsKeys.FEEDS; -import static com.readrops.app.utils.ReadropsKeys.FROM_MAIN_ACTIVITY; -import static com.readrops.app.utils.ReadropsKeys.IMAGE_URL; -import static com.readrops.app.utils.ReadropsKeys.ITEM_ID; -import static com.readrops.app.utils.ReadropsKeys.SETTINGS; -import static com.readrops.app.utils.ReadropsKeys.SYNCING; - -import android.content.Intent; -import android.content.res.Configuration; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.util.Log; -import android.view.ActionMode; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.drawerlayout.widget.DrawerLayout; -import androidx.paging.PagedList; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.afollestad.materialdialogs.MaterialDialog; -import com.bumptech.glide.Glide; -import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader; -import com.bumptech.glide.util.ViewPreloadSizeProvider; -import com.mikepenz.aboutlibraries.Libs; -import com.mikepenz.aboutlibraries.LibsBuilder; -import com.mikepenz.aboutlibraries.LibsConfiguration; -import com.mikepenz.aboutlibraries.entity.Library; -import com.mikepenz.materialdrawer.Drawer; -import com.mikepenz.materialdrawer.model.PrimaryDrawerItem; -import com.mikepenz.materialdrawer.model.SecondaryDrawerItem; -import com.mikepenz.materialdrawer.model.interfaces.IDrawerItem; -import com.readrops.app.R; -import com.readrops.app.account.AccountTypeListActivity; -import com.readrops.app.account.AccountViewModel; -import com.readrops.app.addfeed.AddFeedActivity; -import com.readrops.app.databinding.ActivityMainBinding; -import com.readrops.app.item.ItemActivity; -import com.readrops.app.settings.SettingsActivity; -import com.readrops.app.utils.GlideRequests; -import com.readrops.app.utils.SharedPreferencesManager; -import com.readrops.app.utils.Utils; -import com.readrops.app.utils.customviews.CustomExpandableBadgeDrawerItem; -import com.readrops.app.utils.customviews.ReadropsItemTouchCallback; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.Item; -import com.readrops.db.entities.account.Account; -import com.readrops.db.filters.FilterType; -import com.readrops.db.filters.ListSortType; -import com.readrops.db.pojo.ItemWithFeed; - -import org.jetbrains.annotations.NotNull; -import org.koin.android.compat.ViewModelCompat; -import org.koin.java.KoinJavaComponent; - -import java.lang.ref.WeakReference; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import io.reactivex.CompletableObserver; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; -import io.reactivex.observers.DisposableSingleObserver; -import io.reactivex.schedulers.Schedulers; - -public class MainActivity extends AppCompatActivity implements SwipeRefreshLayout.OnRefreshListener, - ReadropsItemTouchCallback.SwipeCallback, ActionMode.Callback { - - public static final String TAG = MainActivity.class.getSimpleName(); - - public static final int ADD_FEED_REQUEST = 1; - public static final int MANAGE_ACCOUNT_REQUEST = 2; - public static final int ITEM_REQUEST = 3; - public static final int ADD_ACCOUNT_REQUEST = 4; - public static final int SETTINGS_REQUEST = 5; - - private ActivityMainBinding binding; - private MainItemListAdapter adapter; - - private Drawer drawer; - - private PagedList allItems; - - private MainViewModel viewModel; - private DrawerManager drawerManager; - - private int feedCount; - private int feedNb; - private boolean scrollToTop; - private boolean allItemsSelected; - private boolean updating; - - private ActionMode actionMode; - private Disposable syncDisposable; - - private ItemWithFeed selectedItemWithFeed; - - @Override - protected void onCreate(Bundle savedInstanceState) { - setTheme(R.style.AppTheme_NoActionBar); - super.onCreate(savedInstanceState); - - // surely a better way to do this, but hopefully this code will be replaced with jetpack compose - AccountViewModel accountViewModel = ViewModelCompat.getViewModel(this, AccountViewModel.class); - int accountCount = accountViewModel.getAccountCount() - .subscribeOn(Schedulers.io()) - .blockingGet(); - - if (accountCount == 0) { - Intent intent = new Intent(getApplicationContext(), AccountTypeListActivity.class); - startActivity(intent); - finish(); - } - - binding = ActivityMainBinding.inflate(getLayoutInflater()); - - setContentView(binding.getRoot()); - setSupportActionBar(binding.toolbarMain); - - binding.swipeRefreshLayout.setOnRefreshListener(this); - - feedCount = 0; - initRecyclerView(); - - viewModel = ViewModelCompat.getViewModel(this, MainViewModel.class); - - viewModel.getItemsWithFeed().observe(this, itemWithFeeds -> { - allItems = itemWithFeeds; - - if (!itemWithFeeds.isEmpty()) - binding.emptyListLayout.setVisibility(View.GONE); - else - binding.emptyListLayout.setVisibility(View.VISIBLE); - - if (!binding.swipeRefreshLayout.isRefreshing()) - adapter.submitList(itemWithFeeds); - }); - - drawerManager = new DrawerManager(this, binding.toolbarMain, (view, position, drawerItem) -> { - handleDrawerClick(drawerItem); - - return true; - }); - - drawerManager.setHeaderListener((view, profile, current) -> { - if (!current) { - int id = (int) profile.getIdentifier(); - - switch (id) { - case DrawerManager.ADD_ACCOUNT_ID: - Intent intent = new Intent(this, AccountTypeListActivity.class); - intent.putExtra(FROM_MAIN_ACTIVITY, true); - startActivityForResult(intent, ADD_ACCOUNT_REQUEST); - break; - case DrawerManager.ACCOUNT_SETTINGS_ID: - Intent intent1 = new Intent(this, SettingsActivity.class); - intent1.putExtra(SETTINGS, - SettingsActivity.SettingsKey.ACCOUNT_SETTINGS.ordinal()); - intent1.putExtra(ACCOUNT, viewModel.getCurrentAccount()); - startActivity(intent1); - break; - default: - if (!updating) { - viewModel.setCurrentAccount(id); - updateDrawerFeeds(); - } - break; - } - } else { - Intent intent = new Intent(this, SettingsActivity.class); - intent.putExtra(SETTINGS, - SettingsActivity.SettingsKey.ACCOUNT_SETTINGS.ordinal()); - intent.putExtra(ACCOUNT, viewModel.getCurrentAccount()); - startActivityForResult(intent, MANAGE_ACCOUNT_REQUEST); - } - - return true; - }); - - Account currentAccount = getIntent().getParcelableExtra(ACCOUNT); - WeakReference accountWeakReference = new WeakReference<>(currentAccount); - - viewModel.getAllAccounts().observe(this, accounts -> { - getAccountCredentials(accounts); - viewModel.setAccounts(accounts); - - // the activity was just opened - if (drawer == null) { - int currentAccountId = 0; - if (getIntent().hasExtra(ACCOUNT_ID)) { // coming from a notification - currentAccountId = getIntent().getIntExtra(ACCOUNT_ID, 1); - viewModel.setCurrentAccount(currentAccountId); - } - - drawer = drawerManager.buildDrawer(accounts, currentAccountId); - drawer.setSelection(DrawerManager.ARTICLES_ITEM_ID); - updateDrawerFeeds(); - - openItemActivity(getIntent()); - } else if (accounts.size() < drawerManager.getNumberOfProfiles() && !accounts.isEmpty()) { - drawerManager.updateHeader(accounts); - updateDrawerFeeds(); - } else if (accounts.isEmpty()) { - Intent intent = new Intent(this, AccountTypeListActivity.class); - startActivity(intent); - finish(); - } - - if (accountWeakReference.get() != null && !accountWeakReference.get().isLocal()) { - binding.swipeRefreshLayout.setRefreshing(true); - onRefresh(); - accountWeakReference.clear(); - } else if (currentAccount == null && savedInstanceState != null && savedInstanceState.getBoolean(SYNCING)) { - binding.swipeRefreshLayout.setRefreshing(true); - onRefresh(); - savedInstanceState.clear(); - } - - - }); - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - - openItemActivity(intent); - } - - private void openItemActivity(Intent intent) { - if (intent.hasExtra(ITEM_ID) && intent.hasExtra(IMAGE_URL)) { - Intent itemIntent = new Intent(this, ItemActivity.class); - itemIntent.putExtras(intent); - itemIntent.putExtra(ACCOUNT, viewModel.getCurrentAccount()); - - startActivity(itemIntent); - - Item item = new Item(); - item.setId(intent.getIntExtra(ITEM_ID, 0)); - item.setRead(true); - - viewModel.setItemReadState(item) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(throwable -> Utils.showSnackbar(binding.mainRoot, throwable.getMessage())) - .subscribe(); - } - } - - private void handleDrawerClick(IDrawerItem drawerItem) { - if (drawerItem instanceof PrimaryDrawerItem) { - drawer.closeDrawer(); - int id = (int) drawerItem.getIdentifier(); - - switch (id) { - default: - case DrawerManager.ARTICLES_ITEM_ID: - viewModel.setFilterType(FilterType.NO_FILTER); - scrollToTop = true; - viewModel.invalidate(); - setTitle(R.string.articles); - break; - case DrawerManager.READ_LATER_ID: - viewModel.setFilterType(FilterType.READ_IT_LATER_FILTER); - viewModel.invalidate(); - setTitle(R.string.read_later); - break; - case DrawerManager.STARS_ID: - viewModel.setFilterType(FilterType.STARS_FILTER); - viewModel.invalidate(); - setTitle(R.string.favorites); - break; - case DrawerManager.ABOUT_ID: - startAboutActivity(); - break; - case DrawerManager.SETTINGS_ID: - Intent intent = new Intent(getApplication(), SettingsActivity.class); - intent.putExtra(SETTINGS, - SettingsActivity.SettingsKey.SETTINGS.ordinal()); - startActivityForResult(intent, SETTINGS_REQUEST); - break; - } - } else if (drawerItem instanceof SecondaryDrawerItem) { - drawer.closeDrawer(); - - viewModel.setFilterFeedId((int) drawerItem.getIdentifier()); - viewModel.setFilterType(FilterType.FEED_FILTER); - viewModel.invalidate(); - setTitle(((SecondaryDrawerItem) drawerItem).getName().getText()); - } else if (drawerItem instanceof CustomExpandableBadgeDrawerItem) { - drawer.closeDrawer(); - - viewModel.setFilerFolderId((int) (drawerItem.getIdentifier() / 1000)); - viewModel.setFilterType(FilterType.FOLDER_FILER); - viewModel.invalidate(); - setTitle(((CustomExpandableBadgeDrawerItem) drawerItem).getName().getText()); - } - } - - private void updateDrawerFeeds() { - viewModel.getFoldersWithFeeds() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new DisposableSingleObserver>>() { - @Override - public void onSuccess(Map> folderListHashMap) { - drawerManager.updateDrawer(folderListHashMap); - } - - @Override - public void onError(Throwable e) { - Utils.showSnackbar(binding.mainRoot, e.getMessage()); - } - }); - } - - @Override - public void onBackPressed() { - if (drawer.isDrawerOpen()) - drawer.closeDrawer(); - else - super.onBackPressed(); - } - - private void initRecyclerView() { - ViewPreloadSizeProvider preloadSizeProvider = new ViewPreloadSizeProvider(); - adapter = new MainItemListAdapter(KoinJavaComponent.get(GlideRequests.class), preloadSizeProvider); - adapter.setOnItemClickListener(new MainItemListAdapter.OnItemClickListener() { - @Override - public void onItemClick(ItemWithFeed itemWithFeed, int position) { - if (actionMode == null) { - Intent intent = new Intent(getApplicationContext(), ItemActivity.class); - - intent.putExtra(ITEM_ID, itemWithFeed.getItem().getId()); - intent.putExtra(IMAGE_URL, itemWithFeed.getItem().getImageLink()); - intent.putExtra(ACCOUNT, viewModel.getCurrentAccount()); - - startActivityForResult(intent, ITEM_REQUEST); - - itemWithFeed.getItem().setRead(true); - viewModel.setItemReadState(itemWithFeed) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(throwable -> Utils.showSnackbar(binding.mainRoot, throwable.getMessage())) - .subscribe(); - - adapter.notifyItemChanged(position, itemWithFeed); - updateDrawerFeeds(); - } else { - adapter.toggleSelection(position); - int selectionSize = adapter.getSelection().size(); - - if (selectionSize > 0) - actionMode.setTitle(String.valueOf(selectionSize)); - else - actionMode.finish(); - } - } - - @Override - public void onItemLongClick(ItemWithFeed itemWithFeed, int position) { - if (actionMode != null || binding.swipeRefreshLayout.isRefreshing()) - return; - - selectedItemWithFeed = itemWithFeed; - adapter.toggleSelection(position); - - actionMode = startActionMode(MainActivity.this); - actionMode.setTitle(String.valueOf(adapter.getSelection().size())); - } - }); - - RecyclerViewPreloader preloader = new RecyclerViewPreloader(Glide.with(this), adapter, preloadSizeProvider, 10); - binding.itemsRecyclerView.addOnScrollListener(preloader); - - binding.itemsRecyclerView.addRecyclerListener(viewHolder -> { - MainItemListAdapter.ItemViewHolder vh = (MainItemListAdapter.ItemViewHolder) viewHolder; - KoinJavaComponent.get(GlideRequests.class).clear(vh.getItemImage()); - }); - - LinearLayoutManager layoutManager = new LinearLayoutManager(this); - binding.itemsRecyclerView.setLayoutManager(layoutManager); - - DividerItemDecoration decoration = new DividerItemDecoration(this, layoutManager.getOrientation()); - binding.itemsRecyclerView.addItemDecoration(decoration); - - binding.itemsRecyclerView.setAdapter(adapter); - - - Drawable readLater = ContextCompat.getDrawable(this, R.drawable.ic_read_later).mutate(); - DrawableCompat.setTint(readLater, ContextCompat.getColor(this, android.R.color.white)); - - new ItemTouchHelper(new ReadropsItemTouchCallback(this, - new ReadropsItemTouchCallback.Config.Builder() - .swipeDirs(ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) - .swipeCallback(this) - .leftDraw(ContextCompat.getColor(this, R.color.colorAccent), R.drawable.ic_read_later, readLater) - .rightDraw(ContextCompat.getColor(this, R.color.colorAccent), R.drawable.ic_read, null) - .build())) - .attachToRecyclerView(binding.itemsRecyclerView); - - adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { - @Override - public void onItemRangeInserted(int positionStart, int itemCount) { - if (scrollToTop) { - binding.itemsRecyclerView.scrollToPosition(0); - scrollToTop = false; - } - } - - @Override - public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { - if (scrollToTop) { - binding.itemsRecyclerView.scrollToPosition(0); - scrollToTop = false; - } else - super.onItemRangeMoved(fromPosition, toPosition, itemCount); - } - }); - - binding.itemsRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { - @Override - public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { - if (dy > 0) { - binding.addFeedFab.hide(); - } else { - binding.addFeedFab.show(); - } - - int firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition(); - if (firstVisibleItemPosition - 2 >= 0) { - Item item = adapter.getItemWithFeed(firstVisibleItemPosition - 2).getItem(); - - // Might be better to have a global variable updated when going back from settings - if (!item.isRead() && SharedPreferencesManager.readBoolean(SharedPreferencesManager - .SharedPrefKey.MARK_ITEMS_READ_ON_SCROLL)) { - item.setRead(!item.isRead()); - - viewModel.setItemReadState(item) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(throwable -> Utils.showSnackbar(binding.mainRoot, throwable.getMessage())) - .subscribe(); - } - } - } - }); - } - - @Override - public void onSwipe(@NotNull RecyclerView.ViewHolder viewHolder, int direction) { - Item item = adapter.getItemWithFeed(viewHolder.getBindingAdapterPosition()).getItem(); - - if (direction == ItemTouchHelper.LEFT) { // set item read state - item.setRead(!item.isRead()); - - viewModel.setItemReadState(item) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(throwable -> Utils.showSnackbar(binding.mainRoot, throwable.getMessage())) - .subscribe(); - - } else { // set item read it later state - item.setReadItLater(!item.isReadItLater()); - - viewModel.setItemReadItLater(item.isReadItLater(), item.getId()) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(throwable -> Utils.showSnackbar(binding.mainRoot, throwable.getMessage())) - .subscribe(); - } - - adapter.notifyItemChanged(viewHolder.getBindingAdapterPosition()); - } - - @Override - public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { - drawer.getDrawerLayout().setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED); - binding.swipeRefreshLayout.setEnabled(false); - - actionMode.getMenuInflater().inflate(R.menu.item_list_contextual_menu, menu); - getWindow().setStatusBarColor(ContextCompat.getColor(this, R.color.primary_dark)); - - return true; - } - - @Override - public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { - menu.findItem(R.id.item_mark_read).setVisible(!selectedItemWithFeed.getItem().isRead()); - menu.findItem(R.id.item_mark_unread).setVisible(selectedItemWithFeed.getItem().isRead()); - - return true; - } - - @Override - public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { - int itemId = menuItem.getItemId(); - - if (itemId == R.id.item_mark_read) { - setReadState(true); - } else if (itemId == R.id.item_mark_unread) { - setReadState(false); - } else if (itemId == R.id.item_select_all) { - if (allItemsSelected) { - adapter.unselectAll(); - allItemsSelected = false; - actionMode.finish(); - } else { - adapter.selectAll(); - allItemsSelected = true; - } - } - - return true; - } - - @Override - public void onDestroyActionMode(ActionMode mode) { - mode.finish(); - actionMode = null; - - drawer.getDrawerLayout().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED); - binding.swipeRefreshLayout.setEnabled(true); - - adapter.clearSelection(); - } - - private void setReadState(boolean read) { - if (allItemsSelected) { - viewModel.setAllItemsReadState(read) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(throwable -> Utils.showSnackbar(binding.mainRoot, throwable.getMessage())) - .subscribe(); - - allItemsSelected = false; - } else { - viewModel.setItemsReadState(adapter.getSelectedItems(), read) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(throwable -> Utils.showSnackbar(binding.mainRoot, throwable.getMessage())) - .subscribe(); - } - - adapter.updateSelection(read); - updateDrawerFeeds(); - actionMode.finish(); - } - - @Override - public void onRefresh() { - Log.d(TAG, "syncing started"); - drawerManager.disableAccountSelection(); - updating = true; - - if (viewModel.isAccountLocal()) { - viewModel.getFeedCount() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new DisposableSingleObserver() { - @Override - public void onSuccess(@NonNull Integer integer) { - feedNb = integer; - sync(null); - } - - @Override - public void onError(@NonNull Throwable e) { - Utils.showSnackbar(binding.mainRoot, e.getMessage()); - } - }); - } else { - sync(null); - } - } - - public void openAddFeedActivity(View view) { - Intent intent = new Intent(this, AddFeedActivity.class); - intent.putExtra(ACCOUNT_ID, viewModel.getCurrentAccount().getId()); - startActivityForResult(intent, ADD_FEED_REQUEST); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (requestCode == ADD_FEED_REQUEST && resultCode == RESULT_OK && data != null) { - List feeds = data.getParcelableArrayListExtra(FEEDS); - - if (feeds != null && !feeds.isEmpty() && viewModel.isAccountLocal()) { - binding.swipeRefreshLayout.setRefreshing(true); - feedNb = feeds.size(); - sync(feeds); - } - - } else if (requestCode == MANAGE_ACCOUNT_REQUEST || requestCode == SETTINGS_REQUEST) { - updateDrawerFeeds(); - - } else if (requestCode == ADD_ACCOUNT_REQUEST && resultCode == RESULT_OK && data != null) { - Account newAccount = data.getParcelableExtra(ACCOUNT); - - if (newAccount != null) { - // get credentials before creating the repository - if (!newAccount.isLocal()) { - getAccountCredentials(Collections.singletonList(newAccount)); - } - - viewModel.addAccount(newAccount); - adapter.clearData(); - - // start syncing only if the account is not local - if (!viewModel.isAccountLocal()) { - binding.swipeRefreshLayout.setRefreshing(true); - onRefresh(); - } - - drawerManager.resetItems(); - drawerManager.addAccount(newAccount, true); - } - - } - - super.onActivityResult(requestCode, resultCode, data); - } - - private void sync(@Nullable List feeds) { - viewModel.sync(feeds, feed -> { - if (viewModel.isAccountLocal() && feedNb > 0) { - binding.syncProgressTextView.setText(getString(R.string.updating_feed, feed.getName())); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - binding.syncProgressBar.setProgress((feedCount * 100) / feedNb, true); - } else - binding.syncProgressBar.setProgress((feedCount * 100) / feedNb); - } - - feedCount++; - }) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new CompletableObserver() { - @Override - public void onSubscribe(@NonNull Disposable d) { - syncDisposable = d; - - if (viewModel.isAccountLocal() && feedNb > 0) { - binding.syncProgressLayout.setVisibility(View.VISIBLE); - binding.syncProgressBar.setProgress(0); - } - } - - @Override - public void onComplete() { - viewModel.invalidate(); - - if (viewModel.isAccountLocal() && feedNb > 0) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) - binding.syncProgressBar.setProgress(100, true); - else - binding.syncProgressBar.setProgress(100); - - binding.syncProgressLayout.setVisibility(View.GONE); - } - - binding.swipeRefreshLayout.setRefreshing(false); - - scrollToTop = true; - adapter.submitList(allItems); - - drawerManager.enableAccountSelection(); - updateDrawerFeeds(); // update drawer after syncing feeds - updating = false; - } - - @Override - public void onError(@NonNull Throwable e) { - binding.swipeRefreshLayout.setRefreshing(false); - binding.syncProgressLayout.setVisibility(View.GONE); - - Utils.showSnackbar(binding.mainRoot, e.getMessage()); - drawerManager.enableAccountSelection(); - updating = false; - } - }); - } - - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.item_list_menu, menu); - - MenuItem articlesItem = menu.findItem(R.id.item_filter_read_items); - articlesItem.setChecked(viewModel.showReadItems()); - - return true; - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - int itemId = item.getItemId(); - - if (itemId == R.id.item_filter_read_items) { - if (item.isChecked()) { - item.setChecked(false); - viewModel.setShowReadItems(false); - SharedPreferencesManager.writeValue( - SharedPreferencesManager.SharedPrefKey.SHOW_READ_ARTICLES, false); - } else { - item.setChecked(true); - viewModel.setShowReadItems(true); - SharedPreferencesManager.writeValue( - SharedPreferencesManager.SharedPrefKey.SHOW_READ_ARTICLES, true); - } - - viewModel.invalidate(); - return true; - } else if (itemId == R.id.item_sort) { - displayFilterDialog(); - return true; - } else if (itemId == R.id.start_sync) { - if (!viewModel.isAccountLocal()) { - binding.swipeRefreshLayout.setRefreshing(true); - } - onRefresh(); - } - - return super.onOptionsItemSelected(item); - } - - private void displayFilterDialog() { - int index = viewModel.getSortType() == ListSortType.OLDEST_TO_NEWEST ? 1 : 0; - - new MaterialDialog.Builder(this) - .title(R.string.filter) - .items(R.array.filter_items) - .itemsCallbackSingleChoice(index, (dialog, itemView, which, text) -> { - String[] items = getResources().getStringArray(R.array.filter_items); - - if (text.toString().equals(items[0])) - viewModel.setSortType(ListSortType.NEWEST_TO_OLDEST); - else - viewModel.setSortType(ListSortType.OLDEST_TO_NEWEST); - - scrollToTop = true; - viewModel.invalidate(); - return true; - }) - .show(); - } - - private void getAccountCredentials(List accounts) { - for (Account account : accounts) { - if (account.getLogin() == null) - account.setLogin(SharedPreferencesManager.readString(account.getLoginKey())); - - if (account.getPassword() == null) - account.setPassword(SharedPreferencesManager.readString(account.getPasswordKey())); - } - } - - private void startAboutActivity() { - Libs.ActivityStyle activityStyle; - int uiMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; - - if (uiMode == Configuration.UI_MODE_NIGHT_YES) { - activityStyle = Libs.ActivityStyle.DARK; - } else { - activityStyle = Libs.ActivityStyle.LIGHT_DARK_TOOLBAR; - } - - new LibsBuilder() - .withAboutIconShown(true) - .withAboutVersionShown(true) - .withAboutAppName(getString(R.string.app_name)) - .withAboutDescription(getString(R.string.app_description)) - .withLicenseShown(true) - .withLicenseDialog(false) - .withActivityTitle(getString(R.string.about)) - .withActivityStyle(activityStyle) - .withFields(R.string.class.getFields()) - .withAboutSpecial1(getString(R.string.source_code)) - .withAboutSpecial2(getString(R.string.changelog)) - .withListener(new LibsConfiguration.LibsListener() { - @Override - public void onIconClicked(View v) { - - } - - @Override - public boolean onLibraryAuthorClicked(View v, Library library) { - return false; - } - - @Override - public boolean onLibraryContentClicked(View v, Library library) { - return false; - } - - @Override - public boolean onLibraryBottomClicked(View v, Library library) { - return false; - } - - @Override - public boolean onExtraClicked(View v, Libs.SpecialButton specialButton) { - if (v.getId() == R.id.aboutSpecial1) { - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.app_url)))); - } else if (v.getId() == R.id.aboutSpecial2) { - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.changelog_url)))); - } - - return false; - } - - @Override - public boolean onIconLongClicked(View v) { - return false; - } - - @Override - public boolean onLibraryAuthorLongClicked(View v, Library library) { - return false; - } - - @Override - public boolean onLibraryContentLongClicked(View v, Library library) { - return false; - } - - @Override - public boolean onLibraryBottomLongClicked(View v, Library library) { - return false; - } - }) - .start(this); - } - - @Override - protected void onDestroy() { - if (syncDisposable != null && !syncDisposable.isDisposed()) - syncDisposable.dispose(); - - super.onDestroy(); - } - - @Override - protected void onSaveInstanceState(@NonNull Bundle outState) { - if (binding.swipeRefreshLayout.isRefreshing()) - outState.putBoolean(SYNCING, true); - - super.onSaveInstanceState(outState); - } -} diff --git a/app/src/main/java/com/readrops/app/itemslist/MainItemListAdapter.java b/app/src/main/java/com/readrops/app/itemslist/MainItemListAdapter.java deleted file mode 100644 index 1476bf21..00000000 --- a/app/src/main/java/com/readrops/app/itemslist/MainItemListAdapter.java +++ /dev/null @@ -1,372 +0,0 @@ -package com.readrops.app.itemslist; - -import android.content.Context; -import android.content.res.Resources; -import android.graphics.drawable.Drawable; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.paging.PagedListAdapter; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.ListPreloader; -import com.bumptech.glide.RequestBuilder; -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.load.resource.bitmap.CenterCrop; -import com.bumptech.glide.load.resource.bitmap.RoundedCorners; -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; -import com.bumptech.glide.request.RequestOptions; -import com.bumptech.glide.request.transition.DrawableCrossFadeFactory; -import com.bumptech.glide.util.ViewPreloadSizeProvider; -import com.readrops.api.utils.DateUtils; -import com.readrops.app.R; -import com.readrops.app.databinding.ListItemBinding; -import com.readrops.app.utils.GlideRequests; -import com.readrops.app.utils.Utils; -import com.readrops.db.entities.Item; -import com.readrops.db.pojo.ItemWithFeed; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -public class MainItemListAdapter extends PagedListAdapter implements ListPreloader.PreloadModelProvider { - - private GlideRequests glideRequests; - private OnItemClickListener listener; - private ViewPreloadSizeProvider preloadSizeProvider; - - private LinkedHashSet selection; - - public MainItemListAdapter(GlideRequests glideRequests, ViewPreloadSizeProvider preloadSizeProvider) { - super(DIFF_CALLBACK); - - this.glideRequests = glideRequests; - this.preloadSizeProvider = preloadSizeProvider; - selection = new LinkedHashSet<>(); - } - - private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { - @Override - public boolean areItemsTheSame(@NonNull ItemWithFeed item, @NonNull ItemWithFeed t1) { - return item.getItem().getId() == t1.getItem().getId(); - } - - @Override - public boolean areContentsTheSame(@NonNull ItemWithFeed itemWithFeed, @NonNull ItemWithFeed t1) { - Item oldItem = itemWithFeed.getItem(); - Item newItem = t1.getItem(); - - boolean folder = false; - if (itemWithFeed.getFolder() != null && t1.getFolder() != null) - folder = itemWithFeed.getFolder().getName().equals(t1.getFolder().getName()); - - return oldItem.getTitle().equals(newItem.getTitle()) && - itemWithFeed.getFeedName().equals(t1.getFeedName()) && - folder && - oldItem.isRead() == newItem.isRead() && - oldItem.isReadItLater() == newItem.isReadItLater() && - itemWithFeed.getColor() == t1.getColor() && - itemWithFeed.getBgColor() == t1.getBgColor(); - } - - @Override - public Object getChangePayload(@NonNull ItemWithFeed oldItem, @NonNull ItemWithFeed newItem) { - return newItem; - } - }; - - private static final DrawableCrossFadeFactory FADE_FACTORY = new DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build(); - - private static final RequestOptions REQUEST_OPTIONS = new RequestOptions().transform(new CenterCrop(), new RoundedCorners(16)); - - @NonNull - @Override - public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { - ListItemBinding binding = ListItemBinding.inflate(LayoutInflater.from(viewGroup.getContext())); - - ItemViewHolder viewHolder = new ItemViewHolder(binding); - preloadSizeProvider.setView(binding.itemImage); - - return viewHolder; - } - - @Override - public void onBindViewHolder(@NonNull ItemViewHolder holder, int position, @NonNull List payloads) { - if (!payloads.isEmpty()) { - ItemWithFeed itemWithFeed = (ItemWithFeed) payloads.get(0); - - holder.bind(itemWithFeed); - holder.applyColors(itemWithFeed); - - if (itemWithFeed.getFolder() != null) - holder.binding.itemFolderName.setText(itemWithFeed.getFolder().getName()); - else - holder.binding.itemFolderName.setText(R.string.no_folder); - - holder.setReadState(itemWithFeed.getItem().isRead()); - holder.setSelected(selection.contains(position)); - } else - onBindViewHolder(holder, position); - } - - @Override - public void onBindViewHolder(@NonNull ItemViewHolder viewHolder, int i) { - ItemWithFeed itemWithFeed = getItem(i); - if (itemWithFeed == null) - return; - - viewHolder.bind(itemWithFeed); - viewHolder.setImages(itemWithFeed); - viewHolder.applyColors(itemWithFeed); - - int minutes = (int) Math.round(itemWithFeed.getItem().getReadTime()); - if (minutes < 1) - viewHolder.binding.itemReadtime.setText(R.string.read_time_lower_than_1); - else if (minutes > 1) - viewHolder.binding.itemReadtime.setText(viewHolder.itemView.getContext(). - getString(R.string.read_time, String.valueOf(minutes))); - else - viewHolder.binding.itemReadtime.setText(R.string.read_time_one_minute); - - if (itemWithFeed.getFolder() != null) - viewHolder.binding.itemFolderName.setText(itemWithFeed.getFolder().getName()); - else - viewHolder.binding.itemFolderName.setText(R.string.no_folder); - - viewHolder.setReadState(itemWithFeed.getItem().isRead()); - viewHolder.setSelected(selection.contains(viewHolder.getAdapterPosition())); - } - - - @Override - public long getItemId(int position) { - return getItem(position).getItem().getId(); - } - - public void toggleSelection(int position) { - if (selection.contains(position)) - selection.remove(position); - else - selection.add(position); - - notifyItemChanged(position, getItem(position)); - } - - public void clearSelection() { - LinkedHashSet localSelection = new LinkedHashSet<>(selection); - selection.clear(); - - for (int position : localSelection) { - notifyItemChanged(position, getItem(position)); - } - } - - public Set getSelection() { - return selection; - } - - public void updateSelection(boolean read) { - for (int position : selection) { - ItemWithFeed itemWithFeed = getItem(position); - itemWithFeed.getItem().setRead(read); - notifyItemChanged(position, itemWithFeed); - } - } - - public void selectAll() { - selection.clear(); - for (int i = 0; i < getItemCount(); i++) { - selection.add(i); - } - - notifyDataSetChanged(); - } - - public void unselectAll() { - selection.clear(); - notifyDataSetChanged(); - } - - public List getSelectedItems() { - List items = new ArrayList<>(); - - for (int i : selection) { - items.add(getItem(i)); - } - - return items; - } - - public void clearData() { - submitList(null); - } - - public ItemWithFeed getItemWithFeed(int i) { - return getItem(i); - } - - @NonNull - @Override - public List getPreloadItems(int position) { - if (getItem(position).getItem().getHasImage()) { - String url = getItem(position).getItem().getImageLink(); - return Collections.singletonList(url); - } else { - return Collections.emptyList(); - } - } - - @Nullable - @Override - public RequestBuilder getPreloadRequestBuilder(@NonNull String url) { - return glideRequests - .load(url) - .centerCrop() - .apply(REQUEST_OPTIONS) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .transition(DrawableTransitionOptions.withCrossFade(FADE_FACTORY)); - } - - public interface OnItemClickListener { - void onItemClick(ItemWithFeed itemWithFeed, int position); - - void onItemLongClick(ItemWithFeed itemWithFeed, int position); - } - - public void setOnItemClickListener(OnItemClickListener listener) { - this.listener = listener; - } - - public class ItemViewHolder extends RecyclerView.ViewHolder { - - private ListItemBinding binding; - private View[] alphaViews; - - ItemViewHolder(ListItemBinding binding) { - super(binding.getRoot()); - this.binding = binding; - - itemView.setOnClickListener((view -> { - int position = getAdapterPosition(); - - if (listener != null && position != RecyclerView.NO_POSITION) - listener.onItemClick(getItem(position), position); - })); - - itemView.setOnLongClickListener(v -> { - int position = getAdapterPosition(); - - if (listener != null && position != RecyclerView.NO_POSITION) - listener.onItemLongClick(getItem(position), position); - - return true; - }); - - alphaViews = new View[]{ - binding.itemDate, - binding.itemFolderName, - binding.itemFeedIcon, - binding.itemFeedName, - binding.itemDescription, - binding.itemTitle, - binding.itemImage, - binding.itemReadtimeLayout - }; - } - - private void bind(ItemWithFeed itemWithFeed) { - Item item = itemWithFeed.getItem(); - - binding.itemTitle.setText(item.getTitle()); - binding.itemDate.setText(DateUtils.formattedDateByLocal(item.getPubDate())); - binding.itemFeedName.setText(itemWithFeed.getFeedName()); - - if (item.getCleanDescription() != null) { - binding.itemDescription.setVisibility(View.VISIBLE); - binding.itemDescription.setText(item.getCleanDescription()); - } else { - binding.itemDescription.setVisibility(View.GONE); - if (itemWithFeed.getItem().getHasImage()) - binding.itemTitle.setMaxLines(4); - } - } - - private void setImages(ItemWithFeed itemWithFeed) { - if (itemWithFeed.getItem().getHasImage()) { - binding.itemImage.setVisibility(View.VISIBLE); - - glideRequests - .load(itemWithFeed.getItem().getImageLink()) - .centerCrop() - .apply(REQUEST_OPTIONS) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .transition(DrawableTransitionOptions.withCrossFade(FADE_FACTORY)) - .into(binding.itemImage); - } else - binding.itemImage.setVisibility(View.GONE); - - if (itemWithFeed.getFeedIconUrl() != null) { - glideRequests. - load(itemWithFeed.getFeedIconUrl()) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .placeholder(R.drawable.ic_rss_feed_grey) - .into(binding.itemFeedIcon); - } else - binding.itemFeedIcon.setImageResource(R.drawable.ic_rss_feed_grey); - } - - private void applyColors(ItemWithFeed itemWithFeed) { - Resources resources = itemView.getResources(); - - if (itemWithFeed.getBgColor() != 0) { - binding.itemFeedName.setTextColor(itemWithFeed.getBgColor()); - Utils.setDrawableColor(binding.itemDate.getBackground(), itemWithFeed.getBgColor()); - - } else if (itemWithFeed.getColor() != 0) { - binding.itemFeedName.setTextColor(itemWithFeed.getColor()); - Utils.setDrawableColor(binding.itemDate.getBackground(), itemWithFeed.getColor()); - - } else if (itemWithFeed.getBgColor() == 0 && itemWithFeed.getColor() == 0) { - binding.itemFeedName.setTextColor(resources.getColor(android.R.color.tab_indicator_text)); - Utils.setDrawableColor(binding.itemDate.getBackground(), - ContextCompat.getColor(itemView.getContext(), R.color.colorPrimary)); - } - } - - private void setReadState(boolean isRead) { - float alpha = isRead ? 0.5f : 1.0f; - for (View view : alphaViews) { - view.setAlpha(alpha); - } - } - - private void setSelected(boolean selected) { - Context context = itemView.getContext(); - TypedValue outValue = new TypedValue(); - - if (selected) { - context.getTheme().resolveAttribute( - android.R.attr.colorControlHighlight, outValue, true); - } else { - context.getTheme().resolveAttribute( - android.R.attr.selectableItemBackground, outValue, true); - } - - itemView.setBackgroundResource(outValue.resourceId); - } - - public ImageView getItemImage() { - return binding.itemImage; - } - } -} diff --git a/app/src/main/java/com/readrops/app/itemslist/MainViewModel.java b/app/src/main/java/com/readrops/app/itemslist/MainViewModel.java deleted file mode 100644 index 8174728a..00000000 --- a/app/src/main/java/com/readrops/app/itemslist/MainViewModel.java +++ /dev/null @@ -1,266 +0,0 @@ -package com.readrops.app.itemslist; - -import androidx.annotation.NonNull; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MediatorLiveData; -import androidx.lifecycle.ViewModel; -import androidx.paging.DataSource; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; - -import com.readrops.app.repositories.ARepository; -import com.readrops.app.repositories.FeedUpdate; -import com.readrops.app.utils.SharedPreferencesManager; -import com.readrops.db.Database; -import com.readrops.db.RoomFactoryWrapper; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.Item; -import com.readrops.db.entities.account.Account; -import com.readrops.db.filters.FilterType; -import com.readrops.db.filters.ListSortType; -import com.readrops.db.pojo.ItemWithFeed; -import com.readrops.db.queries.ItemsQueryBuilder; -import com.readrops.db.queries.QueryFilters; - -import org.koin.core.parameter.ParametersHolderKt; -import org.koin.java.KoinJavaComponent; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import io.reactivex.Completable; -import io.reactivex.Single; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; - -public class MainViewModel extends ViewModel { - - private final MediatorLiveData> itemsWithFeed; - private LiveData> lastFetch; - private ARepository repository; - private final Database database; - - private final QueryFilters queryFilters; - - private Account currentAccount; - private List accounts; - - public MainViewModel(@NonNull Database database) { - this.database = database; - itemsWithFeed = new MediatorLiveData<>(); - - queryFilters = new QueryFilters(); - queryFilters.setShowReadItems(SharedPreferencesManager.readBoolean( - SharedPreferencesManager.SharedPrefKey.SHOW_READ_ARTICLES)); - } - - //region main query - - private void setRepository() { - repository = KoinJavaComponent.get(ARepository.class, null, - () -> ParametersHolderKt.parametersOf(currentAccount)); - } - - private void buildPagedList() { - if (lastFetch != null) { - itemsWithFeed.removeSource(lastFetch); - } - - DataSource.Factory items; - items = database.itemDao().selectAll(ItemsQueryBuilder.buildItemsQuery(queryFilters, currentAccount.getConfig().getUseSeparateState())); - - lastFetch = new LivePagedListBuilder<>(new RoomFactoryWrapper<>(items), - new PagedList.Config.Builder() - .setPageSize(100) - .setPrefetchDistance(150) - .setEnablePlaceholders(false) - .build()) - .build(); - - itemsWithFeed.addSource(lastFetch, itemsWithFeed::setValue); - } - - public void invalidate() { - buildPagedList(); - } - - public void setShowReadItems(boolean showReadItems) { - queryFilters.setShowReadItems(showReadItems); - } - - public boolean showReadItems() { - return queryFilters.getShowReadItems(); - } - - public void setFilterType(FilterType filterType) { - queryFilters.setFilterType(filterType); - } - - public FilterType getFilterType() { - return queryFilters.getFilterType(); - } - - public void setSortType(ListSortType sortType) { - queryFilters.setSortType(sortType); - } - - public ListSortType getSortType() { - return queryFilters.getSortType(); - } - - public void setFilterFeedId(int filterFeedId) { - queryFilters.setFilterFeedId(filterFeedId); - } - - public void setFilerFolderId(int folderId) { - queryFilters.setFilterFolderId(folderId); - } - - public MediatorLiveData> getItemsWithFeed() { - return itemsWithFeed; - } - - public Completable sync(List feeds, FeedUpdate update) { - itemsWithFeed.removeSource(lastFetch); - - // get current viewed feed - if (feeds == null && queryFilters.getFilterType() == FilterType.FEED_FILTER) { - return Single.create(emitter -> emitter.onSuccess(database.feedDao() - .getFeedById(queryFilters.getFilterFeedId()))) - .flatMapCompletable(feed -> repository.sync(Collections.singletonList(feed), update)); - } - - return repository.sync(feeds, update); - } - - public Single getFeedCount() { - return repository.getFeedCount(currentAccount.getId()); - } - - public Single>> getFoldersWithFeeds() { - return repository.getFoldersWithFeeds(); - } - - //endregion - - //region Account - - public LiveData> getAllAccounts() { - return database.accountDao().selectAllAsync(); - } - - private Completable deselectOldCurrentAccount(int accountId) { - return Completable.create(emitter -> { - database.accountDao().deselectOldCurrentAccount(accountId); - emitter.onComplete(); - }); - } - - private Account getAccount(int id) { - for (Account account : accounts) { - if (account.getId() == id) - return account; - } - - return null; - } - - public void addAccount(Account account) { - accounts.add(account); - setCurrentAccount(account); - } - - public Account getCurrentAccount() { - return currentAccount; - } - - public void setCurrentAccount(Account currentAccount) { - this.currentAccount = currentAccount; - setRepository(); - queryFilters.setAccountId(currentAccount.getId()); - buildPagedList(); - - // set the new account as the current one - Completable setCurrentAccount = Completable.create(emitter -> { - database.accountDao().setCurrentAccount(currentAccount.getId()); - emitter.onComplete(); - }); - - Completable.concat(Arrays.asList(setCurrentAccount, deselectOldCurrentAccount(currentAccount.getId()))) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(); - } - - public void setCurrentAccount(int id) { - setCurrentAccount(getAccount(id)); - } - - - public void setAccounts(List accounts) { - this.accounts = accounts; - - boolean currentAccountExists = false; - - for (Account account1 : accounts) { - if (account1.isCurrentAccount()) { - currentAccount = account1; - currentAccountExists = true; - - setRepository(); - queryFilters.setAccountId(currentAccount.getId()); - buildPagedList(); - break; - } - } - - if (!currentAccountExists && !accounts.isEmpty()) { - setCurrentAccount(accounts.get(0)); - accounts.get(0).setCurrentAccount(true); - } - } - - public boolean isAccountLocal() { - return currentAccount.isLocal(); - } - - //endregion - - //region Item read state - - public Completable setItemReadState(ItemWithFeed itemWithFeed) { - return repository.setItemReadState(itemWithFeed.getItem()); - } - - public Completable setItemReadState(Item item) { - return repository.setItemReadState(item); - } - - public Completable setItemsReadState(List items, boolean read) { - List completableList = new ArrayList<>(); - - for (ItemWithFeed itemWithFeed : items) { - itemWithFeed.getItem().setRead(read); - completableList.add(setItemReadState(itemWithFeed)); - } - - return Completable.concat(completableList); - } - - public Completable setAllItemsReadState(boolean read) { - if (queryFilters.getFilterType() == FilterType.FEED_FILTER) - return repository.setAllFeedItemsReadState(queryFilters.getFilterFeedId(), read); - else - return repository.setAllItemsReadState(read); - } - - public Completable setItemReadItLater(boolean readLater, int itemId) { - return database.itemDao().setReadItLater(readLater, itemId); - } - - //endregion -} diff --git a/app/src/main/java/com/readrops/app/more/AboutLibrariesScreen.kt b/app/src/main/java/com/readrops/app/more/AboutLibrariesScreen.kt new file mode 100644 index 00000000..a0772e94 --- /dev/null +++ b/app/src/main/java/com/readrops/app/more/AboutLibrariesScreen.kt @@ -0,0 +1,59 @@ +package com.readrops.app.more + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer +import com.readrops.app.R +import com.readrops.app.util.components.AndroidScreen + +class AboutLibrariesScreen : AndroidScreen() { + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(id = R.string.open_source_libraries)) }, + navigationIcon = { + IconButton( + onClick = { navigator.pop() } + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + LibrariesContainer( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/more/DonationDialog.kt b/app/src/main/java/com/readrops/app/more/DonationDialog.kt new file mode 100644 index 00000000..c7a1eaa1 --- /dev/null +++ b/app/src/main/java/com/readrops/app/more/DonationDialog.kt @@ -0,0 +1,113 @@ +package com.readrops.app.more + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import com.readrops.app.R +import com.readrops.app.util.components.dialog.BaseDialog +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.spacing + +@Composable +fun DonationDialog( + onDismiss: () -> Unit +) { + val uriHandler = LocalUriHandler.current + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + + BaseDialog( + title = stringResource(id = R.string.make_donation), + icon = painterResource(id = R.drawable.ic_donation), + onDismiss = onDismiss + ) { + Column { + Text( + text = stringResource(R.string.donation_text) + ) + + MediumSpacer() + + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { + uriHandler.openUri(context.getString(R.string.paypal_url)) + onDismiss() + } + ) { + Text( + text = stringResource(R.string.paypal), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding( + horizontal = MaterialTheme.spacing.mediumSpacing, + vertical = MaterialTheme.spacing.shortSpacing + ) + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { + clipboardManager.setText(AnnotatedString(context.getString(R.string.bitcoin_address))) + Toast + .makeText( + context, + context.getString(R.string.bitcoin_address), + Toast.LENGTH_SHORT + ) + .show() + onDismiss() + } + ) { + Text( + text = stringResource(R.string.bitcoin_copy_address), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding( + horizontal = MaterialTheme.spacing.mediumSpacing, + vertical = MaterialTheme.spacing.shortSpacing + ) + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { + clipboardManager.setText(AnnotatedString(context.getString(R.string.litecoin_address))) + Toast + .makeText( + context, + context.getString(R.string.litecoin_address), + Toast.LENGTH_SHORT + ) + .show() + onDismiss() + } + ) { + Text( + text = stringResource(R.string.litecoin_copy_address), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding( + horizontal = MaterialTheme.spacing.mediumSpacing, + vertical = MaterialTheme.spacing.shortSpacing + ) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/more/MoreTab.kt b/app/src/main/java/com/readrops/app/more/MoreTab.kt new file mode 100644 index 00000000..c70d536e --- /dev/null +++ b/app/src/main/java/com/readrops/app/more/MoreTab.kt @@ -0,0 +1,178 @@ +package com.readrops.app.more + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabOptions +import com.readrops.app.BuildConfig +import com.readrops.app.R +import com.readrops.app.account.selection.adaptiveIconPainterResource +import com.readrops.app.more.preferences.PreferencesScreen +import com.readrops.app.util.components.IconText +import com.readrops.app.util.components.SelectableIconText +import com.readrops.app.util.openUrl +import com.readrops.app.util.theme.LargeSpacer +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.app.util.theme.spacing +import org.koin.core.component.KoinComponent + +object MoreTab : Tab, KoinComponent { + + override val options: TabOptions + @Composable + get() = TabOptions( + index = 4u, + title = stringResource(R.string.more) + ) + + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val context = LocalContext.current + + var showDonationDialog by remember { mutableStateOf(false) } + + if (showDonationDialog) { + DonationDialog( + onDismiss = { showDonationDialog = false } + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + ) { + LargeSpacer() + + Image( + painter = adaptiveIconPainterResource(id = R.mipmap.ic_launcher_round), + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + + MediumSpacer() + + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge + ) + + ShortSpacer() + + IconText( + text = if (BuildConfig.DEBUG) { + "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" + } else { + "v${BuildConfig.VERSION_NAME}" + }, + icon = painterResource(id = R.drawable.ic_version), + style = MaterialTheme.typography.labelLarge + ) + + ShortSpacer() + + Text( + text = stringResource(id = R.string.app_licence), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + ShortSpacer() + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + IconButton( + onClick = { context.openUrl(context.getString(R.string.app_url)) } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_github), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + + IconButton( + onClick = { context.openUrl(context.getString(R.string.changelog_url)) } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_changelog), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + + IconButton( + onClick = { context.openUrl(context.getString(R.string.app_issues_url)) } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_bug_report), + contentDescription = null + ) + } + } + + MediumSpacer() + + SelectableIconText( + icon = painterResource(id = R.drawable.ic_settings), + text = stringResource(R.string.settings), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), + spacing = MaterialTheme.spacing.largeSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + tint = MaterialTheme.colorScheme.primary, + onClick = { navigator.push(PreferencesScreen()) } + ) + + SelectableIconText( + icon = painterResource(id = R.drawable.ic_library), + text = stringResource(id = R.string.open_source_libraries), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), + spacing = MaterialTheme.spacing.largeSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + tint = MaterialTheme.colorScheme.primary, + onClick = { navigator.push(AboutLibrariesScreen()) } + ) + + SelectableIconText( + icon = painterResource(id = R.drawable.ic_donation), + text = stringResource(id = R.string.make_donation), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), + spacing = MaterialTheme.spacing.largeSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + tint = MaterialTheme.colorScheme.primary, + onClick = { showDonationDialog = true } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreen.kt b/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreen.kt new file mode 100644 index 00000000..6d6a7a65 --- /dev/null +++ b/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreen.kt @@ -0,0 +1,193 @@ +package com.readrops.app.more.preferences + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import cafe.adriel.voyager.koin.getScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.readrops.app.R +import com.readrops.app.more.preferences.components.BasePreference +import com.readrops.app.more.preferences.components.ListPreferenceWidget +import com.readrops.app.more.preferences.components.PreferenceHeader +import com.readrops.app.more.preferences.components.SwitchPreferenceWidget +import com.readrops.app.sync.SyncWorker +import com.readrops.app.util.components.AndroidScreen +import com.readrops.app.util.components.CenteredProgressIndicator +import kotlinx.coroutines.launch + + +class PreferencesScreen : AndroidScreen() { + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val context = LocalContext.current + val screenModel = getScreenModel() + + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + val state by screenModel.state.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(id = R.string.preferences)) }, + navigationIcon = { + IconButton( + onClick = { navigator.pop() } + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = null + ) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Box( + modifier = Modifier.padding(paddingValues) + ) { + when (state) { + is PreferencesScreenState.Loading -> { + CenteredProgressIndicator() + } + + else -> { + val loadedState = (state as PreferencesScreenState.Loaded) + + Column { + PreferenceHeader(text = stringResource(id = R.string.global)) + + ListPreferenceWidget( + preference = loadedState.themePref.second, + selectedKey = loadedState.themePref.first, + entries = mapOf( + "light" to stringResource(id = R.string.light), + "dark" to stringResource(id = R.string.dark), + "system" to stringResource(id = R.string.system) + ), + title = stringResource(id = R.string.theme), + onValueChange = {} + ) + + ListPreferenceWidget( + preference = loadedState.backgroundSyncPref.second, + selectedKey = loadedState.backgroundSyncPref.first, + entries = mapOf( + "manual" to stringResource(id = R.string.manual), + "0.30" to stringResource(id = R.string.min_30), + "1" to stringResource(id = R.string.hour_1), + "2" to stringResource(id = R.string.hour_2), + "3" to stringResource(id = R.string.hour_3), + "6" to stringResource(id = R.string.hour_6), + "12" to stringResource(id = R.string.hour_12), + "24" to stringResource(id = R.string.every_day) + ), + title = stringResource(id = R.string.auto_synchro), + onValueChange = { SyncWorker.startPeriodically(context, it) } + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + BasePreference( + title = stringResource(R.string.disable_battery_optimization), + subtitle = stringResource(R.string.disable_battery_optimization_subtitle), + onClick = { + val powerManager = + context.getSystemService("power") as PowerManager + val packageName = context.packageName + + if (!powerManager.isIgnoringBatteryOptimizations(packageName)) { + @SuppressLint("BatteryLife") + val intent = Intent().apply { + action = + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = Uri.parse("package:$packageName") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(intent) + } else { + coroutineScope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.battery_optimization_already_disabled)) + } + } + } + ) + } + + PreferenceHeader(text = stringResource(id = R.string.timeline)) + + ListPreferenceWidget( + preference = loadedState.timelineItemSize.second, + selectedKey = loadedState.timelineItemSize.first, + entries = mapOf( + "compact" to stringResource(id = R.string.compact), + "regular" to stringResource(id = R.string.regular), + "large" to stringResource(id = R.string.large) + ), + title = stringResource(id = R.string.item_size), + onValueChange = {} + ) + + SwitchPreferenceWidget( + preference = loadedState.hideReadFeeds.second, + isChecked = loadedState.hideReadFeeds.first, + title = stringResource(id = R.string.hide_feeds), + subtitle = stringResource(R.string.hide_feeds_subtitle) + ) + + SwitchPreferenceWidget( + preference = loadedState.scrollReadPref.second, + isChecked = loadedState.scrollReadPref.first, + title = stringResource(id = R.string.mark_items_read_on_scroll) + ) + + PreferenceHeader(text = stringResource(id = R.string.item_view)) + + ListPreferenceWidget( + preference = loadedState.openLinksWith.second, + selectedKey = loadedState.openLinksWith.first, + entries = mapOf( + "navigator_view" to stringResource(id = R.string.navigator_view), + "external_navigator" to stringResource(id = R.string.external_navigator) + ), + title = stringResource(id = R.string.open_items_in), + onValueChange = {} + ) + } + } + } + } + } + } +} + diff --git a/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreenModel.kt b/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreenModel.kt new file mode 100644 index 00000000..3d043223 --- /dev/null +++ b/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreenModel.kt @@ -0,0 +1,63 @@ +package com.readrops.app.more.preferences + +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.readrops.app.util.Preference +import com.readrops.app.util.Preferences +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +typealias PreferenceState = Pair> + +class PreferencesScreenModel( + preferences: Preferences, + dispatcher: CoroutineDispatcher = Dispatchers.IO +) : StateScreenModel(PreferencesScreenState.Loading) { + + init { + screenModelScope.launch(dispatcher) { + val flows = listOf( + preferences.theme.flow, + preferences.backgroundSynchronization.flow, + preferences.scrollRead.flow, + preferences.hideReadFeeds.flow, + preferences.openLinksWith.flow, + preferences.timelineItemSize.flow + ) + + combine( + flows + ) { list -> + PreferencesScreenState.Loaded( + themePref = (list[0] as String) to preferences.theme, + backgroundSyncPref = (list[1] as String) to preferences.backgroundSynchronization, + scrollReadPref = (list[2] as Boolean) to preferences.scrollRead, + hideReadFeeds = (list[3] as Boolean) to preferences.hideReadFeeds, + openLinksWith = (list[4] as String) to preferences.openLinksWith, + timelineItemSize = (list[5] as String) to preferences.timelineItemSize + ) + }.collect { theme -> + mutableState.update { theme } + } + } + } + +} + +sealed class PreferencesScreenState { + data object Loading : PreferencesScreenState() + data object Error : PreferencesScreenState() + + data class Loaded( + val themePref: PreferenceState, + val backgroundSyncPref: PreferenceState, + val scrollReadPref: PreferenceState, + val hideReadFeeds: PreferenceState, + val openLinksWith: PreferenceState, + val timelineItemSize: PreferenceState + ) : PreferencesScreenState() + +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/more/preferences/components/BasePreference.kt b/app/src/main/java/com/readrops/app/more/preferences/components/BasePreference.kt new file mode 100644 index 00000000..288f4e26 --- /dev/null +++ b/app/src/main/java/com/readrops/app/more/preferences/components/BasePreference.kt @@ -0,0 +1,63 @@ +package com.readrops.app.more.preferences.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.spacing + +@Composable +fun BasePreference( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + rightComponent: (@Composable () -> Unit)? = null +) { + Box( + modifier = modifier.clickable { onClick() } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal, + maxLines = 2 + ) + + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + if (rightComponent != null) { + MediumSpacer() + + rightComponent() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/more/preferences/components/ListPreferenceWidget.kt b/app/src/main/java/com/readrops/app/more/preferences/components/ListPreferenceWidget.kt new file mode 100644 index 00000000..f76e7025 --- /dev/null +++ b/app/src/main/java/com/readrops/app/more/preferences/components/ListPreferenceWidget.kt @@ -0,0 +1,61 @@ +package com.readrops.app.more.preferences.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import com.readrops.app.util.Preference +import kotlinx.coroutines.launch + +@Composable +fun ListPreferenceWidget( + preference: Preference, + selectedKey: T, + entries: Map, + title: String, + modifier: Modifier = Modifier, + onValueChange: (T) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + var showDialog by remember { mutableStateOf(false) } + + if (showDialog) { + val values = remember { + entries.map { entry -> + ToggleableInfo( + key = entry.key, + text = entry.value, + isSelected = selectedKey == entry.key + ) + }.toMutableStateList() + } + + RadioButtonPreferenceDialog( + title = title, + entries = values, + onCheckChange = { newKey -> + onValueChange(newKey) + + values.replaceAll { + it.copy(isSelected = it.key == newKey) + } + + coroutineScope.launch { + preference.write(newKey) + } + }, + onDismiss = { showDialog = false } + ) + } + + BasePreference( + title = title, + subtitle = entries[selectedKey], + onClick = { showDialog = true }, + modifier = modifier + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/more/preferences/components/PreferenceHeader.kt b/app/src/main/java/com/readrops/app/more/preferences/components/PreferenceHeader.kt new file mode 100644 index 00000000..1824b340 --- /dev/null +++ b/app/src/main/java/com/readrops/app/more/preferences/components/PreferenceHeader.kt @@ -0,0 +1,23 @@ +package com.readrops.app.more.preferences.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.readrops.app.util.theme.spacing + +@Composable +fun PreferenceHeader( + text: String +) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding( + horizontal = MaterialTheme.spacing.mediumSpacing, + vertical = MaterialTheme.spacing.shortSpacing + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/more/preferences/components/RadioButtonPreferenceDialog.kt b/app/src/main/java/com/readrops/app/more/preferences/components/RadioButtonPreferenceDialog.kt new file mode 100644 index 00000000..669af088 --- /dev/null +++ b/app/src/main/java/com/readrops/app/more/preferences/components/RadioButtonPreferenceDialog.kt @@ -0,0 +1,133 @@ +package com.readrops.app.more.preferences.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.readrops.app.R +import com.readrops.app.util.theme.LargeSpacer +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.spacing + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PreferenceBaseDialog( + title: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + BasicAlertDialog( + onDismissRequest = onDismiss + ) { + Surface( + tonalElevation = AlertDialogDefaults.TonalElevation, + shape = AlertDialogDefaults.shape, + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start, + modifier = modifier + .padding(MaterialTheme.spacing.largeSpacing) + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = AlertDialogDefaults.titleContentColor + ) + + MediumSpacer() + + content() + } + } + } +} + +data class ToggleableInfo( + val key: T, + val text: String, + val isSelected: Boolean +) + +@Composable +fun RadioButtonPreferenceDialog( + title: String, + entries: List>, + onCheckChange: (T) -> Unit, + onDismiss: () -> Unit +) { + PreferenceBaseDialog( + title = title, + onDismiss = onDismiss + ) { + Column( + horizontalAlignment = Alignment.Start + ) { + entries.forEach { entry -> + RadioButtonItem( + text = entry.text, + isSelected = entry.isSelected, + onClick = { onCheckChange(entry.key) } + ) + } + + MediumSpacer() + + TextButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.End) + ) { + Text(text = stringResource(id = R.string.cancel)) + } + } + } +} + +@Composable +fun RadioButtonItem( + text: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Box( + modifier = Modifier.clickable { onClick() } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = MaterialTheme.spacing.shortSpacing, + vertical = MaterialTheme.spacing.veryShortSpacing + ) + ) { + RadioButton( + selected = isSelected, + onClick = onClick + ) + + LargeSpacer() + + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} diff --git a/app/src/main/java/com/readrops/app/more/preferences/components/SwitchPreferenceWidget.kt b/app/src/main/java/com/readrops/app/more/preferences/components/SwitchPreferenceWidget.kt new file mode 100644 index 00000000..1e36b5c0 --- /dev/null +++ b/app/src/main/java/com/readrops/app/more/preferences/components/SwitchPreferenceWidget.kt @@ -0,0 +1,40 @@ +package com.readrops.app.more.preferences.components + +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.readrops.app.util.Preference +import kotlinx.coroutines.launch + +@Composable +fun SwitchPreferenceWidget( + preference: Preference, + isChecked: Boolean, + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, +) { + val coroutineScope = rememberCoroutineScope() + + BasePreference( + title = title, + subtitle = subtitle, + onClick = { + coroutineScope.launch { + preference.write(!isChecked) + } + }, + rightComponent = { + Switch( + checked = isChecked, + onCheckedChange = { + coroutineScope.launch { + preference.write(!isChecked) + } + } + ) + }, + modifier = modifier + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/notifications/NotificationItem.kt b/app/src/main/java/com/readrops/app/notifications/NotificationItem.kt new file mode 100644 index 00000000..4555c22b --- /dev/null +++ b/app/src/main/java/com/readrops/app/notifications/NotificationItem.kt @@ -0,0 +1,81 @@ +package com.readrops.app.notifications + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import com.readrops.app.util.components.FeedIcon +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.spacing + +@Composable +fun NotificationItem( + feedName: String, + iconUrl: String?, + folderName: String?, + checked: Boolean, + enabled: Boolean, + onCheckChange: (Boolean) -> Unit, +) { + Box( + modifier = Modifier.clickable { onCheckChange(!checked) } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = MaterialTheme.spacing.mediumSpacing, + vertical = MaterialTheme.spacing.shortSpacing + ) + ) { + FeedIcon( + iconUrl = iconUrl, + name = feedName + ) + + MediumSpacer() + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f, fill = true) + ) { + Text( + text = feedName, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (folderName != null) { + Text( + text = folderName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + + MediumSpacer() + + Switch( + checked = checked, + enabled = enabled, + onCheckedChange = onCheckChange, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/notifications/NotificationPermissionActivity.kt b/app/src/main/java/com/readrops/app/notifications/NotificationPermissionActivity.kt deleted file mode 100644 index 46230e4e..00000000 --- a/app/src/main/java/com/readrops/app/notifications/NotificationPermissionActivity.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.readrops.app.notifications - -import android.content.Intent -import android.os.Bundle -import android.view.MenuItem -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.Observer -import androidx.recyclerview.widget.LinearLayoutManager -import com.afollestad.materialdialogs.MaterialDialog -import com.readrops.app.R -import com.readrops.app.settings.SettingsActivity -import com.readrops.app.databinding.ActivityNotificationPermissionBinding -import com.readrops.app.utils.ReadropsKeys -import com.readrops.app.utils.ReadropsKeys.ACCOUNT_ID -import com.readrops.app.utils.SharedPreferencesManager -import com.readrops.app.utils.Utils -import com.readrops.db.entities.Feed -import com.readrops.db.entities.account.Account -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.schedulers.Schedulers -import org.koin.androidx.viewmodel.ext.android.getViewModel - -class NotificationPermissionActivity : AppCompatActivity() { - - private lateinit var binding: ActivityNotificationPermissionBinding - private lateinit var viewModel: NotificationPermissionViewModel - private var adapter: NotificationPermissionListAdapter? = null - - private var isFirstCheck = true - private var feedStateChanged = false - private var feeds = listOf() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityNotificationPermissionBinding.inflate(layoutInflater) - setContentView(binding.root) - - setTitle(R.string.notifications) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - val accountId = intent.getIntExtra(ACCOUNT_ID, 0) - - viewModel = getViewModel() - viewModel.getAccount(accountId).observe(this, Observer { account -> - viewModel.account = account - - if (adapter == null) { - // execute the method only once - setupUI(account) - } - }) - } - - private fun setupUI(account: Account) { - binding.notifPermissionAccountSwitch.isChecked = account.isNotificationsEnabled - binding.notifPermissionAccountSwitch.setOnCheckedChangeListener { _, isChecked -> - account.isNotificationsEnabled = isChecked - binding.notifPermissionFeedsSwitch.isEnabled = isChecked - - adapter?.enableAll = isChecked - adapter?.notifyDataSetChanged() - - viewModel.setAccountNotificationsState(isChecked) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError { Utils.showSnackbar(binding.root, it.message) } - .subscribe() - - if (isChecked) displayAutoSynchroPopup() - } - - binding.notifPermissionFeedsSwitch.isEnabled = account.isNotificationsEnabled - binding.notifPermissionFeedsSwitch.setOnCheckedChangeListener { _, isChecked -> - if (canUpdateAllFeedsPermissions(isChecked)) { - viewModel.setAllFeedsNotificationState(isChecked) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError { Utils.showSnackbar(binding.root, it.message) } - .subscribe() - } - - if (isFirstCheck) isFirstCheck = false - if (feedStateChanged) feedStateChanged = false - } - - adapter = NotificationPermissionListAdapter(account.isNotificationsEnabled) { feed -> - feedStateChanged = true - - viewModel.setFeedNotificationState(feed) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError { Utils.showSnackbar(binding.root, it.message) } - .subscribe() - } - - binding.notifPermissionAccountList.layoutManager = LinearLayoutManager(this) - binding.notifPermissionAccountList.adapter = adapter - - viewModel.getFeedsWithNotifPermission().observe(this, Observer { newFeeds -> - feeds = newFeeds - - binding.notifPermissionFeedsSwitch.isChecked = newFeeds.all { it.isNotificationEnabled } - adapter?.submitList(newFeeds) - }) - } - - /** - * Inform if is possible to update all feeds notifications permissions in the same time. - * The method takes into account the following states : - * - first check : when opening the activity with all feeds permissions enabled, - * the enable all feeds permissions switch will be checked but the request mustn't be executed - * - feed state : if all feeds permissions are enabled and a feed permission is disabled, - * the enable all feeds permissions switch will be unchecked but the request mustn't be executed as only one feed permission is disabled - * - all feeds permissions switch checked : if the setOnCheckedChangeListener method is triggered because all feeds permissions were enabled, - * do not execute the request as it would be pointless - */ - private fun canUpdateAllFeedsPermissions(isChecked: Boolean): Boolean { - return (!isFirstCheck || !feeds.all { it.isNotificationEnabled }) && - (!feedStateChanged || (isChecked && !feeds.all { it.isNotificationEnabled })) - } - - private fun displayAutoSynchroPopup() { - val autoSynchroValue = SharedPreferencesManager.readString(SharedPreferencesManager.SharedPrefKey.AUTO_SYNCHRO) - - if (autoSynchroValue.toFloat() <= 0) { - MaterialDialog.Builder(this) - .title(R.string.auto_synchro_disabled) - .content(R.string.enable_auto_synchro_text) - .positiveText(R.string.open) - .neutralText(R.string.cancel) - .onPositive { _, _ -> - val intent = Intent(this, SettingsActivity::class.java).apply { - putExtra(ReadropsKeys.SETTINGS, SettingsActivity.SettingsKey.SETTINGS.ordinal) - } - - startActivity(intent) - } - .show() - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> finish() - } - - return super.onOptionsItemSelected(item) - } -} diff --git a/app/src/main/java/com/readrops/app/notifications/NotificationPermissionListAdapter.kt b/app/src/main/java/com/readrops/app/notifications/NotificationPermissionListAdapter.kt deleted file mode 100644 index 01cd67f0..00000000 --- a/app/src/main/java/com/readrops/app/notifications/NotificationPermissionListAdapter.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.readrops.app.notifications - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.readrops.app.R -import com.readrops.app.databinding.NotificationPermissionLayoutBinding -import com.readrops.app.utils.GlideRequests -import com.readrops.db.entities.Feed -import org.koin.core.component.KoinComponent -import org.koin.core.component.get - -class NotificationPermissionListAdapter(var enableAll: Boolean, val listener: (feed: Feed) -> Unit) : - ListAdapter(DIFF_CALLBACK), KoinComponent { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationPermissionViewHolder { - val binding = NotificationPermissionLayoutBinding.inflate(LayoutInflater.from(parent.context)) - - return NotificationPermissionViewHolder(binding) - } - - override fun onBindViewHolder(holder: NotificationPermissionViewHolder, position: Int) { - val feed = getItem(position) - - holder.binding.notificationFeedName.text = feed.name - holder.binding.notificationSwitch.isChecked = feed.isNotificationEnabled - - holder.binding.notificationSwitch.isEnabled = enableAll - - holder.itemView.setOnClickListener { if (enableAll) listener(getItem(position)) } - - get() - .load(feed.iconUrl) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .placeholder(R.drawable.ic_rss_feed_grey) - .into(holder.binding.notificationFeedIcon) - } - - override fun onBindViewHolder(holder: NotificationPermissionViewHolder, position: Int, payloads: MutableList) { - if (payloads.isNotEmpty()) { - val feed = payloads.first() as Feed - holder.binding.notificationSwitch.isChecked = feed.isNotificationEnabled - } else onBindViewHolder(holder, position) - } - - inner class NotificationPermissionViewHolder(val binding: NotificationPermissionLayoutBinding) : - RecyclerView.ViewHolder(binding.root) - - companion object { - val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Feed, newItem: Feed): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: Feed, newItem: Feed): Boolean { - return oldItem.isNotificationEnabled == newItem.isNotificationEnabled - } - - override fun getChangePayload(oldItem: Feed, newItem: Feed): Any? { - return newItem - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/notifications/NotificationPermissionViewModel.kt b/app/src/main/java/com/readrops/app/notifications/NotificationPermissionViewModel.kt deleted file mode 100644 index d555f8a0..00000000 --- a/app/src/main/java/com/readrops/app/notifications/NotificationPermissionViewModel.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.readrops.app.notifications - -import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import com.readrops.db.Database -import com.readrops.db.entities.Feed -import com.readrops.db.entities.account.Account -import io.reactivex.Completable - -class NotificationPermissionViewModel(val database: Database) : ViewModel() { - - var account: Account? = null - - fun getAccount(accountId: Int): LiveData = database.accountDao().selectAsync(accountId) - - fun getFeedsWithNotifPermission(): LiveData> = database.feedDao() - .getFeedsForNotifPermission(account?.id!!) - - fun setAccountNotificationsState(enabled: Boolean): Completable = database.accountDao() - .updateNotificationState(account?.id!!, enabled) - - fun setFeedNotificationState(feed: Feed): Completable = database.feedDao() - .updateFeedNotificationState(feed.id, !feed.isNotificationEnabled) - - fun setAllFeedsNotificationState(enabled: Boolean): Completable = database.feedDao() - .updateAllFeedsNotificationState(account?.id!!, enabled) -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/notifications/NotificationsScreen.kt b/app/src/main/java/com/readrops/app/notifications/NotificationsScreen.kt new file mode 100644 index 00000000..de74cbf7 --- /dev/null +++ b/app/src/main/java/com/readrops/app/notifications/NotificationsScreen.kt @@ -0,0 +1,251 @@ +package com.readrops.app.notifications + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import cafe.adriel.voyager.koin.getScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import com.readrops.app.R +import com.readrops.app.more.preferences.PreferencesScreen +import com.readrops.app.more.preferences.components.BasePreference +import com.readrops.app.util.components.AndroidScreen +import com.readrops.app.util.components.CenteredProgressIndicator +import com.readrops.app.util.components.Placeholder +import com.readrops.app.util.components.ThreeDotsMenu +import com.readrops.app.util.components.dialog.TwoChoicesDialog +import com.readrops.app.util.theme.LargeSpacer +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.spacing +import com.readrops.db.entities.account.Account +import org.koin.core.parameter.parametersOf + +class NotificationsScreen(val account: Account) : AndroidScreen() { + + @SuppressLint("InlinedApi") + @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val context = LocalContext.current + val screenModel = getScreenModel { parametersOf(account) } + + val state by screenModel.state.collectAsStateWithLifecycle() + + val topAppBarScrollBehavior = + TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + + val permissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) + val launcher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { + screenModel.refreshNotificationManager() + } + + LaunchedEffect(permissionState.status) { + if (permissionState.status.isGranted) { + screenModel.refreshNotificationManager() + } + } + + if (state.showBackgroundSyncDialog) { + TwoChoicesDialog( + title = stringResource(id = R.string.auto_synchro_disabled), + text = stringResource(id = R.string.enable_auto_synchro_text), + icon = painterResource(id = R.drawable.ic_sync), + confirmText = stringResource(id = R.string.open), + dismissText = stringResource(id = R.string.cancel), + onDismiss = { screenModel.setBackgroundSyncDialogState(false) }, + onConfirm = { + screenModel.setBackgroundSyncDialogState(false) + navigator.push(PreferencesScreen()) + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(id = R.string.notifications)) }, + navigationIcon = { + IconButton( + onClick = { navigator.pop() } + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = null + ) + } + }, + actions = { + if (state.feedsWithFolderState is FeedsWithFolderState.Loaded) { + val loadedState = + state.feedsWithFolderState as FeedsWithFolderState.Loaded + + if (loadedState.feedsWithFolder.isNotEmpty()) { + ThreeDotsMenu( + items = mapOf( + 1 to if (loadedState.allFeedNotificationsEnabled) { + stringResource(id = R.string.disable_all) + } else { + stringResource(id = R.string.enable_all) + } + ), + onItemClick = { + screenModel.setAllFeedsNotificationsState(!loadedState.allFeedNotificationsEnabled) + } + ) + } + } + }, + scrollBehavior = topAppBarScrollBehavior + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) + ) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU + && !state.areNotificationsEnabled + ) { + item { + BasePreference( + title = stringResource(R.string.grant_access_notifications), + subtitle = stringResource(R.string.system_notifications_disabled), + onClick = { + if (!permissionState.status.shouldShowRationale) { + val intent = Intent().apply { + action = Settings.ACTION_APP_NOTIFICATION_SETTINGS + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + + launcher.launch(intent) + } else { + permissionState.launchPermissionRequest() + } + } + ) + } + } + + item { + BasePreference( + title = stringResource(R.string.show_notifications_background_sync), + subtitle = stringResource(R.string.background_sync_new_articles), + onClick = { + screenModel.setAccountNotificationsState(!state.areAccountNotificationsEnabled) + + if (state.areAccountNotificationsEnabled.not()) { + screenModel.setBackgroundSyncDialogState(true) + } + }, + rightComponent = { + Switch( + checked = state.areAccountNotificationsEnabled, + onCheckedChange = { + screenModel.setAccountNotificationsState(it) + + if (it) { + screenModel.setBackgroundSyncDialogState(true) + } + } + ) + } + ) + + MediumSpacer() + + Text( + text = stringResource(id = R.string.feeds), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing) + ) + } + + when (state.feedsWithFolderState) { + is FeedsWithFolderState.Loaded -> { + val feedsWithFolder = + (state.feedsWithFolderState as FeedsWithFolderState.Loaded).feedsWithFolder + + if (feedsWithFolder.isNotEmpty()) { + items( + items = (state.feedsWithFolderState as FeedsWithFolderState.Loaded).feedsWithFolder, + key = { it.feed.id } + ) { feedWithFolder -> + NotificationItem( + feedName = feedWithFolder.feed.name!!, + iconUrl = feedWithFolder.feed.iconUrl, + folderName = feedWithFolder.folderName, + checked = feedWithFolder.feed.isNotificationEnabled, + enabled = state.areAccountNotificationsEnabled, + onCheckChange = { + if (state.areAccountNotificationsEnabled) { + screenModel.setFeedNotificationsState( + feedWithFolder.feed.id, + it + ) + } + } + ) + } + } else { + item { + LargeSpacer() + + Placeholder( + text = stringResource(id = R.string.no_feed), + painter = painterResource(id = R.drawable.ic_rss_feed_grey), + ) + } + } + } + + FeedsWithFolderState.Loading -> { + item { + repeat(4) { + LargeSpacer() + } + + CenteredProgressIndicator() + } + } + } + } + } + } +} + diff --git a/app/src/main/java/com/readrops/app/notifications/NotificationsScreenModel.kt b/app/src/main/java/com/readrops/app/notifications/NotificationsScreenModel.kt new file mode 100644 index 00000000..0d6ab660 --- /dev/null +++ b/app/src/main/java/com/readrops/app/notifications/NotificationsScreenModel.kt @@ -0,0 +1,105 @@ +package com.readrops.app.notifications + +import androidx.core.app.NotificationManagerCompat +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.readrops.app.util.Preferences +import com.readrops.db.Database +import com.readrops.db.entities.account.Account +import com.readrops.db.pojo.FeedWithFolder +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class NotificationsScreenModel( + private val account: Account, + private val database: Database, + private val preferences: Preferences, + private val notificationManager: NotificationManagerCompat, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : StateScreenModel( + NotificationsState( + areNotificationsEnabled = notificationManager.areNotificationsEnabled(), + areAccountNotificationsEnabled = account.isNotificationsEnabled + ) +) { + + init { + screenModelScope.launch(dispatcher) { + database.accountDao().selectAccountNotificationsState(account.id) + .collect { isNotificationsEnabled -> + mutableState.update { it.copy(areAccountNotificationsEnabled = isNotificationsEnabled) } + } + } + + screenModelScope.launch(dispatcher) { + database.feedDao().selectFeedsWithFolderName(account.id) + .collect { feedsWithFolder -> + mutableState.update { + it.copy( + feedsWithFolderState = FeedsWithFolderState.Loaded(feedsWithFolder) + ) + } + } + } + + screenModelScope.launch(dispatcher) { + preferences.backgroundSynchronization.flow + .collect { sync -> + mutableState.update { it.copy(isBackGroundSyncEnabled = sync != "manual") } + } + } + } + + fun setAccountNotificationsState(enabled: Boolean) { + screenModelScope.launch(dispatcher) { + database.accountDao().updateNotificationState(account.id, enabled) + } + } + + fun setFeedNotificationsState(feedId: Int, enabled: Boolean) { + screenModelScope.launch(dispatcher) { + database.feedDao().updateFeedNotificationState(feedId, enabled) + } + } + + fun setAllFeedsNotificationsState(enabled: Boolean) { + screenModelScope.launch(dispatcher) { + database.feedDao().updateAllFeedsNotificationState(account.id, enabled) + } + } + + fun setBackgroundSyncDialogState(visible: Boolean) { + when { + visible && !state.value.isBackGroundSyncEnabled -> { + mutableState.update { it.copy(showBackgroundSyncDialog = visible) } + } + !visible -> { + mutableState.update { it.copy(showBackgroundSyncDialog = visible) } + } + } + } + + fun refreshNotificationManager() { + mutableState.update { it.copy(areNotificationsEnabled = notificationManager.areNotificationsEnabled()) } + } +} + +data class NotificationsState( + val areAccountNotificationsEnabled: Boolean = false, + val feedsWithFolderState: FeedsWithFolderState = FeedsWithFolderState.Loading, + val showBackgroundSyncDialog: Boolean = false, + val isBackGroundSyncEnabled: Boolean = false, + val areNotificationsEnabled: Boolean = false +) + +sealed class FeedsWithFolderState { + data object Loading : FeedsWithFolderState() + + data class Loaded(val feedsWithFolder: List) : FeedsWithFolderState() { + + val allFeedNotificationsEnabled + get() = feedsWithFolder.none { !it.feed.isNotificationEnabled } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/notifications/sync/SyncResultAnalyser.kt b/app/src/main/java/com/readrops/app/notifications/sync/SyncResultAnalyser.kt deleted file mode 100644 index cf6c24eb..00000000 --- a/app/src/main/java/com/readrops/app/notifications/sync/SyncResultAnalyser.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.readrops.app.notifications.sync - -import android.content.Context -import androidx.core.content.ContextCompat -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.readrops.app.R -import com.readrops.db.Database -import com.readrops.db.entities.Feed -import com.readrops.db.entities.Item -import com.readrops.db.entities.account.Account -import com.readrops.api.services.SyncResult -import com.readrops.app.utils.GlideRequests -import com.readrops.app.utils.Utils -import org.koin.core.component.KoinComponent -import org.koin.core.component.get - -/** - * Simple class to get synchro notification content (title, content and largeIcon) according to some rules - */ -class SyncResultAnalyser(val context: Context, private val syncResults: Map, val database: Database) : KoinComponent { - - private val notifContent = SyncResultNotifContent() - - fun getSyncNotifContent(): SyncResultNotifContent { - if (newItemsInMultipleAccounts()) { // new items from several accounts - var itemCount = 0 - val feeds = database.feedDao().selectFromIdList(getFeedsIdsForNewItems(syncResults)) - - syncResults.values.forEach { syncResult -> - itemCount += syncResult.items.filter { isFeedNotificationEnabledForItem(feeds, it) }.size - } - - notifContent.title = context.getString(R.string.new_items, itemCount.toString()) - } else { // new items from only one account - val syncResultMap = syncResults.filterValues { it.items.isNotEmpty() } - - if (syncResultMap.values.isNotEmpty()) { - val syncResult = syncResultMap.values.first() - val account = syncResultMap.keys.first() - val feedsIdsForNewItems = getFeedsIdsForNewItems(syncResult) - - notifContent.accountId = account.id - - if (account.isNotificationsEnabled) { - val feeds = database.feedDao().selectFromIdList(feedsIdsForNewItems) - - val items = syncResult.items.filter { isFeedNotificationEnabledForItem(feeds, it) } - val itemCount = items.size - - // new items from several feeds from one account - if (feedsIdsForNewItems.size > 1 && itemCount > 1) { - notifContent.title = account.accountName - notifContent.content = context.getString(R.string.new_items, itemCount.toString()) - notifContent.largeIcon = Utils.getBitmapFromDrawable(ContextCompat.getDrawable(context, account.accountType!!.iconRes)) - } else if (feedsIdsForNewItems.size == 1) // new items from only one feed from one account - oneFeedCase(feedsIdsForNewItems.first(), syncResult.items) - else if (itemCount == 1) - oneFeedCase(items.first().feedId.toLong(), items) - } - } - } - - return notifContent - } - - private fun oneFeedCase(feedId: Long, items: List) { - val feed = database.feedDao().getFeedById(feedId.toInt()) - - if (feed.isNotificationEnabled) { - notifContent.title = feed?.name - - feed?.iconUrl?.let { - val target = get() - .asBitmap() - .load(it) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .submit() - - notifContent.largeIcon = target.get() - } - - if (items.size == 1) { - val item = database.itemDao().selectByRemoteId(items.first().remoteId!!, - items.first().feedId) - notifContent.content = item.title - notifContent.item = item - } else notifContent.content = context.getString(R.string.new_items, items.size.toString()) - } - } - - private fun newItemsInMultipleAccounts(): Boolean { - val itemsNotEmptyByAccount = mutableListOf() - - for ((account, syncResult) in syncResults) { - if (account.isNotificationsEnabled) itemsNotEmptyByAccount += syncResult.items.isNotEmpty() - } - - // return true it there is at least two true booleans in the list - return itemsNotEmptyByAccount.groupingBy { it }.eachCount()[true] ?: 0 > 1 - } - - private fun getFeedsIdsForNewItems(syncResult: SyncResult): List { - val feedsIds = mutableListOf() - - syncResult.items.forEach { - if (it.feedId.toLong() !in feedsIds) - feedsIds += it.feedId.toLong() - } - - return feedsIds - } - - private fun getFeedsIdsForNewItems(syncResults: Map): List { - val feedsIds = mutableListOf() - - syncResults.values.forEach { feedsIds += getFeedsIdsForNewItems(it) } - return feedsIds - } - - private fun isFeedNotificationEnabledForItem(feeds: List, item: Item): Boolean = - feeds.find { it.id == item.feedId }?.isNotificationEnabled!! -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/notifications/sync/SyncResultDebugData.kt b/app/src/main/java/com/readrops/app/notifications/sync/SyncResultDebugData.kt deleted file mode 100644 index 847a2730..00000000 --- a/app/src/main/java/com/readrops/app/notifications/sync/SyncResultDebugData.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.readrops.app.notifications.sync - -import com.readrops.api.services.SyncResult -import com.readrops.db.Database -import com.readrops.db.entities.Item -import com.readrops.db.entities.account.Account -import com.readrops.db.entities.account.AccountType -import org.jetbrains.annotations.TestOnly -import org.koin.core.component.KoinComponent -import org.koin.core.component.get - -class SyncResultDebugData { - - companion object : KoinComponent { - - @TestOnly - fun oneAccountOneFeedOneItem(): Map { - val database = get() - val account1 = database.accountDao().select(2) - - - val item = database.itemDao().select(5000) - // database.feedDao().updateNotificationState(item.feedId, false).subscribe() - - return mutableMapOf().apply { - put(account1, SyncResult().apply { items = mutableListOf(item) }) - } - } - - @TestOnly - fun oneAccountOneFeedMultipleItems(): Map { - val account1 = Account().apply { - id = 1 - accountType = AccountType.FRESHRSS - isNotificationsEnabled = true - } - - val database = get() - val item = database.itemDao().select(5055) - database.feedDao().updateFeedNotificationState(item.feedId, false).subscribe() - - val item2 = database.itemDao().select(5056) - - return mutableMapOf().apply { - put(account1, SyncResult().apply { items = listOf(item, item2) }) - } - } - - @TestOnly - fun oneAccountMultipleFeeds(): Map { - val account1 = Account().apply { - accountName = "Test account" - id = 1 - accountType = AccountType.FRESHRSS - isNotificationsEnabled = true - } - - val item1 = Item().apply { - id = 1 - title = "oneAccountMultipleFeeds" - feedId = 1 - } - - val item2 = Item().apply { - id = 2 - title = "oneAccountMultipleFeeds" - feedId = 2 - } - - return mutableMapOf().apply { - put(account1, SyncResult().apply { items = mutableListOf(item1, item2) }) - } - } - - fun multipleAccounts(): Map { - val account1 = Account().apply { - id = 1 - accountType = AccountType.FRESHRSS - isNotificationsEnabled = true - } - - val account2 = Account().apply { - id = 2 - accountType = AccountType.LOCAL - isNotificationsEnabled = true - } - - val item = Item().apply { - id = 1 - title = "multipleAccountsCase" - feedId = 90 - } - - return mutableMapOf().apply { - put(account1, SyncResult().apply { items = mutableListOf(item) }) - put(account2, SyncResult().apply { items = mutableListOf(item) }) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/notifications/sync/SyncResultNotifContent.kt b/app/src/main/java/com/readrops/app/notifications/sync/SyncResultNotifContent.kt deleted file mode 100644 index 0e1891cf..00000000 --- a/app/src/main/java/com/readrops/app/notifications/sync/SyncResultNotifContent.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.readrops.app.notifications.sync - -import android.graphics.Bitmap -import com.readrops.db.entities.Item - -class SyncResultNotifContent { - var title: String? = null - var content: String? = null - var largeIcon: Bitmap? = null - var item: Item? = null - var accountId: Int? = null -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/notifications/sync/SyncWorker.kt b/app/src/main/java/com/readrops/app/notifications/sync/SyncWorker.kt deleted file mode 100644 index c91fa1a6..00000000 --- a/app/src/main/java/com/readrops/app/notifications/sync/SyncWorker.kt +++ /dev/null @@ -1,189 +0,0 @@ -package com.readrops.app.notifications.sync - -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.work.Worker -import androidx.work.WorkerParameters -import com.readrops.api.services.SyncResult -import com.readrops.app.R -import com.readrops.app.ReadropsApp -import com.readrops.app.itemslist.MainActivity -import com.readrops.app.repositories.ARepository -import com.readrops.app.utils.ReadropsKeys -import com.readrops.app.utils.SharedPreferencesManager -import com.readrops.db.Database -import com.readrops.db.entities.Item -import com.readrops.db.entities.account.Account -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers -import org.koin.core.component.KoinComponent -import org.koin.core.component.get -import org.koin.core.parameter.parametersOf - -class SyncWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters), KoinComponent { - - private var disposable: Disposable? = null - - private val notificationManager = NotificationManagerCompat.from(applicationContext) - private val database = get() - - override fun doWork(): Result { - var result = Result.success() - val syncResults = mutableMapOf() - - try { - val accounts = database.accountDao().selectAll() - - val notificationBuilder = NotificationCompat.Builder(applicationContext, ReadropsApp.SYNC_CHANNEL_ID) - .setContentTitle(applicationContext.getString(R.string.auto_synchro)) - .setProgress(0, 0, true) - .setSmallIcon(R.drawable.ic_notif) - .setOnlyAlertOnce(true) - - accounts.forEach { - notificationBuilder.setContentText(it.accountName) - notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build()) - - it.login = SharedPreferencesManager.readString(it.loginKey) - it.password = SharedPreferencesManager.readString(it.passwordKey) - - val repository = get(parameters = { parametersOf(it) }) - - disposable = repository.sync(null, null) - .doOnError { throwable -> - result = Result.failure() - Log.e(TAG, throwable.message!!, throwable) - } - .subscribe() - - if (repository.syncResult != null) syncResults[it] = repository.syncResult - } - } catch (e: Exception) { - Log.e(TAG, e.message!!) - result = Result.failure() - } finally { - notificationManager.cancel(SYNC_NOTIFICATION_ID) - displaySyncResultNotif(syncResults) - - return result - } - } - - override fun onStopped() { - super.onStopped() - - disposable?.dispose() - notificationManager.cancel(SYNC_NOTIFICATION_ID) - } - - private fun displaySyncResultNotif(syncResults: Map) { - val notifContent = SyncResultAnalyser(applicationContext, syncResults, database) - .getSyncNotifContent() - - if (notifContent.title != null) { - val intent = Intent(applicationContext, MainActivity::class.java).apply { - if (notifContent.item != null) { - putExtra(ReadropsKeys.ITEM_ID, notifContent.item?.id) - putExtra(ReadropsKeys.IMAGE_URL, notifContent.item?.imageLink) - - if (notifContent.accountId != null) putExtra(ReadropsKeys.ACCOUNT_ID, notifContent.accountId!!) - } - } - - val notificationBuilder = NotificationCompat.Builder(applicationContext, ReadropsApp.SYNC_CHANNEL_ID) - .setContentTitle(notifContent.title) - .setContentText(notifContent.content) - .setStyle(NotificationCompat.BigTextStyle().bigText(notifContent.content)) - .setSmallIcon(R.drawable.ic_notif) - .setContentIntent(PendingIntent.getActivity(applicationContext, 0, - intent, PendingIntent.FLAG_UPDATE_CURRENT)) - .setAutoCancel(true) - - notifContent.item?.let { - val feed = database.feedDao().getFeedById(it.feedId) - - notificationBuilder.addAction(buildReadlaterAction(it)) - .addAction(buildMarkAsRead(it)) - .setColor(if (feed.backgroundColor != 0) feed.backgroundColor else feed.textColor) - } - - notifContent.largeIcon?.let { - notificationBuilder.setLargeIcon(it) - } - - notificationManager.notify(SYNC_RESULT_NOTIFICATION_ID, notificationBuilder.build()) - } - - } - - private fun buildReadlaterAction(item: Item): NotificationCompat.Action { - val broadcastIntent = Intent(applicationContext, ReadLaterReceiver::class.java).apply { - putExtra(ReadropsKeys.ITEM_ID, item.id) - } - - return NotificationCompat.Action.Builder(R.drawable.ic_read_later, applicationContext.getString(R.string.read_later), - PendingIntent.getBroadcast(applicationContext, 0, broadcastIntent, PendingIntent.FLAG_UPDATE_CURRENT)) - .setAllowGeneratedReplies(false) - .build() - } - - private fun buildMarkAsRead(item: Item): NotificationCompat.Action { - val broadcastIntent = Intent(applicationContext, MarkReadReceiver::class.java).apply { - putExtra(ReadropsKeys.ITEM_ID, item.id) - } - - return NotificationCompat.Action.Builder(R.drawable.ic_read, applicationContext.getString(R.string.read), - PendingIntent.getBroadcast(applicationContext, 0, broadcastIntent, PendingIntent.FLAG_UPDATE_CURRENT)) - .setAllowGeneratedReplies(false) - .build() - } - - class MarkReadReceiver : BroadcastReceiver(), KoinComponent { - - override fun onReceive(context: Context?, intent: Intent?) { - val itemId = intent?.getIntExtra(ReadropsKeys.ITEM_ID, 0)!! - - with(get()) { - itemDao().setReadState(itemId, true) - .subscribeOn(Schedulers.io()) - .subscribe() - } - - with(NotificationManagerCompat.from(context!!)) { - cancel(SYNC_RESULT_NOTIFICATION_ID) - } - } - } - - class ReadLaterReceiver : BroadcastReceiver(), KoinComponent { - - override fun onReceive(context: Context?, intent: Intent?) { - val itemId = intent?.getIntExtra(ReadropsKeys.ITEM_ID, 0)!! - - with(get()) { - val item = itemDao().select(itemId) - item.isReadItLater = !item.isReadItLater - - itemDao().setReadItLater(item.isReadItLater, itemId) - .subscribeOn(Schedulers.io()) - .subscribe() - } - - with(NotificationManagerCompat.from(context!!)) { - cancel(SYNC_RESULT_NOTIFICATION_ID) - } - } - - } - - companion object { - val TAG = SyncWorker::class.java.simpleName - private const val SYNC_NOTIFICATION_ID = 2 - const val SYNC_RESULT_NOTIFICATION_ID = 3 - } -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/repositories/ARepository.java b/app/src/main/java/com/readrops/app/repositories/ARepository.java deleted file mode 100644 index ac234c8f..00000000 --- a/app/src/main/java/com/readrops/app/repositories/ARepository.java +++ /dev/null @@ -1,207 +0,0 @@ -package com.readrops.app.repositories; - -import static com.readrops.app.utils.ReadropsKeys.FEEDS; - -import android.content.Context; -import android.content.Intent; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.readrops.api.services.Credentials; -import com.readrops.api.services.SyncResult; -import com.readrops.api.utils.AuthInterceptor; -import com.readrops.app.addfeed.FeedInsertionResult; -import com.readrops.app.addfeed.ParsingResult; -import com.readrops.app.utils.feedscolors.FeedColorsKt; -import com.readrops.app.utils.feedscolors.FeedsColorsIntentService; -import com.readrops.db.Database; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.Item; -import com.readrops.db.entities.ItemState; -import com.readrops.db.entities.account.Account; - -import org.koin.java.KoinJavaComponent; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; - -import io.reactivex.Completable; -import io.reactivex.Single; - -public abstract class ARepository { - - protected Context context; - protected Database database; - protected Account account; - - protected SyncResult syncResult; - - protected ARepository(Database database, @NonNull Context context, @Nullable Account account) { - this.context = context; - this.database = database; - this.account = account; - - setCredentials(account); - } - - protected void setCredentials(@Nullable Account account) { - KoinJavaComponent.get(AuthInterceptor.class) - .setCredentials(account != null && !account.isLocal() ? Credentials.toCredentials(account) : null); - } - - public abstract Completable login(Account account, boolean insert); - - public abstract Completable sync(@Nullable List feeds, @Nullable FeedUpdate update); - - public abstract Single> addFeeds(List results); - - public Completable insertOPMLFoldersAndFeeds(Map> foldersAndFeeds) { - List completableList = new ArrayList<>(); - - for (Map.Entry> entry : foldersAndFeeds.entrySet()) { - Folder folder = entry.getKey(); - folder.setAccountId(account.getId()); - - Completable completable = Single.create(emitter -> { - Folder dbFolder = database.folderDao().getFolderByName(folder.getName(), account.getId()); - - if (dbFolder != null) - emitter.onSuccess(dbFolder.getId()); - else - emitter.onSuccess((int) database.folderDao().compatInsert(folder)); - }).flatMap(folderId -> { - List feeds = entry.getValue(); - for (Feed feed : feeds) { - feed.setFolderId(folderId); - } - - List parsingResults = ParsingResult.toParsingResults(feeds); - return addFeeds(parsingResults); - }).flatMapCompletable(feedInsertionResults -> Completable.complete()); - - completableList.add(completable); - } - - return Completable.concat(completableList); - } - - public Completable updateFeed(Feed feed) { - return Completable.create(emitter -> { - database.feedDao().updateFeedFields(feed.getId(), feed.getName(), feed.getUrl(), feed.getFolderId()); - emitter.onComplete(); - }); - } - - public Completable deleteFeed(Feed feed) { - return database.feedDao().delete(feed); - } - - public Single addFolder(Folder folder) { - return database.folderDao().insert(folder); - } - - public Completable updateFolder(Folder folder) { - return database.folderDao().update(folder); - } - - public Completable deleteFolder(Folder folder) { - return database.folderDao().delete(folder); - } - - public Completable setItemReadState(Item item) { - if (account.getConfig().getUseSeparateState()) { - return database.itemStateChangesDao().upsertItemReadStateChange(item, account.getId(), true) - .andThen(database.itemStateDao().upsertItemReadState(new ItemState(0, item.isRead(), - item.isStarred(), item.getRemoteId(), account.getId()))); - } else if (account.isLocal()) { - return database.itemDao().setReadState(item.getId(), item.isRead()); - } else { // nextcloud case - return database.itemStateChangesDao().upsertItemReadStateChange(item, account.getId(), false) - .andThen(database.itemDao().setReadState(item.getId(), item.isRead())); - } - - } - - public Completable setAllItemsReadState(boolean read) { - if (account.isLocal()) { // TODO see if it's possible to implement for others accounts - return database.itemDao().setAllItemsReadState(read ? 1 : 0, account.getId()); - } else { - return Completable.complete(); - } - } - - public Completable setAllFeedItemsReadState(int feedId, boolean read) { - if (account.isLocal()) { - return database.itemDao().setAllFeedItemsReadState(feedId, read ? 1 : 0); - } else { - return Completable.complete(); - } - } - - public Completable setItemStarState(Item item) { - if (account.getConfig().getUseSeparateState()) { - return database.itemStateChangesDao().upsertItemStarStateChange(item, account.getId(), true) - .andThen(database.itemStateDao().upsertItemStarState(new ItemState(0, item.isRead(), - item.isStarred(), item.getRemoteId(), account.getId()))); - } else if (account.isLocal()) { - return database.itemDao().setStarState(item.getId(), item.isRead()); - } else { // nextcloud case - return database.itemStateChangesDao().upsertItemStarStateChange(item, account.getId(), false) - .andThen(database.itemDao().setStarState(item.getId(), item.isStarred())); - } - } - - public Single getFeedCount(int accountId) { - return database.feedDao().getFeedCount(accountId); - } - - public Single>> getFoldersWithFeeds() { - return Single.create(emitter -> { - List folders = database.folderDao().getFolders(account.getId()); - Map> foldersWithFeeds = new TreeMap<>(Comparator.nullsLast(Folder::compareTo)); - - for (Folder folder : folders) { - List feeds = database.feedDao().getFeedsByFolder(folder.getId()); - - for (Feed feed : feeds) { - int unreadCount = database.itemDao().getUnreadCount(feed.getId()); - feed.setUnreadCount(unreadCount); - } - - foldersWithFeeds.put(folder, feeds); - } - - // feeds without folder - List feedsWithoutFolder = database.feedDao().getFeedsWithoutFolder(account.getId()); - for (Feed feed : feedsWithoutFolder) { - feed.setUnreadCount(database.itemDao().getUnreadCount(feed.getId())); - } - - foldersWithFeeds.put(null, feedsWithoutFolder); - - emitter.onSuccess(foldersWithFeeds); - }); - } - - protected void setFeedColors(Feed feed) { - FeedColorsKt.setFeedColors(feed); - database.feedDao().updateColors(feed.getId(), - feed.getTextColor(), feed.getBackgroundColor()); - } - - protected void setFeedsColors(List feeds) { - Intent intent = new Intent(context, FeedsColorsIntentService.class); - intent.putParcelableArrayListExtra(FEEDS, new ArrayList<>(feeds)); - - context.startService(intent); - } - - public SyncResult getSyncResult() { - return syncResult; - } -} diff --git a/app/src/main/java/com/readrops/app/repositories/FeedUpdate.kt b/app/src/main/java/com/readrops/app/repositories/FeedUpdate.kt deleted file mode 100644 index 8280f116..00000000 --- a/app/src/main/java/com/readrops/app/repositories/FeedUpdate.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.readrops.app.repositories - -import com.readrops.db.entities.Feed - -interface FeedUpdate { - - fun onNext(feed: Feed) - -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/repositories/FeverRepository.kt b/app/src/main/java/com/readrops/app/repositories/FeverRepository.kt index 2a28d283..c22f46d4 100644 --- a/app/src/main/java/com/readrops/app/repositories/FeverRepository.kt +++ b/app/src/main/java/com/readrops/app/repositories/FeverRepository.kt @@ -1,5 +1,6 @@ package com.readrops.app.repositories +/* import android.content.Context import android.util.Log import com.readrops.api.services.SyncType @@ -30,7 +31,7 @@ class FeverRepository( database: Database, context: Context, account: Account?, -) : ARepository(database, context, account) { +) : BaseRepository(database, context, account) { override fun login(account: Account, insert: Boolean): Completable = rxCompletable(context = dispatcher) { @@ -226,4 +227,4 @@ class FeverRepository( companion object { val TAG: String = FeverRepository::class.java.simpleName } -} \ No newline at end of file +}*/ diff --git a/app/src/main/java/com/readrops/app/repositories/FreshRSSRepository.java b/app/src/main/java/com/readrops/app/repositories/FreshRSSRepository.java deleted file mode 100644 index 880513b1..00000000 --- a/app/src/main/java/com/readrops/app/repositories/FreshRSSRepository.java +++ /dev/null @@ -1,301 +0,0 @@ -package com.readrops.app.repositories; - -import android.content.Context; -import android.util.Log; -import android.util.TimingLogger; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.readrops.api.services.SyncType; -import com.readrops.api.services.freshrss.FreshRSSDataSource; -import com.readrops.api.services.freshrss.FreshRSSSyncData; -import com.readrops.app.addfeed.FeedInsertionResult; -import com.readrops.app.addfeed.ParsingResult; -import com.readrops.app.utils.Utils; -import com.readrops.db.Database; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.Item; -import com.readrops.db.entities.ItemState; -import com.readrops.db.entities.account.Account; -import com.readrops.db.pojo.ItemReadStarState; - -import org.joda.time.DateTime; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import io.reactivex.Completable; -import io.reactivex.Single; - -public class FreshRSSRepository extends ARepository { - - private static final String TAG = FreshRSSRepository.class.getSimpleName(); - - private final FreshRSSDataSource dataSource; - - public FreshRSSRepository(FreshRSSDataSource dataSource, Database database, @NonNull Context context, @Nullable Account account) { - super(database, context, account); - - this.dataSource = dataSource; - } - - @Override - public Completable login(Account account, boolean insert) { - setCredentials(account); - - return dataSource.login(account.getLogin(), account.getPassword()) - .flatMap(token -> { - account.setToken(token); - setCredentials(account); - - return dataSource.getWriteToken(); - }) - .flatMap(writeToken -> { - account.setWriteToken(writeToken); - - return dataSource.getUserInfo(); - }) - .flatMapCompletable(userInfo -> { - account.setDisplayedName(userInfo.getUserName()); - - if (insert) { - return database.accountDao().insert(account) - .flatMapCompletable(id -> { - account.setId(id.intValue()); - - return Completable.complete(); - }); - } - - return Completable.complete(); - }); - } - - @Override - public Completable sync(@Nullable List feeds, @Nullable FeedUpdate update) { - FreshRSSSyncData syncData = new FreshRSSSyncData(); - SyncType syncType; - - if (account.getLastModified() != 0) { - syncType = SyncType.CLASSIC_SYNC; - syncData.setLastModified(account.getLastModified()); - } else - syncType = SyncType.INITIAL_SYNC; - - long newLastModified = DateTime.now().getMillis() / 1000L; - TimingLogger logger = new TimingLogger(TAG, "FreshRSS sync timer"); - - return Single.create(emitter -> { - List itemStateChanges = database - .itemStateChangesDao() - .getItemStateChanges(account.getId()); - - syncData.setReadItemsIds(itemStateChanges.stream() - .filter(it -> it.getReadChange() && it.getRead()) - .map(ItemReadStarState::getRemoteId) - .collect(Collectors.toList())); - - syncData.setUnreadItemsIds(itemStateChanges.stream() - .filter(it -> it.getReadChange() && !it.getRead()) - .map(ItemReadStarState::getRemoteId) - .collect(Collectors.toList())); - - syncData.setStarredItemsIds(itemStateChanges.stream() - .filter(it -> it.getStarChange() && it.getStarred()) - .map(ItemReadStarState::getRemoteId) - .collect(Collectors.toList())); - - syncData.setUnstarredItemsIds(itemStateChanges.stream() - .filter(it -> it.getStarChange() && !it.getStarred()) - .map(ItemReadStarState::getRemoteId) - .collect(Collectors.toList())); - - emitter.onSuccess(syncData); - }).flatMap(syncData1 -> dataSource.sync(syncType, syncData1, account.getWriteToken())) - .flatMapCompletable(syncResult -> { - logger.addSplit("server queries"); - - insertFolders(syncResult.getFolders()); - logger.addSplit("folders insertion"); - insertFeeds(syncResult.getFeeds()); - logger.addSplit("feeds insertion"); - - insertItems(syncResult.getItems(), false); - logger.addSplit("items insertion"); - - insertItems(syncResult.getStarredItems(), true); - logger.addSplit("starred items insertion"); - - insertItemsIds(syncResult.getUnreadIds(), syncResult.getReadIds(), syncResult.getStarredIds()); - logger.addSplit("insert and update items ids"); - - account.setLastModified(newLastModified); - database.accountDao().updateLastModified(account.getId(), newLastModified); - - database.itemStateChangesDao().resetStateChanges(account.getId()); - - logger.dumpToLog(); - - this.syncResult = syncResult; - - return Completable.complete(); - }); - } - - @Override - public Single> addFeeds(List results) { - List completableList = new ArrayList<>(); - List insertionResults = new ArrayList<>(); - - for (ParsingResult result : results) { - completableList.add(dataSource.createFeed(account.getWriteToken(), result.getUrl()) - .doOnComplete(() -> { - FeedInsertionResult feedInsertionResult = new FeedInsertionResult(); - feedInsertionResult.setParsingResult(result); - insertionResults.add(feedInsertionResult); - }).onErrorResumeNext(throwable -> { - Log.d(TAG, throwable.getMessage()); - - FeedInsertionResult feedInsertionResult = new FeedInsertionResult(); - - feedInsertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.ERROR); - feedInsertionResult.setParsingResult(result); - insertionResults.add(feedInsertionResult); - - return Completable.complete(); - })); - } - - return Completable.concat(completableList) - .andThen(Single.just(insertionResults)); - } - - @Override - public Completable updateFeed(Feed feed) { - return Single.create(emitter -> { - Folder folder = feed.getFolderId() == null ? null : database.folderDao().select(feed.getFolderId()); - emitter.onSuccess(folder); - - }).flatMapCompletable(folder -> dataSource.updateFeed(account.getWriteToken(), - feed.getUrl(), feed.getName(), folder == null ? null : folder.getRemoteId()) - .andThen(super.updateFeed(feed))); - } - - @Override - public Completable deleteFeed(Feed feed) { - return dataSource.deleteFeed(account.getWriteToken(), feed.getUrl()) - .andThen(super.deleteFeed(feed)); - } - - @Override - public Single addFolder(Folder folder) { - return dataSource.createFolder(account.getWriteToken(), folder.getName()) - .andThen(super.addFolder(folder)); - } - - @Override - public Completable updateFolder(Folder folder) { - return dataSource.updateFolder(account.getWriteToken(), folder.getRemoteId(), folder.getName()) - .andThen(Completable.create(emitter -> { - folder.setRemoteId("user/-/label/" + folder.getName()); - emitter.onComplete(); - })) - .andThen(super.updateFolder(folder)); - } - - @Override - public Completable deleteFolder(Folder folder) { - return dataSource.deleteFolder(account.getWriteToken(), folder.getRemoteId()) - .andThen(super.deleteFolder(folder)); - } - - private void insertFeeds(List freshRSSFeeds) { - freshRSSFeeds.stream().forEach(feed -> feed.setAccountId(account.getId())); - - List insertedFeedsIds = database.feedDao().feedsUpsert(freshRSSFeeds, account); - - if (!insertedFeedsIds.isEmpty()) { - setFeedsColors(database.feedDao().selectFromIdList(insertedFeedsIds)); - } - - } - - private void insertFolders(List freshRSSFolders) { - freshRSSFolders.stream().forEach(folder -> folder.setAccountId(account.getId())); - - database.folderDao().foldersUpsert(freshRSSFolders, account); - } - - private void insertItems(List items, boolean starredItems) { - List itemsToInsert = new ArrayList<>(); - Map itemsFeedsIds = new HashMap<>(); - - for (Item item : items) { - Integer feedId; - if (itemsFeedsIds.containsKey(item.getFeedRemoteId())) { - feedId = itemsFeedsIds.get(item.getFeedRemoteId()); - } else { - feedId = database.feedDao().getFeedIdByRemoteId(item.getFeedRemoteId(), account.getId()); - itemsFeedsIds.put(item.getFeedRemoteId(), feedId); - } - - item.setFeedId(feedId); - if (item.getText() != null) { - item.setReadTime(Utils.readTimeFromString(item.getText())); - } - - - // workaround to avoid inserting starred items coming from the main item call - // as the API exclusion filter doesn't seem to work - if (!starredItems) { - if (!item.isStarred()) { - itemsToInsert.add(item); - } - } else { - itemsToInsert.add(item); - } - } - - if (!itemsToInsert.isEmpty()) { - Collections.sort(itemsToInsert, Item::compareTo); - database.itemDao().insert(itemsToInsert); - } - } - - private void insertItemsIds(List unreadIds, List readIds, List starredIds) { - database.itemStateDao().deleteItemsStates(account.getId()); - - database.itemStateDao().insertItemStates(unreadIds.stream().map(id -> { - boolean starred = starredIds.stream().filter(starredId -> starredId.equals(id)).count() == 1; - if (starred) { - starredIds.remove(id); - } - - return new ItemState(0, false, starred, id, account.getId()); - } - ).collect(Collectors.toList())); - - database.itemStateDao().insertItemStates(readIds.stream().map(id -> { - boolean starred = starredIds.stream().filter(starredId -> starredId.equals(id)).count() == 1; - if (starred) { - starredIds.remove(id); - } - - return new ItemState(0, true, starred, id, account.getId()); - } - ).collect(Collectors.toList())); - - // insert starred items ids which are read - if (!starredIds.isEmpty()) { - database.itemStateDao().insertItemStates(starredIds.stream().map(id -> - new ItemState(0, true, true, id, account.getId())) - .collect(Collectors.toList())); - } - } -} diff --git a/app/src/main/java/com/readrops/app/repositories/FreshRSSRepository.kt b/app/src/main/java/com/readrops/app/repositories/FreshRSSRepository.kt new file mode 100644 index 00000000..eb269488 --- /dev/null +++ b/app/src/main/java/com/readrops/app/repositories/FreshRSSRepository.kt @@ -0,0 +1,231 @@ +package com.readrops.app.repositories + +import com.readrops.api.services.Credentials +import com.readrops.api.services.SyncType +import com.readrops.api.services.freshrss.FreshRSSDataSource +import com.readrops.api.services.freshrss.FreshRSSSyncData +import com.readrops.api.utils.AuthInterceptor +import com.readrops.app.util.Utils +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.Item +import com.readrops.db.entities.ItemState +import com.readrops.db.entities.account.Account +import org.koin.core.component.KoinComponent + +class FreshRSSRepository( + database: Database, + account: Account, + private val dataSource: FreshRSSDataSource, +) : BaseRepository(database, account), KoinComponent { + + override suspend fun login(account: Account) { + val authInterceptor = getKoin().get() + authInterceptor.credentials = Credentials.toCredentials(account) + + account.token = dataSource.login(account.login!!, account.password!!) + // we got the authToken, time to provide it to make real calls + authInterceptor.credentials = Credentials.toCredentials(account) + + account.writeToken = dataSource.getWriteToken() + + val userInfo = dataSource.getUserInfo() + account.displayedName = userInfo.userName + } + + override suspend fun synchronize( + selectedFeeds: List, + onUpdate: suspend (Feed) -> Unit + ): Pair = throw NotImplementedError("This method can't be called here") + + override suspend fun synchronize(): SyncResult { + val itemStateChanges = database.itemStateChangeDao() + .selectItemStateChanges(account.id) + + val syncData = FreshRSSSyncData( + readIds = itemStateChanges.filter { it.readChange && it.read } + .map { it.remoteId }, + unreadIds = itemStateChanges.filter { it.readChange && !it.read } + .map { it.remoteId }, + starredIds = itemStateChanges.filter { it.starChange && it.starred } + .map { it.remoteId }, + unstarredIds = itemStateChanges.filter { it.starChange && !it.starred } + .map { it.remoteId } + ) + + val syncType: SyncType + if (account.lastModified != 0L) { + syncType = SyncType.CLASSIC_SYNC + syncData.lastModified = account.lastModified + } else { + syncType = SyncType.INITIAL_SYNC + } + + val newLastModified = System.currentTimeMillis() / 1000L + + return dataSource.synchronize(syncType, syncData, account.writeToken!!).run { + insertFolders(folders) + val newFeeds = insertFeeds(feeds) + + val newItems = insertItems(items, false) + insertItems(starredItems, true) + + insertItemsIds(unreadIds, readIds, starredIds.toMutableList()) + + account.lastModified = newLastModified + database.accountDao().updateLastModified(newLastModified, account.id) + + database.itemStateChangeDao().resetStateChanges(account.id) + + SyncResult( + items = newItems, + feeds = newFeeds + ) + } + } + + override suspend fun insertNewFeeds( + newFeeds: List, + onUpdate: (Feed) -> Unit + ): ErrorResult { + val errors = hashMapOf() + + for (newFeed in newFeeds) { + onUpdate(newFeed) + + try { + dataSource.createFeed(account.writeToken!!, newFeed.url!!) + } catch (e: Exception) { + errors[newFeed] = e + } + } + + return errors + } + + override suspend fun updateFeed(feed: Feed) { + dataSource.updateFeed(account.writeToken!!, feed.url!!, feed.name!!, feed.remoteFolderId!!) + super.updateFeed(feed) + } + + override suspend fun deleteFeed(feed: Feed) { + dataSource.deleteFeed(account.writeToken!!, feed.url!!) + super.deleteFeed(feed) + } + + override suspend fun updateFolder(folder: Folder) { + dataSource.updateFolder(account.writeToken!!, folder.remoteId!!, folder.name!!) + folder.remoteId = FreshRSSDataSource.FOLDER_PREFIX + folder.name + + super.updateFolder(folder) + } + + override suspend fun deleteFolder(folder: Folder) { + dataSource.deleteFolder(account.writeToken!!, folder.remoteId!!) + super.deleteFolder(folder) + } + + private suspend fun insertFeeds(feeds: List): List { + feeds.forEach { it.accountId = account.id } + return database.feedDao().upsertFeeds(feeds, account) + } + + private suspend fun insertFolders(folders: List) { + folders.forEach { it.accountId = account.id } + database.folderDao().upsertFolders(folders, account) + } + + private suspend fun insertItems(items: List, starredItems: Boolean): List { + val newItems = arrayListOf() + val itemsFeedsIds = mutableMapOf() + + for (item in items) { + val feedId: Int + if (itemsFeedsIds.containsKey(item.feedRemoteId)) { + feedId = itemsFeedsIds.getValue(item.feedRemoteId) + } else { + feedId = + database.feedDao().selectRemoteFeedLocalId(item.feedRemoteId!!, account.id) + itemsFeedsIds[item.feedRemoteId] = feedId + } + + item.feedId = feedId + + if (item.text != null) { + item.readTime = Utils.readTimeFromString(item.text!!) + } + + // workaround to avoid inserting starred items coming from the main item call + // as the API exclusion filter doesn't seem to work + if (!starredItems) { + if (!item.isStarred) { + newItems.add(item) + } + } else { + newItems.add(item) + } + } + + if (newItems.isNotEmpty()) { + newItems.sortWith(Item::compareTo) + database.itemDao().insert(newItems) + .zip(newItems) + .forEach { (id, item) -> item.id = id.toInt() } + } + + return newItems + } + + private suspend fun insertItemsIds( + unreadIds: List, + readIds: List, + starredIds: MutableList // TODO is it performance wise? + ) { + database.itemStateDao().deleteItemStates(account.id) + + database.itemStateDao().insert(unreadIds.map { id -> + val starred = starredIds.count { starredId -> starredId == id } == 1 + + if (starred) { + starredIds.remove(id) + } + + ItemState( + id = 0, + read = false, + starred = starred, + remoteId = id, + accountId = account.id + ) + }) + + database.itemStateDao().insert(readIds.map { id -> + val starred = starredIds.count { starredId -> starredId == id } == 1 + if (starred) { + starredIds.remove(id) + } + + ItemState( + id = 0, + read = true, + starred = starred, + remoteId = id, + accountId = account.id + ) + }) + + // insert starred items ids which are read + if (starredIds.isNotEmpty()) { + database.itemStateDao().insert(starredIds.map { id -> + ItemState( + 0, + read = true, + starred = true, + remoteId = id, + accountId = account.id + ) + }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/repositories/GetFoldersWithFeeds.kt b/app/src/main/java/com/readrops/app/repositories/GetFoldersWithFeeds.kt new file mode 100644 index 00000000..4127e2ec --- /dev/null +++ b/app/src/main/java/com/readrops/app/repositories/GetFoldersWithFeeds.kt @@ -0,0 +1,73 @@ +package com.readrops.app.repositories + +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.filters.MainFilter +import com.readrops.db.queries.FeedUnreadCountQueryBuilder +import com.readrops.db.queries.FoldersAndFeedsQueryBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class GetFoldersWithFeeds( + private val database: Database, +) { + + fun get( + accountId: Int, + mainFilter: MainFilter, + useSeparateState: Boolean, + hideReadFeeds: Boolean = false + ): Flow>> { + val foldersAndFeedsQuery = FoldersAndFeedsQueryBuilder.build(accountId, hideReadFeeds) + val unreadItemsCountQuery = FeedUnreadCountQueryBuilder.build(accountId, mainFilter, useSeparateState) + + return combine( + flow = database.folderDao().selectFoldersAndFeeds(foldersAndFeedsQuery), + flow2 = database.itemDao().selectFeedUnreadItemsCount(unreadItemsCountQuery) + ) { folders, itemCounts -> + val foldersWithFeeds = folders.groupBy( + keySelector = { + if (it.folderId != null) { + Folder( + id = it.folderId!!, + name = it.folderName, + remoteId = it.folderRemoteId, + accountId = it.accountId + ) + } else { + null + } + }, + valueTransform = { + Feed( + id = it.feedId, + name = it.feedName, + iconUrl = it.feedIcon, + url = it.feedUrl, + siteUrl = it.feedSiteUrl, + description = it.feedDescription, + remoteId = it.feedRemoteId, + unreadCount = itemCounts[it.feedId] ?: 0 + ) + } + ).mapValues { listEntry -> + // Empty folder case + if (listEntry.value.any { it.id == 0 }) { + listOf() + } else { + listEntry.value + } + } + + foldersWithFeeds.toSortedMap(nullsLast(Folder::compareTo)) + } + } + + fun getNewItemsUnreadCount(accountId: Int, useSeparateState: Boolean): Flow = + if (useSeparateState) { + database.itemDao().selectUnreadNewItemsCountByItemState(accountId) + } else { + database.itemDao().selectUnreadNewItemsCount(accountId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/repositories/LocalFeedRepository.java b/app/src/main/java/com/readrops/app/repositories/LocalFeedRepository.java deleted file mode 100644 index 38a0042d..00000000 --- a/app/src/main/java/com/readrops/app/repositories/LocalFeedRepository.java +++ /dev/null @@ -1,189 +0,0 @@ -package com.readrops.app.repositories; - -import android.accounts.NetworkErrorException; -import android.content.Context; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.readrops.api.localfeed.LocalRSSDataSource; -import com.readrops.api.services.SyncResult; -import com.readrops.api.utils.ApiUtils; -import com.readrops.api.utils.exceptions.ParseException; -import com.readrops.api.utils.exceptions.UnknownFormatException; -import com.readrops.app.addfeed.FeedInsertionResult; -import com.readrops.app.addfeed.ParsingResult; -import com.readrops.app.utils.SharedPreferencesManager; -import com.readrops.app.utils.Utils; -import com.readrops.db.Database; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Item; -import com.readrops.db.entities.account.Account; - -import org.jsoup.Jsoup; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; - -import io.reactivex.Completable; -import io.reactivex.Single; -import kotlin.Pair; -import okhttp3.Headers; - -public class LocalFeedRepository extends ARepository { - - private static final String TAG = LocalFeedRepository.class.getSimpleName(); - - private LocalRSSDataSource dataSource; - - public LocalFeedRepository(LocalRSSDataSource dataSource, Database database, @NonNull Context context, @Nullable Account account) { - super(database, context, account); - - syncResult = new SyncResult(); - this.dataSource = dataSource; - } - - @Override - public Completable login(Account account, boolean insert) { - return null; - } - - @Override - public Completable sync(@Nullable List feeds, FeedUpdate update) { - return Completable.create(emitter -> { - List feedList; - - if (feeds == null || feeds.isEmpty()) { - feedList = database.feedDao().getFeeds(account.getId()); - } else { - feedList = feeds; - } - - for (Feed feed : feedList) { - Handler mainHandler = new Handler(Looper.getMainLooper()); - mainHandler.post(() -> update.onNext(feed)); - - try { - Headers.Builder headers = new Headers.Builder(); - if (feed.getEtag() != null) { - headers.add(ApiUtils.IF_NONE_MATCH_HEADER, feed.getEtag()); - } - if (feed.getLastModified() != null) { - headers.add(ApiUtils.IF_MODIFIED_HEADER, feed.getLastModified()); - } - - Pair> pair = dataSource.queryRSSResource(feed.getUrl(), headers.build()); - - if (pair != null) { - insertNewItems(feed, pair.getSecond()); - } - } catch (Exception e) { - Log.d(TAG, "sync: " + e.getMessage()); - } - } - - emitter.onComplete(); - }); - } - - @Override - public Single> addFeeds(List results) { - return Single.create(emitter -> { - List insertionResults = new ArrayList<>(); - - for (ParsingResult parsingResult : results) { - FeedInsertionResult insertionResult = new FeedInsertionResult(); - - try { - Pair> pair = dataSource.queryRSSResource(parsingResult.getUrl(), - null); - Feed feed = insertFeed(pair.getFirst(), parsingResult); - - if (feed != null) { - insertionResult.setFeed(feed); - } - } catch (ParseException e) { - Log.d(TAG, "addFeeds: " + e.getMessage()); - insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.PARSE_ERROR); - } catch (UnknownFormatException e) { - Log.d(TAG, "addFeeds: " + e.getMessage()); - insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.FORMAT_ERROR); - } catch (NetworkErrorException | IOException e) { - Log.d(TAG, "addFeeds: " + e.getMessage()); - insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.NETWORK_ERROR); - } catch (Exception e) { - Log.d(TAG, "addFeeds: " + e.getMessage()); - insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.UNKNOWN_ERROR); - } finally { - insertionResult.setParsingResult(parsingResult); - insertionResults.add(insertionResult); - } - } - - emitter.onSuccess(insertionResults); - }); - } - - @SuppressWarnings("SimplifyStreamApiCallChains") - private void insertNewItems(Feed feed, List items) { - database.feedDao().updateHeaders(feed.getEtag(), feed.getLastModified(), feed.getId()); - - Collections.sort(items, Item::compareTo); - - int maxItems = Integer.parseInt(SharedPreferencesManager.readString( - SharedPreferencesManager.SharedPrefKey.ITEMS_TO_PARSE_MAX_NB)); - if (maxItems > 0 && items.size() > maxItems) { - items = items.subList(items.size() - maxItems, items.size()); - } - - items.stream().forEach(item -> item.setFeedId(feed.getId())); - insertItems(items, feed); - } - - private Feed insertFeed(Feed feed, ParsingResult parsingResult) { - feed.setFolderId(parsingResult.getFolderId()); - - if (database.feedDao().feedExists(feed.getUrl(), account.getId())) { - return null; // feed already inserted - } - - setFeedColors(feed); - feed.setAccountId(account.getId()); - - // we need empty headers to query the feed just after, without any 304 result - feed.setEtag(null); - feed.setLastModified(null); - - feed.setId((int) (database.feedDao().compatInsert(feed))); - return feed; - } - - private void insertItems(Collection items, Feed feed) { - List itemsToInsert = new ArrayList<>(); - - for (Item dbItem : items) { - if (!database.itemDao().itemExists(dbItem.getGuid(), feed.getAccountId())) { - if (dbItem.getDescription() != null) { - dbItem.setCleanDescription(Jsoup.parse(dbItem.getDescription()).text()); - } - - if (dbItem.getContent() != null) { - dbItem.setReadTime(Utils.readTimeFromString(dbItem.getContent())); - } else if (dbItem.getDescription() != null) { - dbItem.setReadTime(Utils.readTimeFromString(dbItem.getCleanDescription())); - } - - itemsToInsert.add(dbItem); - } - } - - syncResult.getItems().addAll(itemsToInsert); - database.itemDao().insert(itemsToInsert); - } -} diff --git a/app/src/main/java/com/readrops/app/repositories/LocalRSSRepository.kt b/app/src/main/java/com/readrops/app/repositories/LocalRSSRepository.kt new file mode 100644 index 00000000..032de40f --- /dev/null +++ b/app/src/main/java/com/readrops/app/repositories/LocalRSSRepository.kt @@ -0,0 +1,136 @@ +package com.readrops.app.repositories + +import android.util.Log +import com.readrops.api.localfeed.LocalRSSDataSource +import com.readrops.api.utils.ApiUtils +import com.readrops.api.utils.HtmlParser +import com.readrops.app.util.FeedColors +import com.readrops.app.util.Utils +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Item +import com.readrops.db.entities.account.Account +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Headers +import org.jsoup.Jsoup +import org.koin.core.component.KoinComponent +import org.koin.core.component.get + +class LocalRSSRepository( + private val dataSource: LocalRSSDataSource, + database: Database, + account: Account +) : BaseRepository(database, account), KoinComponent { + + override suspend fun login(account: Account) { /* useless here */ } + + override suspend fun synchronize( + selectedFeeds: List, + onUpdate: suspend (Feed) -> Unit + ): Pair { + val errors = hashMapOf() + val newItems = mutableListOf() + + val feeds = selectedFeeds.ifEmpty { + database.feedDao().selectFeeds(account.id) + } + + for (feed in feeds) { + onUpdate(feed) + + val headers = Headers.Builder() + if (feed.etag != null) { + headers[ApiUtils.IF_NONE_MATCH_HEADER] = feed.etag!! + } + if (feed.lastModified != null) { + headers[ApiUtils.IF_MODIFIED_HEADER] = feed.lastModified!! + } + + try { + val pair = dataSource.queryRSSResource(feed.url!!, headers.build()) + + pair?.let { newItems.addAll(insertNewItems(it.second, feed)) } + } catch (e: Exception) { + errors[feed] = e + } + } + + return Pair(SyncResult(items = newItems), errors) + } + + override suspend fun synchronize(): SyncResult = + throw NotImplementedError("This method can't be called here") + + + override suspend fun insertNewFeeds( + newFeeds: List, + onUpdate: (Feed) -> Unit + ): ErrorResult = withContext(Dispatchers.IO) { + val errors = hashMapOf() + + for (newFeed in newFeeds) { + onUpdate(newFeed) + + try { + val result = dataSource.queryRSSResource(newFeed.url!!, null)!! + insertFeed(result.first.also { it.folderId = newFeed.folderId }) + } catch (e: Exception) { + errors[newFeed] = e + } + } + + return@withContext errors + } + + private suspend fun insertNewItems(items: List, feed: Feed): List { + val newItems = mutableListOf() + + for (item in items) { + if (!database.itemDao().itemExists(item.remoteId!!, feed.accountId)) { + if (item.description != null) { + item.cleanDescription = Jsoup.parse(item.description).text() + } + + if (item.content != null) { + item.readTime = Utils.readTimeFromString(item.content!!) + } else if (item.description != null) { + item.readTime = Utils.readTimeFromString(item.cleanDescription!!) + } + + item.feedId = feed.id + newItems += item + } + } + + database.itemDao().insert(newItems) + .zip(newItems) + .forEach { (id, item) -> item.id = id.toInt() } + + return newItems + } + + private suspend fun insertFeed(feed: Feed): Feed { + // TODO better handle this case + require(!database.feedDao().feedExists(feed.url!!, account.id)) { + "Feed already exists for account ${account.accountName}" + } + + return feed.apply { + accountId = account.id + // we need empty headers to query the feed just after, without any 304 result + etag = null + lastModified = null + + try { + iconUrl = HtmlParser.getFaviconLink(siteUrl!!, get()).also { feedUrl -> + feedUrl?.let { color = FeedColors.getFeedColor(it) } + } + } catch (e: Exception) { + Log.d("LocalRSSRepository", "insertFeed: ${e.message}") + } + + id = database.feedDao().insert(this).toInt() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/repositories/NextNewsRepository.java b/app/src/main/java/com/readrops/app/repositories/NextNewsRepository.java deleted file mode 100644 index 9860f0f2..00000000 --- a/app/src/main/java/com/readrops/app/repositories/NextNewsRepository.java +++ /dev/null @@ -1,352 +0,0 @@ -package com.readrops.app.repositories; - -import android.content.Context; -import android.database.sqlite.SQLiteConstraintException; -import android.util.Log; -import android.util.TimingLogger; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.readrops.api.services.SyncResult; -import com.readrops.api.services.SyncType; -import com.readrops.api.services.nextcloudnews.NextNewsDataSource; -import com.readrops.api.services.nextcloudnews.NextNewsSyncData; -import com.readrops.api.utils.exceptions.UnknownFormatException; -import com.readrops.app.addfeed.FeedInsertionResult; -import com.readrops.app.addfeed.ParsingResult; -import com.readrops.app.utils.Utils; -import com.readrops.db.Database; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.Item; -import com.readrops.db.entities.account.Account; -import com.readrops.db.pojo.ItemReadStarState; - -import org.joda.time.LocalDateTime; -import org.koin.java.KoinJavaComponent; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -import io.reactivex.Completable; -import io.reactivex.Single; -import okhttp3.OkHttpClient; - -public class NextNewsRepository extends ARepository { - - private static final String TAG = NextNewsRepository.class.getSimpleName(); - - private final NextNewsDataSource dataSource; - - public NextNewsRepository(NextNewsDataSource dataSource, Database database, @NonNull Context context, @Nullable Account account) { - super(database, context, account); - - this.dataSource = dataSource; - } - - @Override - public Completable login(Account account, boolean insert) { - setCredentials(account); - return Single.create(emitter -> { - OkHttpClient httpClient = KoinJavaComponent.get(OkHttpClient.class); - - String displayName = dataSource.login(httpClient, account); - emitter.onSuccess(displayName); - }).flatMapCompletable(displayName -> { - account.setDisplayedName(displayName); - account.setCurrentAccount(true); - - if (insert) { - return database.accountDao().insert(account) - .flatMapCompletable(id -> { - account.setId(id.intValue()); - return Completable.complete(); - }); - } - - return Completable.complete(); - }); - } - - @Override - public Completable sync(@Nullable List feeds, @Nullable FeedUpdate update) { - setCredentials(account); - return Completable.create(emitter -> { - try { - long lastModified = LocalDateTime.now().toDateTime().getMillis(); - SyncType syncType; - - if (account.getLastModified() != 0) { - syncType = SyncType.CLASSIC_SYNC; - } else { - syncType = SyncType.INITIAL_SYNC; - } - - NextNewsSyncData syncData = new NextNewsSyncData(); - - if (syncType == SyncType.CLASSIC_SYNC) { - syncData.setLastModified(account.getLastModified() / 1000L); - - List itemStateChanges = database - .itemStateChangesDao() - .getNextcloudNewsStateChanges(account.getId()); - - syncData.setReadItems(itemStateChanges.stream() - .filter(it -> it.getReadChange() && it.getRead()) - .map(ItemReadStarState::getRemoteId) - .collect(Collectors.toList())); - - syncData.setUnreadItems(itemStateChanges.stream() - .filter(it -> it.getReadChange() && !it.getRead()) - .map(ItemReadStarState::getRemoteId) - .collect(Collectors.toList())); - - List starredItemsIds = itemStateChanges.stream() - .filter(it -> it.getStarChange() && it.getStarred()) - .map(ItemReadStarState::getRemoteId) - .collect(Collectors.toList()); - - if (!starredItemsIds.isEmpty()) { - syncData.setStarredItems(database.itemDao().getStarChanges(starredItemsIds, account.getId())); - } - - List unstarredItemsIds = itemStateChanges.stream() - .filter(it -> it.getStarChange() && !it.getStarred()) - .map(ItemReadStarState::getRemoteId) - .collect(Collectors.toList()); - - if (!unstarredItemsIds.isEmpty()) { - syncData.setUnstarredItems(database.itemDao().getStarChanges(unstarredItemsIds, account.getId())); - } - - } - - TimingLogger timings = new TimingLogger(TAG, "nextcloud news " + syncType.name().toLowerCase()); - SyncResult result = dataSource.sync(syncType, syncData); - timings.addSplit("server queries"); - - if (!result.isError()) { - syncResult = new SyncResult(); - - insertFolders(result.getFolders()); - timings.addSplit("insert folders"); - - insertFeeds(result.getFeeds(), false); - timings.addSplit("insert feeds"); - - boolean initialSync = syncType == SyncType.INITIAL_SYNC; - insertItems(result.getItems(), initialSync); - timings.addSplit("insert items"); - - insertItems(result.getStarredItems(), initialSync); - timings.dumpToLog(); - - account.setLastModified(lastModified); - database.accountDao().updateLastModified(account.getId(), lastModified); - - database.itemStateChangesDao().resetStateChanges(account.getId()); - - emitter.onComplete(); - } else { - emitter.onError(new Throwable()); - } - - } catch (Exception e) { - Log.d(TAG, "sync: " + e.getMessage()); - emitter.onError(e); - } - }); - } - - @Override - public Single> addFeeds(List results) { - setCredentials(account); - return Single.create(emitter -> { - List feedInsertionResults = new ArrayList<>(); - - for (ParsingResult result : results) { - FeedInsertionResult insertionResult = new FeedInsertionResult(); - - try { - List nextNewsFeeds = dataSource.createFeed(result.getUrl(), 0); - - if (nextNewsFeeds != null) { - List newFeeds = insertFeeds(nextNewsFeeds, true); - // there is always only one object in the list, see nextcloud news dataSource doc - insertionResult.setFeed(newFeeds.get(0)); - } else - insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.UNKNOWN_ERROR); - - insertionResult.setParsingResult(result); - } catch (Exception e) { - if (e instanceof IOException) - insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.NETWORK_ERROR); - else if (e instanceof UnknownFormatException) - insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.FORMAT_ERROR); - else if (e instanceof SQLiteConstraintException) - insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.DB_ERROR); - else - insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.UNKNOWN_ERROR); - } - - feedInsertionResults.add(insertionResult); - } - - emitter.onSuccess(feedInsertionResults); - }); - } - - @Override - public Completable updateFeed(Feed feed) { - setCredentials(account); - return Completable.create(emitter -> { - Folder folder = feed.getFolderId() == null ? null : database.folderDao().select(feed.getFolderId()); - - if (folder != null) - feed.setRemoteFolderId(folder.getRemoteId()); - else - feed.setRemoteFolderId(String.valueOf(0)); // 0 for no folder - - try { - if (dataSource.renameFeed(feed) && dataSource.changeFeedFolder(feed)) { - emitter.onComplete(); - } else - emitter.onError(new Exception("Unknown error when updating feed")); - } catch (Exception e) { - emitter.onError(e); - } - }).andThen(super.updateFeed(feed)); - } - - @Override - public Completable deleteFeed(Feed feed) { - setCredentials(account); - return Completable.create(emitter -> { - try { - if (dataSource.deleteFeed(Integer.parseInt(feed.getRemoteId()))) { - emitter.onComplete(); - } else - emitter.onError(new Exception("Unknown error")); - } catch (Exception e) { - emitter.onError(e); - } - - emitter.onComplete(); - }).andThen(super.deleteFeed(feed)); - } - - @Override - public Single addFolder(Folder folder) { - setCredentials(account); - return Single.create(emitter -> { - try { - List folders = dataSource.createFolder(folder); - - if (folders != null) { - Folder nextNewsFolder = folders.get(0); // always only one item returned by the server, see doc - folder.setRemoteId(nextNewsFolder.getRemoteId()); - - emitter.onSuccess(folder); - } else - emitter.onError(new Exception("Unknown error")); - } catch (Exception e) { - emitter.onError(e); - } - }).flatMap(folder1 -> database.folderDao().insert(folder)); - } - - @Override - public Completable updateFolder(Folder folder) { - setCredentials(account); - return Completable.create(emitter -> { - try { - if (dataSource.renameFolder(folder)) { - emitter.onComplete(); - } else - emitter.onError(new Exception("Unknown error")); - - } catch (Exception e) { - emitter.onError(e); - } - - emitter.onComplete(); - }).andThen(super.updateFolder(folder)); - } - - @Override - public Completable deleteFolder(Folder folder) { - setCredentials(account); - return Completable.create(emitter -> { - try { - if (dataSource.deleteFolder(folder)) { - emitter.onComplete(); - } else - emitter.onError(new Exception("Unknown error")); - - } catch (Exception e) { - emitter.onError(e); - } - - emitter.onComplete(); - }).andThen(super.deleteFolder(folder)); - } - - private List insertFeeds(List nextNewsFeeds, boolean newFeeds) { - for (Feed nextNewsFeed : nextNewsFeeds) { - nextNewsFeed.setAccountId(account.getId()); - } - - List insertedFeedsIds; - if (newFeeds) { - insertedFeedsIds = database.feedDao().insert(nextNewsFeeds); - } else { - insertedFeedsIds = database.feedDao().feedsUpsert(nextNewsFeeds, account); - } - - List insertedFeeds = new ArrayList<>(); - if (!insertedFeedsIds.isEmpty()) { - insertedFeeds.addAll(database.feedDao().selectFromIdList(insertedFeedsIds)); - setFeedsColors(insertedFeeds); - } - - return insertedFeeds; - } - - private void insertFolders(List nextNewsFolders) { - for (Folder folder : nextNewsFolders) { - folder.setAccountId(account.getId()); - } - - database.folderDao().foldersUpsert(nextNewsFolders, account); - } - - private void insertItems(List items, boolean initialSync) { - List itemsToInsert = new ArrayList<>(); - - for (Item item : items) { - int feedId = database.feedDao().getFeedIdByRemoteId(item.getFeedRemoteId(), account.getId()); - - //if the item already exists, update only its read state - if (!initialSync && feedId > 0 && database.itemDao().remoteItemExists(String.valueOf(item.getRemoteId()), feedId)) { - database.itemDao().setReadAndStarState(item.getRemoteId(), item.isRead(), item.isStarred()); - continue; - } - - item.setFeedId(feedId); - item.setReadTime(Utils.readTimeFromString(item.getContent())); - - itemsToInsert.add(item); - } - - if (!itemsToInsert.isEmpty()) { - syncResult.setItems(itemsToInsert); - - Collections.sort(itemsToInsert, Item::compareTo); - database.itemDao().insert(itemsToInsert); - } - } -} diff --git a/app/src/main/java/com/readrops/app/repositories/NextcloudNewsRepository.kt b/app/src/main/java/com/readrops/app/repositories/NextcloudNewsRepository.kt new file mode 100644 index 00000000..f695a23c --- /dev/null +++ b/app/src/main/java/com/readrops/app/repositories/NextcloudNewsRepository.kt @@ -0,0 +1,184 @@ +package com.readrops.app.repositories + +import com.readrops.api.services.Credentials +import com.readrops.api.services.SyncType +import com.readrops.api.services.nextcloudnews.NextcloudNewsDataSource +import com.readrops.api.services.nextcloudnews.NextcloudNewsSyncData +import com.readrops.api.utils.AuthInterceptor +import com.readrops.app.util.Utils +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.Item +import com.readrops.db.entities.account.Account +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.get + +class NextcloudNewsRepository( + database: Database, + account: Account, + private val dataSource: NextcloudNewsDataSource, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : BaseRepository(database, account), KoinComponent { + + override suspend fun login(account: Account) { + get().apply { + credentials = Credentials.toCredentials(account) + } + + val displayName = dataSource.login(get(), account) + account.displayedName = displayName + } + + override suspend fun synchronize( + selectedFeeds: List, + onUpdate: suspend (Feed) -> Unit + ): Pair = throw NotImplementedError("This method can't be called here") + + override suspend fun synchronize(): SyncResult { + val itemStateChanges = database.itemStateChangeDao() + .selectItemStateChanges(account.id) + + val syncData = NextcloudNewsSyncData( + lastModified = account.lastModified, + readIds = itemStateChanges.filter { it.readChange && it.read } + .map { it.remoteId.toInt() }, + unreadIds = itemStateChanges.filter { it.readChange && !it.read } + .map { it.remoteId.toInt() }, + starredIds = itemStateChanges.filter { it.starChange && it.starred } + .map { it.remoteId.toInt() }, + unstarredIds = itemStateChanges.filter { it.starChange && !it.starred } + .map { it.remoteId.toInt() } + ) + + val syncType = if (account.lastModified != 0L) { + SyncType.CLASSIC_SYNC + } else { + SyncType.INITIAL_SYNC + } + + val newLastModified = System.currentTimeMillis() / 1000L + + return dataSource.synchronize(syncType, syncData).run { + insertFolders(folders) + val newFeeds = insertFeeds(feeds) + + val initialSync = syncType == SyncType.INITIAL_SYNC + val newItems = insertItems(items, initialSync) + insertItems(starredItems, initialSync) + + account.lastModified = newLastModified + database.accountDao().updateLastModified(newLastModified, account.id) + + database.itemStateChangeDao().resetStateChanges(account.id) + + SyncResult( + items = newItems, + feeds = newFeeds + ) + } + } + + override suspend fun insertNewFeeds( + newFeeds: List, + onUpdate: (Feed) -> Unit + ): ErrorResult { + val errors = hashMapOf() + + for (newFeed in newFeeds) { + onUpdate(newFeed) + + try { + val feeds = dataSource.createFeed(newFeed.url!!, null) + insertFeeds(feeds) + } catch (e: Exception) { + errors[newFeed] = e + } + } + + return errors + } + + override suspend fun updateFeed(feed: Feed) = withContext(dispatcher) { + val folder = + if (feed.folderId != null) database.folderDao().select(feed.folderId!!) else null + + listOf( + async { dataSource.renameFeed(feed.name!!, feed.remoteId!!.toInt()) }, + async { dataSource.changeFeedFolder(folder?.remoteId?.toInt(), feed.remoteId!!.toInt()) } + ).awaitAll() + + super.updateFeed(feed) + } + + override suspend fun deleteFeed(feed: Feed) { + dataSource.deleteFeed(feed.remoteId!!.toInt()) + super.deleteFeed(feed) + } + + override suspend fun addFolder(folder: Folder) { + val folders = dataSource.createFolder(folder.name!!) + .onEach { it.accountId = account.id } + + database.folderDao().insert(folders) + } + + override suspend fun updateFolder(folder: Folder) { + dataSource.renameFolder(folder.name!!, folder.remoteId!!.toInt()) + super.updateFolder(folder) + } + + override suspend fun deleteFolder(folder: Folder) { + dataSource.deleteFolder(folder.remoteId!!.toInt()) + super.deleteFolder(folder) + } + + private suspend fun insertFolders(folders: List) { + folders.forEach { it.accountId = account.id } + database.folderDao().upsertFolders(folders, account) + } + + private suspend fun insertFeeds(feeds: List): List { + feeds.forEach { it.accountId = account.id } + return database.feedDao().upsertFeeds(feeds, account) + } + + private suspend fun insertItems(items: List, initialSync: Boolean): List { + val newItems = arrayListOf() + val itemsFeedsIds = mutableMapOf() + + for (item in items) { + val feedId: Int + if (itemsFeedsIds.containsKey(item.feedRemoteId)) { + feedId = itemsFeedsIds.getValue(item.feedRemoteId) + } else { + feedId = + database.feedDao().selectRemoteFeedLocalId(item.feedRemoteId!!, account.id) + itemsFeedsIds[item.feedRemoteId] = feedId + } + + if (!initialSync && feedId > 0 && database.itemDao().itemExists(item.remoteId!!, feedId)) { + database.itemDao() + .updateReadAndStarState(item.remoteId!!, item.isRead, item.isStarred) + continue + } + + item.feedId = feedId + item.readTime = Utils.readTimeFromString(item.content.orEmpty()) + newItems += item + } + + if (newItems.isNotEmpty()) { + database.itemDao().insert(newItems) + .zip(newItems) + .forEach { (id, item) -> item.id = id.toInt() } + } + + return newItems + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/repositories/Repository.kt b/app/src/main/java/com/readrops/app/repositories/Repository.kt new file mode 100644 index 00000000..b23cdc19 --- /dev/null +++ b/app/src/main/java/com/readrops/app/repositories/Repository.kt @@ -0,0 +1,238 @@ +package com.readrops.app.repositories + +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.Item +import com.readrops.db.entities.ItemState +import com.readrops.db.entities.account.Account + +typealias ErrorResult = HashMap + +data class SyncResult( + val items: List = listOf(), + val feeds: List = listOf() +) + +interface Repository { + + /** + * This method is intended for remote accounts. + */ + suspend fun login(account: Account) + + /** + * Global synchronization for the local account. + * @param selectedFeeds feeds to be updated, will fetch all account feeds if list is empty + * @param onUpdate notify each feed update + * @return newly inserted items and feeds used by background synchronization and notifications, + * and errors per feed if occurred to be transmitted to the user + */ + suspend fun synchronize( + selectedFeeds: List, + onUpdate: suspend (Feed) -> Unit + ): Pair + + /** + * Global synchronization for remote accounts. Unlike the local account, remote accounts + * won't benefit from synchronization status and granular synchronization + * @return the result of the synchronization: newly inserted items and feeds + */ + suspend fun synchronize(): SyncResult + + /** + * Insert new feeds by notifying each of them + * @param newFeeds feeds to insert + * @param onUpdate notify each feed insertion + * @return errors by feed + */ + suspend fun insertNewFeeds(newFeeds: List, onUpdate: (Feed) -> Unit): ErrorResult +} + +abstract class BaseRepository( + val database: Database, + val account: Account, +) : Repository { + + open suspend fun updateFeed(feed: Feed) = + database.feedDao().updateFeedFields(feed.id, feed.name!!, feed.url!!, feed.folderId) + + open suspend fun deleteFeed(feed: Feed) = database.feedDao().delete(feed) + + open suspend fun addFolder(folder: Folder) { + database.folderDao().insert(folder) + } + + open suspend fun updateFolder(folder: Folder) = database.folderDao().update(folder) + + open suspend fun deleteFolder(folder: Folder) = database.folderDao().delete(folder) + + open suspend fun setItemReadState(item: Item) { + when { + account.config.useSeparateState -> { + database.itemStateChangeDao().upsertItemReadStateChange(item, account.id, true) + database.itemStateDao().upsertItemReadState( + ItemState( + id = 0, + read = item.isRead, + starred = item.isStarred, + remoteId = item.remoteId!!, + accountId = account.id + ) + ) + } + account.isLocal -> { + database.itemDao().updateReadState(item.id, item.isRead) + } + else -> { + database.itemStateChangeDao().upsertItemReadStateChange(item, account.id, false) + database.itemDao().updateReadState(item.id, item.isRead) + } + } + } + + open suspend fun setItemStarState(item: Item) { + when { + account.config.useSeparateState -> { + database.itemStateChangeDao().upsertItemStarStateChange(item, account.id, true) + database.itemStateDao().upsertItemStarState( + ItemState( + id = 0, + read = item.isRead, + starred = item.isStarred, + remoteId = item.remoteId!!, + accountId = account.id + ) + ) + } + account.isLocal -> { + database.itemDao().updateStarState(item.id, item.isStarred) + } + else -> { + database.itemStateChangeDao().upsertItemStarStateChange(item, account.id, false) + database.itemDao().updateStarState(item.id, item.isStarred) + } + } + } + + open suspend fun setAllItemsRead() { + val accountId = account.id + + when { + account.config.useSeparateState -> { + database.itemStateChangeDao().upsertAllItemsReadStateChanges(accountId) + database.itemStateDao().setAllItemsRead(accountId) + } + account.isLocal -> { + database.itemDao().setAllItemsRead(account.id) + } + else -> { + database.itemStateChangeDao().upsertAllItemsReadStateChanges(accountId) + database.itemDao().setAllItemsRead(accountId) + } + } + } + + open suspend fun setAllStarredItemsRead() { + val accountId = account.id + + when { + account.config.useSeparateState -> { + database.itemStateChangeDao().upsertStarredItemReadStateChanges(accountId) + database.itemStateDao().setAllStarredItemsRead(accountId) + } + account.isLocal -> { + database.itemDao().setAllStarredItemsRead(accountId) + } + else -> { + database.itemStateChangeDao().upsertStarredItemReadStateChanges(accountId) + database.itemDao().setAllStarredItemsRead(accountId) + } + } + } + + open suspend fun setAllNewItemsRead() { + val accountId = account.id + + when { + account.config.useSeparateState -> { + database.itemStateChangeDao().upsertNewItemReadStateChanges(accountId) + database.itemStateDao().setAllNewItemsRead(accountId) + } + account.isLocal -> { + database.itemDao().setAllNewItemsRead(accountId) + } + else -> { + database.itemStateChangeDao().upsertNewItemReadStateChanges(accountId) + database.itemDao().setAllNewItemsRead(accountId) + } + } + } + + open suspend fun setAllItemsReadByFeed(feedId: Int) { + val accountId = account.id + + when { + account.config.useSeparateState -> { + database.itemStateChangeDao() + .upsertItemReadStateChangesByFeed(feedId, accountId) + database.itemStateDao().setAllItemsReadByFeed(feedId, accountId) + } + account.isLocal -> { + database.itemDao().setAllItemsReadByFeed(feedId, accountId) + } + else -> { + database.itemStateChangeDao().upsertItemReadStateChangesByFeed(feedId, accountId) + database.itemDao().setAllItemsReadByFeed(feedId, accountId) + } + } + } + + open suspend fun setAllItemsReadByFolder(folderId: Int) { + val accountId = account.id + + when { + account.config.useSeparateState -> { + database.itemStateChangeDao().upsertItemReadStateChangesByFolder(folderId, accountId) + database.itemStateDao().setAllItemsReadByFolder(folderId, accountId) + } + account.isLocal -> { + database.itemDao().setAllItemsReadByFolder(folderId, accountId) + } + else -> { + database.itemStateChangeDao().upsertItemReadStateChangesByFolder(folderId, accountId) + database.itemDao().setAllItemsReadByFolder(folderId, accountId) + } + } + } + + suspend fun insertOPMLFoldersAndFeeds( + foldersAndFeeds: Map>, + onUpdate: (Feed) -> Unit + ): ErrorResult { + val errors = hashMapOf() + + for ((folder, feeds) in foldersAndFeeds) { + if (folder != null) { + folder.accountId = account.id + + val dbFolder = database.folderDao().selectFolderByName(folder.name!!, account.id) + + if (dbFolder != null) { + folder.id = dbFolder.id + } else { + folder.id = database.folderDao().insert(folder).toInt() + } + } + + feeds.forEach { it.folderId = folder?.id } + + errors += insertNewFeeds( + newFeeds = feeds, + onUpdate = onUpdate + ) + } + + return errors + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/settings/AccountSettingsFragment.java b/app/src/main/java/com/readrops/app/settings/AccountSettingsFragment.java deleted file mode 100644 index f96ceb84..00000000 --- a/app/src/main/java/com/readrops/app/settings/AccountSettingsFragment.java +++ /dev/null @@ -1,318 +0,0 @@ -package com.readrops.app.settings; - - -import android.Manifest; -import android.app.Notification; -import android.app.PendingIntent; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.provider.Settings; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.fragment.app.Fragment; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; - -import com.afollestad.materialdialogs.MaterialDialog; -import com.readrops.app.utils.OPMLHelper; -import com.readrops.api.opml.OPMLParser; -import com.readrops.app.R; -import com.readrops.app.ReadropsApp; -import com.readrops.app.account.AccountViewModel; -import com.readrops.app.account.AddAccountActivity; -import com.readrops.app.feedsfolders.ManageFeedsFoldersActivity; -import com.readrops.app.notifications.NotificationPermissionActivity; -import com.readrops.app.utils.FileUtils; -import com.readrops.app.utils.PermissionManager; -import com.readrops.app.utils.SharedPreferencesManager; -import com.readrops.app.utils.Utils; -import com.readrops.db.entities.Feed; -import com.readrops.db.entities.Folder; -import com.readrops.db.entities.account.Account; -import com.readrops.db.entities.account.AccountType; - -import java.io.FileNotFoundException; -import java.util.List; -import java.util.Map; - -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.observers.DisposableCompletableObserver; -import io.reactivex.schedulers.Schedulers; -import kotlin.Unit; - -import static android.app.Activity.RESULT_OK; -import static com.readrops.app.utils.OPMLHelper.OPEN_OPML_FILE_REQUEST; -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT; -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT_ID; -import static com.readrops.app.utils.ReadropsKeys.EDIT_ACCOUNT; - -import org.koin.android.compat.ViewModelCompat; - -/** - * A simple {@link Fragment} subclass. - */ -public class AccountSettingsFragment extends PreferenceFragmentCompat { - - private static final String TAG = AccountSettingsFragment.class.getSimpleName(); - - private static final int WRITE_EXTERNAL_STORAGE_REQUEST = 1; - - private Account account; - private AccountViewModel viewModel; - - public AccountSettingsFragment() { - - } - - public static AccountSettingsFragment newInstance(Account account) { - AccountSettingsFragment fragment = new AccountSettingsFragment(); - Bundle args = new Bundle(); - - args.putParcelable(ACCOUNT, account); - fragment.setArguments(args); - - return fragment; - } - - @SuppressWarnings("ConstantConditions") - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - addPreferencesFromResource(R.xml.acount_preferences); - - account = getArguments().getParcelable(ACCOUNT); - - Preference feedsFoldersPref = findPreference("feeds_folders_key"); - Preference credentialsPref = findPreference("credentials_key"); - Preference deleteAccountPref = findPreference("delete_account_key"); - Preference opmlPref = findPreference("opml_import_export"); - Preference notificationPref = findPreference("notifications"); - - if (account.is(AccountType.LOCAL)) - credentialsPref.setVisible(false); - - if (!account.is(AccountType.LOCAL)) - opmlPref.setVisible(false); - - feedsFoldersPref.setOnPreferenceClickListener(preference -> { - Intent intent = new Intent(getContext(), ManageFeedsFoldersActivity.class); - intent.putExtra(ACCOUNT, account); - startActivity(intent); - - return true; - }); - - credentialsPref.setOnPreferenceClickListener(preference -> { - if (!account.isLocal()) { - Intent intent = new Intent(getContext(), AddAccountActivity.class); - intent.putExtra(EDIT_ACCOUNT, account); - startActivity(intent); - } - - return true; - }); - - deleteAccountPref.setOnPreferenceClickListener(preference -> { - deleteAccount(); - return true; - }); - - opmlPref.setOnPreferenceClickListener(preference -> { - new MaterialDialog.Builder(getActivity()) - .items(R.array.opml_import_export) - .itemsCallback(((dialog, itemView, position, text) -> openOPMLMode(position))) - .show(); - return true; - }); - - notificationPref.setOnPreferenceClickListener(preference -> { - Intent intent = new Intent(getContext(), NotificationPermissionActivity.class); - intent.putExtra(ACCOUNT_ID, account.getId()); - - startActivity(intent); - return true; - }); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - viewModel = ViewModelCompat.getViewModel(this, AccountViewModel.class); - viewModel.setAccount(account); - } - - private void deleteAccount() { - new MaterialDialog.Builder(getContext()) - .title(R.string.delete_account_question) - .positiveText(R.string.validate) - .negativeText(R.string.cancel) - .onPositive(((dialog, which) -> { - SharedPreferencesManager.remove(account.getLoginKey()); - SharedPreferencesManager.remove(account.getPasswordKey()); - - viewModel.delete(account) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new DisposableCompletableObserver() { - @Override - public void onComplete() { - getActivity().finish(); - } - - @Override - public void onError(Throwable e) { - Utils.showSnackbar(getView(), e.getMessage()); - } - }); - })) - .show(); - } - - private void openOPMLMode(int position) { - if (position == 0) { - OPMLHelper.openFileIntent(this); - } else { - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - if (PermissionManager.isPermissionGranted(getContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE)) { - exportAsOPMLFile(); - } else { - requestExternalStoragePermission(); - } - } else { - exportAsOPMLFile(); - } - } - } - - // region opml import - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (requestCode == OPEN_OPML_FILE_REQUEST && resultCode == RESULT_OK && data != null) { - Uri uri = data.getData(); - - MaterialDialog dialog = new MaterialDialog.Builder(getActivity()) - .title(R.string.opml_processing) - .content(R.string.operation_takes_time) - .progress(true, 100) - .cancelable(false) - .show(); - - try { - parseOPMLFile(uri, dialog); - } catch (FileNotFoundException e) { - Log.d(TAG, e.getMessage()); - displayErrorMessage(); - } - } - - super.onActivityResult(requestCode, resultCode, data); - } - - private void parseOPMLFile(Uri uri, MaterialDialog dialog) throws FileNotFoundException { - viewModel.parseOPMLFile(uri, getContext()) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new DisposableCompletableObserver() { - @Override - public void onComplete() { - dialog.dismiss(); - } - - @Override - public void onError(Throwable e) { - dialog.dismiss(); - - displayErrorMessage(); - } - }); - } - - private void displayErrorMessage() { - new MaterialDialog.Builder(getActivity()) - .title(R.string.processing_file_failed) - .neutralText(R.string.cancel) - .iconRes(R.drawable.ic_error) - .show(); - } - - //endregion - - //region opml export - - private void exportAsOPMLFile() { - String fileName = "subscriptions.opml"; - - try { - String path = FileUtils.writeDownloadFile(getContext(), fileName, "text/x-opml", outputStream -> { - Map> folderListMap = viewModel.getFoldersWithFeeds() - .subscribeOn(Schedulers.io()) - .blockingGet(); - - - OPMLParser.write(folderListMap, outputStream) - .blockingAwait(); - - return Unit.INSTANCE; - }); - - displayNotification(fileName, path); - } catch (Exception e) { - displayErrorMessage(); - } - - } - - private void displayNotification(String name, String absolutePath) { - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setDataAndType(Uri.parse(absolutePath), "text/plain"); - - Notification notification = new NotificationCompat.Builder(getContext(), ReadropsApp.OPML_EXPORT_CHANNEL_ID) - .setContentTitle(getString(R.string.opml_export)) - .setContentText(name) - .setSmallIcon(R.drawable.ic_notif) - .setContentIntent(PendingIntent.getActivity(getContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) - .setAutoCancel(true) - .build(); - - NotificationManagerCompat manager = NotificationManagerCompat.from(getContext()); - manager.notify(2, notification); - } - - private void requestExternalStoragePermission() { - PermissionManager.requestPermissions(this, WRITE_EXTERNAL_STORAGE_REQUEST, - Manifest.permission.WRITE_EXTERNAL_STORAGE); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - if (requestCode == WRITE_EXTERNAL_STORAGE_REQUEST) { - if (grantResults[0] != PackageManager.PERMISSION_GRANTED) { - - if (shouldShowRequestPermissionRationale(permissions[0])) { - Utils.showSnackBarWithAction(getView(), getString(R.string.external_storage_opml_export), - getString(R.string.try_again), v -> requestExternalStoragePermission()); - } else { - Utils.showSnackBarWithAction(getView(), getString(R.string.external_storage_opml_export), - getString(R.string.permissions), v -> { - Intent intent = new Intent(); - intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - intent.setData(Uri.fromParts("package", getContext().getPackageName(), null)); - getContext().startActivity(intent); - }); - } - } else { - exportAsOPMLFile(); - } - } - } - - //endregion -} diff --git a/app/src/main/java/com/readrops/app/settings/SettingsActivity.java b/app/src/main/java/com/readrops/app/settings/SettingsActivity.java deleted file mode 100644 index 06b0a639..00000000 --- a/app/src/main/java/com/readrops/app/settings/SettingsActivity.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.readrops.app.settings; - -import android.os.Bundle; -import android.view.MenuItem; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.fragment.app.Fragment; - -import com.readrops.app.R; -import com.readrops.db.entities.account.Account; - -import static com.readrops.app.utils.ReadropsKeys.ACCOUNT; -import static com.readrops.app.utils.ReadropsKeys.SETTINGS; - -public class SettingsActivity extends AppCompatActivity { - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_settings); - - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - - Account account = getIntent().getParcelableExtra(ACCOUNT); - - SettingsKey settingsKey = SettingsKey.values()[getIntent().getIntExtra(SETTINGS, -1)]; - Fragment fragment = null; - - switch (settingsKey) { - case ACCOUNT_SETTINGS: - fragment = AccountSettingsFragment.newInstance(account); - setTitle(account.getAccountName()); - break; - case SETTINGS: - fragment = new SettingsFragment(); - setTitle(R.string.settings); - break; - } - - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.settings_activity_fragment, fragment) - .commit(); - } - - public enum SettingsKey { - ACCOUNT_SETTINGS, - SETTINGS - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - return true; - } - - return super.onOptionsItemSelected(item); - } -} diff --git a/app/src/main/java/com/readrops/app/settings/SettingsFragment.java b/app/src/main/java/com/readrops/app/settings/SettingsFragment.java deleted file mode 100644 index 5c7dbca5..00000000 --- a/app/src/main/java/com/readrops/app/settings/SettingsFragment.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.readrops.app.settings; - -import static com.readrops.app.utils.ReadropsKeys.FEEDS; - -import android.content.Intent; -import android.os.Bundle; -import android.util.Pair; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; -import androidx.work.Constraints; -import androidx.work.ExistingPeriodicWorkPolicy; -import androidx.work.NetworkType; -import androidx.work.PeriodicWorkRequest; -import androidx.work.WorkManager; - -import com.readrops.app.R; -import com.readrops.app.notifications.sync.SyncWorker; -import com.readrops.app.utils.feedscolors.FeedsColorsIntentService; -import com.readrops.db.Database; - -import org.koin.java.KoinJavaComponent; - -import java.util.ArrayList; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -public class SettingsFragment extends PreferenceFragmentCompat { - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - addPreferencesFromResource(R.xml.preferences); - - Preference feedsColorsPreference = findPreference("reload_feeds_colors"); - Preference themePreference = findPreference("dark_theme"); - Preference synchroPreference = findPreference("auto_synchro"); - - - AtomicBoolean serviceStarted = new AtomicBoolean(false); - feedsColorsPreference.setOnPreferenceClickListener(preference -> { - Database database = KoinJavaComponent.get(Database.class); - - database.feedDao().getAllFeeds().observe(getActivity(), feeds -> { - if (!serviceStarted.get()) { - Intent intent = new Intent(getContext(), FeedsColorsIntentService.class); - intent.putParcelableArrayListExtra(FEEDS, new ArrayList<>(feeds)); - - getContext().startService(intent); - serviceStarted.set(true); - } - }); - - return true; - }); - - themePreference.setOnPreferenceChangeListener((preference, newValue) -> { - if (newValue.equals(getString(R.string.theme_value_light))) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); - } else if (newValue.equals(getString(R.string.theme_value_dark))) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); - } else { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); - } - - return true; - }); - - synchroPreference.setOnPreferenceChangeListener(((preference, newValue) -> { - WorkManager workManager = WorkManager.getInstance(getContext()); - Pair interval = getWorkerInterval((String) newValue); - - if (interval != null) { - Constraints constraints = new Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build(); - - PeriodicWorkRequest request = new PeriodicWorkRequest.Builder(SyncWorker.class, interval.first, interval.second) - .addTag(SyncWorker.Companion.getTAG()) - .setConstraints(constraints) - .setInitialDelay(interval.first, interval.second) - .build(); - - workManager.enqueueUniquePeriodicWork(SyncWorker.Companion.getTAG(), ExistingPeriodicWorkPolicy.REPLACE, request); - } else { - workManager.cancelAllWorkByTag(SyncWorker.Companion.getTAG()); - } - - return true; - })); - } - - @Nullable - private Pair getWorkerInterval(String newValue) { - int interval; - TimeUnit timeUnit; - - switch (newValue) { - case "0.30": - interval = 30; - timeUnit = TimeUnit.MINUTES; - break; - case "1": - interval = 1; - timeUnit = TimeUnit.HOURS; - break; - case "2": - interval = 2; - timeUnit = TimeUnit.HOURS; - break; - case "3": - interval = 3; - timeUnit = TimeUnit.HOURS; - break; - case "6": - interval = 6; - timeUnit = TimeUnit.HOURS; - break; - case "12": - interval = 12; - timeUnit = TimeUnit.HOURS; - break; - case "24": - interval = 1; - timeUnit = TimeUnit.DAYS; - break; - default: - return null; - } - - return new Pair<>(interval, timeUnit); - } - -} diff --git a/app/src/main/java/com/readrops/app/sync/SyncAnalyzer.kt b/app/src/main/java/com/readrops/app/sync/SyncAnalyzer.kt new file mode 100644 index 00000000..d7749ed3 --- /dev/null +++ b/app/src/main/java/com/readrops/app/sync/SyncAnalyzer.kt @@ -0,0 +1,163 @@ +package com.readrops.app.sync + +import android.content.Context +import android.graphics.Bitmap +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import coil.imageLoader +import coil.request.ImageRequest +import com.readrops.app.R +import com.readrops.app.repositories.SyncResult +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Item +import com.readrops.db.entities.account.Account +import org.koin.core.component.KoinComponent + +data class NotificationContent( + val title: String? = null, + val text: String? = null, + val largeIcon: Bitmap? = null, + val item: Item? = null, + val color: Int = 0, + val accountId: Int = 0 +) + +class SyncAnalyzer( + val context: Context, + val database: Database +) : KoinComponent { + + suspend fun getNotificationContent(syncResults: Map): NotificationContent? { + return if (newItemsInMultipleAccounts(syncResults)) { // new items from several accounts + val feeds = database.feedDao().selectFromIds(getFeedsIdsForNewItems(syncResults)) + + var itemCount = 0 + for (syncResult in syncResults.values) { + itemCount += syncResult.items.filter { + isFeedNotificationEnabledForItem(feeds, it) + }.size + } + + NotificationContent(title = context.getString(R.string.new_items, "$itemCount")) + } else { + // new items from a single account + return if (syncResults.isNotEmpty()) { + getSingleAccountContent(syncResults.keys.first(), syncResults.values.first()) + } else { + null + } + } + } + + private suspend fun getSingleAccountContent( + account: Account, + syncResult: SyncResult + ): NotificationContent? { + val feedsIdsForNewItems = getFeedsIdsForNewItems(syncResult) + + if (account.isNotificationsEnabled) { + val feeds = database.feedDao().selectFromIds(feedsIdsForNewItems) + + val items = + syncResult.items.filter { isFeedNotificationEnabledForItem(feeds, it) } + val itemCount = items.size + + return when { + // multiple new items from several feeds + feedsIdsForNewItems.size > 1 && itemCount > 1 -> { + NotificationContent( + title = account.accountName!!, + text = context.getString(R.string.new_items, itemCount.toString()), + largeIcon = ContextCompat.getDrawable( + context, + account.accountType!!.iconRes + )!!.toBitmap(), + accountId = account.id + ) + } + // multiple new items from a single feed + feedsIdsForNewItems.size == 1 -> + singleFeedCase(feedsIdsForNewItems.first(), syncResult.items, account) + // only one new item from a single feed + itemCount == 1 -> singleFeedCase(items.first().feedId, items, account) + else -> null + } + } + + return null + } + + private suspend fun singleFeedCase( + feedId: Int, + items: List, + account: Account + ): NotificationContent? { + val feed = database.feedDao().selectFeed(feedId) + + if (feed.isNotificationEnabled) { + val icon = feed.iconUrl?.let { + val target = context.imageLoader + .execute( + ImageRequest.Builder(context) + .data(it) + .build() + ) + + target.drawable?.toBitmap() + } + + val (item, text) = if (items.size == 1) { + val item = items.first() + item to item.title + } else { + null to context.getString(R.string.new_items, items.size.toString()) + } + + return NotificationContent( + title = feed.name, + text = text, + largeIcon = icon, + item = item, + color = feed.color, + accountId = account.id + ) + } + + return null + } + + private fun newItemsInMultipleAccounts(syncResults: Map): Boolean { + val itemsNotEmptyByAccount = mutableListOf() + + for ((account, syncResult) in syncResults) { + if (account.isNotificationsEnabled) { + itemsNotEmptyByAccount += syncResult.items.isNotEmpty() + } + } + + // return true it there is at least two true in the list + return (itemsNotEmptyByAccount.groupingBy { it }.eachCount()[true] ?: 0) > 1 + } + + private fun getFeedsIdsForNewItems(syncResult: SyncResult): List { + val feedsIds = mutableListOf() + + syncResult.items.forEach { + if (it.feedId !in feedsIds) + feedsIds += it.feedId + } + + return feedsIds + } + + private fun getFeedsIdsForNewItems(syncResults: Map): List { + val feedsIds = mutableListOf() + + syncResults.values.forEach { feedsIds += getFeedsIdsForNewItems(it) } + return feedsIds + } + + private fun isFeedNotificationEnabledForItem(feeds: List, item: Item): Boolean = + feeds.find { it.id == item.feedId }?.isNotificationEnabled!! +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/sync/SyncBroadcastReceiver.kt b/app/src/main/java/com/readrops/app/sync/SyncBroadcastReceiver.kt new file mode 100644 index 00000000..8b3cb5cb --- /dev/null +++ b/app/src/main/java/com/readrops/app/sync/SyncBroadcastReceiver.kt @@ -0,0 +1,44 @@ +package com.readrops.app.sync + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat +import com.readrops.db.Database +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + + +class SyncBroadcastReceiver : BroadcastReceiver(), KoinComponent { + + private val notificationManager by inject() + private val database by inject() + + @OptIn(DelicateCoroutinesApi::class) + override fun onReceive(context: Context, intent: Intent) { + notificationManager.cancel(SyncWorker.SYNC_RESULT_NOTIFICATION_ID) + + when (intent.action) { + ACTION_MARK_READ -> { + val id = intent.getIntExtra(SyncWorker.ITEM_ID_KEY, -1) + GlobalScope.launch { + database.itemDao().updateReadState(id, true) + } + } + ACTION_SET_FAVORITE -> { + val id = intent.getIntExtra(SyncWorker.ITEM_ID_KEY, -1) + GlobalScope.launch { + database.itemDao().updateStarState(id, true) + } + } + } + } + + companion object { + const val ACTION_MARK_READ = "ACTION_MARK_READ" + const val ACTION_SET_FAVORITE = "ACTION_SET_FAVORITE" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/sync/SyncWorker.kt b/app/src/main/java/com/readrops/app/sync/SyncWorker.kt new file mode 100644 index 00000000..122d266b --- /dev/null +++ b/app/src/main/java/com/readrops/app/sync/SyncWorker.kt @@ -0,0 +1,412 @@ +package com.readrops.app.sync + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.Action +import androidx.core.app.NotificationCompat.Builder +import androidx.core.app.NotificationManagerCompat +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.readrops.api.services.Credentials +import com.readrops.api.utils.AuthInterceptor +import com.readrops.app.MainActivity +import com.readrops.app.R +import com.readrops.app.ReadropsApp +import com.readrops.app.repositories.BaseRepository +import com.readrops.app.repositories.ErrorResult +import com.readrops.app.repositories.SyncResult +import com.readrops.app.util.FeedColors +import com.readrops.app.util.putSerializable +import com.readrops.db.Database +import com.readrops.db.entities.account.Account +import kotlinx.coroutines.flow.first +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.component.inject +import org.koin.core.parameter.parametersOf +import java.util.concurrent.TimeUnit + + +class SyncWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params), KoinComponent { + + private val notificationManager by inject() + private val database by inject() + + override suspend fun doWork(): Result { + val isManual = tags.contains(WORK_MANUAL) + + val infos = WorkManager.getInstance(applicationContext) + .getWorkInfosByTagFlow(TAG).first() + + if (infos.any { it.state == WorkInfo.State.RUNNING && it.id != id }) { + return if (isManual) { + Result.failure( + workDataOf( + SYNC_FAILURE_KEY to true, + ) + .putSerializable( + SYNC_FAILURE_EXCEPTION_KEY, + Exception(applicationContext.getString(R.string.background_sync_already_running)) + ) + ) + } else { + Result.retry() + } + } + + val notificationBuilder = Builder(applicationContext, ReadropsApp.SYNC_CHANNEL_ID) + .setProgress(0, 0, true) + .setSmallIcon(R.drawable.ic_sync) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) // for Android 7.1 and earlier + .setOngoing(true) + .setOnlyAlertOnce(true) + + return try { + val (workResult, syncResults) = refreshAccounts(notificationBuilder) + notificationManager.cancel(SYNC_NOTIFICATION_ID) + + if (!isManual) { + displaySyncResults(syncResults) + } + + workResult + } catch (e: Exception) { + Log.e(TAG, "${e::class.simpleName}: ${e.message} ${e.printStackTrace()}") + + notificationManager.cancel(SYNC_NOTIFICATION_ID) + if (isManual) { + Result.failure( + workDataOf(SYNC_FAILURE_KEY to true) + .putSerializable(SYNC_FAILURE_EXCEPTION_KEY, e) + ) + } else { + Result.failure() + } + } + } + + private suspend fun refreshAccounts(notificationBuilder: Builder): Pair> { + val sharedPreferences = get() + var workResult = Result.success(workDataOf(END_SYNC_KEY to true)) + val syncResults = mutableMapOf() + + val accountId = inputData.getInt(ACCOUNT_ID_KEY, -1) + val accounts = if (accountId == -1) { + database.accountDao().selectAllAccounts().first() + } else { + listOf(database.accountDao().select(accountId)) + } + + for (account in accounts) { + if (!account.isLocal) { + account.login = sharedPreferences.getString(account.loginKey, null) + account.password = sharedPreferences.getString(account.passwordKey, null) + } + + val repository = get { parametersOf(account) } + + notificationBuilder.setContentTitle( + applicationContext.resources.getString( + R.string.updating_account, + account.accountName + ) + ) + + if (notificationManager.areNotificationsEnabled()) { + notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build()) + } + + if (account.isLocal) { + val result = refreshLocalAccount(repository, account, notificationBuilder) + + if (result.second.isNotEmpty() && tags.contains(WORK_MANUAL)) { + workResult = Result.success( + workDataOf(END_SYNC_KEY to true) + .putSerializable(LOCAL_SYNC_ERRORS_KEY, result.second) + ) + } + + syncResults[account] = result.first + } else { + get().credentials = Credentials.toCredentials(account) + + val syncResult = repository.synchronize() + fetchFeedColors(syncResult, notificationBuilder) + + syncResults[account] = syncResult + } + } + + return workResult to syncResults + } + + @SuppressLint("MissingPermission") + private suspend fun refreshLocalAccount( + repository: BaseRepository, + account: Account, + notificationBuilder: Builder + ): Pair { + val feedId = inputData.getInt(FEED_ID_KEY, 0) + val folderId = inputData.getInt(FOLDER_ID_KEY, 0) + + val feeds = when { + feedId > 0 -> listOf(database.feedDao().selectFeed(feedId)) + folderId > 0 -> database.feedDao().selectFeedsByFolder(folderId) + else -> listOf() + } + + var feedCount = 0 + val feedMax = if (feeds.isNotEmpty()) { + feeds.size + } else { + database.feedDao().selectFeedCount(account.id) + } + + val result = repository.synchronize( + selectedFeeds = feeds, + onUpdate = { feed -> + notificationBuilder.setContentText(feed.name) + .setStyle(NotificationCompat.BigTextStyle().bigText(feed.name)) + .setProgress(feedMax, ++feedCount, false) + notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build()) + + setProgress( + workDataOf( + FEED_NAME_KEY to feed.name, + FEED_MAX_KEY to feedMax, + FEED_COUNT_KEY to feedCount + ) + ) + } + ) + + if (result.second.isNotEmpty()) { + Log.e( + TAG, + "refreshing local account ${account.accountName}: ${result.second.size} errors" + ) + } + + return result + } + + private suspend fun fetchFeedColors( + syncResult: SyncResult, + notificationBuilder: Builder + ) = with(syncResult) { + notificationBuilder.setContentTitle(applicationContext.getString(R.string.get_feeds_colors)) + + for ((index, feed) in feeds.withIndex()) { + notificationBuilder.setContentText(feed.name) + .setStyle(NotificationCompat.BigTextStyle().bigText(feed.name)) + .setProgress(feeds.size, index + 1, false) + + if (notificationManager.areNotificationsEnabled()) { + notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build()) + } + + try { + if (feed.iconUrl != null) { + val color = FeedColors.getFeedColor(feed.iconUrl!!) + database.feedDao().updateFeedColor(feed.id, color) + } + } catch (e: Exception) { + Log.e(TAG, "${feed.name}: ${e.message}") + } + } + } + + private suspend fun displaySyncResults(syncResults: Map) { + val notificationContent = SyncAnalyzer(applicationContext, database) + .getNotificationContent(syncResults) + + if (notificationContent != null) { + val intent = Intent(applicationContext, MainActivity::class.java).apply { + if (notificationContent.accountId > 0) { + putExtra(ACCOUNT_ID_KEY, notificationContent.accountId) + } + + if (notificationContent.item != null) { + putExtra(ITEM_ID_KEY, notificationContent.item.id) + } + } + + val notificationBuilder = Builder(applicationContext, ReadropsApp.SYNC_CHANNEL_ID) + .setContentTitle(notificationContent.title) + .setContentText(notificationContent.text) + .setStyle(NotificationCompat.BigTextStyle().bigText(notificationContent.text)) + .setSmallIcon(R.drawable.ic_notifications) + .setColor(notificationContent.color) + .setContentIntent( + PendingIntent.getActivity( + applicationContext, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + .setAutoCancel(true) + + notificationContent.item?.let { item -> + val itemId = item.id + + notificationBuilder + .addAction(getMarkReadAction(itemId)) + .addAction(getMarkFavoriteAction(itemId)) + } + + notificationContent.largeIcon?.let { notificationBuilder.setLargeIcon(it) } + + if (notificationManager.areNotificationsEnabled()) { + notificationManager.notify(SYNC_RESULT_NOTIFICATION_ID, notificationBuilder.build()) + } + } + } + + private fun getMarkReadAction(itemId: Int): Action { + val intent = Intent(applicationContext, SyncBroadcastReceiver::class.java).apply { + action = SyncBroadcastReceiver.ACTION_MARK_READ + putExtra(ITEM_ID_KEY, itemId) + } + + val pendingIntent = + PendingIntent.getBroadcast( + applicationContext, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + + return Action.Builder( + R.drawable.ic_done_all, + applicationContext.getString(R.string.mark_read), + pendingIntent + ) + .setAllowGeneratedReplies(false) + .build() + } + + private fun getMarkFavoriteAction(itemId: Int): Action { + val intent = Intent(applicationContext, SyncBroadcastReceiver::class.java).apply { + action = SyncBroadcastReceiver.ACTION_SET_FAVORITE + putExtra(ITEM_ID_KEY, itemId) + } + + val pendingIntent = + PendingIntent.getBroadcast( + applicationContext, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return Action.Builder( + R.drawable.ic_favorite_border, + applicationContext.getString(R.string.add_to_favorite), + pendingIntent + ) + .setAllowGeneratedReplies(false) + .build() + } + + companion object { + private val TAG: String = SyncWorker::class.java.simpleName + + private val WORK_AUTO = "$TAG-auto" + private val WORK_MANUAL = "$TAG-manual" + + private const val SYNC_NOTIFICATION_ID = 2 + const val SYNC_RESULT_NOTIFICATION_ID = 3 + + const val END_SYNC_KEY = "END_SYNC" + const val SYNC_FAILURE_KEY = "SYNC_FAILURE" + const val SYNC_FAILURE_EXCEPTION_KEY = "SYNC_FAILURE_EXCEPTION" + const val ACCOUNT_ID_KEY = "ACCOUNT_ID" + const val FEED_ID_KEY = "FEED_ID" + const val ITEM_ID_KEY = "ITEM_ID" + const val FOLDER_ID_KEY = "FOLDER_ID" + const val FEED_NAME_KEY = "FEED_NAME" + const val FEED_MAX_KEY = "FEED_MAX" + const val FEED_COUNT_KEY = "FEED_COUNT" + const val LOCAL_SYNC_ERRORS_KEY = "LOCAL_SYNC_ERRORS" + + suspend fun startNow(context: Context, data: Data, onUpdate: (WorkInfo) -> Unit) { + val request = OneTimeWorkRequestBuilder() + .addTag(TAG) + .addTag(WORK_MANUAL) + .setInputData(data) + .build() + + WorkManager.getInstance(context).apply { + enqueueUniqueWork(WORK_MANUAL, ExistingWorkPolicy.KEEP, request) + getWorkInfoByIdFlow(request.id) + .collect { workInfo -> + if (workInfo != null) { + onUpdate(workInfo) + } + } + } + } + + fun startPeriodically(context: Context, period: String) { + val workManager = WorkManager.getInstance(context) + + val interval = when (period) { + "0.30" -> 30L to TimeUnit.MINUTES + "1" -> 1L to TimeUnit.HOURS + "2" -> 2L to TimeUnit.HOURS + "3" -> 3L to TimeUnit.HOURS + "6" -> 6L to TimeUnit.HOURS + "12" -> 12L to TimeUnit.HOURS + "24" -> 1L to TimeUnit.DAYS + else -> null + } + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + if (interval != null) { + val request = PeriodicWorkRequest.Builder( + SyncWorker::class.java, + interval.first, + interval.second + ) + .addTag(TAG) + .addTag(WORK_AUTO) + .setConstraints(constraints) + .setInitialDelay(interval.first, interval.second) + .setBackoffCriteria(BackoffPolicy.LINEAR, interval.first, interval.second) + .build() + + workManager.enqueueUniquePeriodicWork( + WORK_AUTO, + ExistingPeriodicWorkPolicy.UPDATE, + request + ) + } else { + workManager.cancelAllWorkByTag(WORK_AUTO) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/ErrorListDialog.kt b/app/src/main/java/com/readrops/app/timelime/ErrorListDialog.kt new file mode 100644 index 00000000..3a05aa56 --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/ErrorListDialog.kt @@ -0,0 +1,54 @@ +package com.readrops.app.timelime + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.readrops.app.R +import com.readrops.app.repositories.ErrorResult +import com.readrops.app.util.ErrorMessage +import com.readrops.app.util.components.dialog.BaseDialog +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.ShortSpacer + +@Composable +fun ErrorListDialog( + errorResult: ErrorResult, + onDismiss: () -> Unit, +) { + val scrollableState = rememberScrollState() + + BaseDialog( + title = stringResource(R.string.synchronization_errors), + icon = painterResource(id = R.drawable.ic_error), + onDismiss = onDismiss, + modifier = Modifier.heightIn(max = 500.dp) + ) { + Text( + text = pluralStringResource( + id = R.plurals.error_occurred_feed, + count = errorResult.size + ) + ) + + MediumSpacer() + + Column( + modifier = Modifier.verticalScroll(scrollableState) + ) { + for (error in errorResult.entries) { + Text(text = "${error.key.name}: ${ErrorMessage.get(error.value, LocalContext.current)}") + + ShortSpacer() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/FilterBottomSheet.kt b/app/src/main/java/com/readrops/app/timelime/FilterBottomSheet.kt new file mode 100644 index 00000000..3b4d4399 --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/FilterBottomSheet.kt @@ -0,0 +1,85 @@ +package com.readrops.app.timelime + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.readrops.app.R +import com.readrops.app.util.theme.LargeSpacer +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.app.util.theme.spacing +import com.readrops.db.filters.ListSortType +import com.readrops.db.queries.QueryFilters + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilterBottomSheet( + onSetShowReadItemsState: () -> Unit, + onSetSortTypeState: () -> Unit, + filters: QueryFilters, + onDismiss: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss + ) { + Column( + modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing) + ) { + Text( + text = stringResource(R.string.filters) + ) + + ShortSpacer() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onSetShowReadItemsState) + ) { + Checkbox( + checked = filters.showReadItems, + onCheckedChange = { onSetShowReadItemsState() } + ) + + ShortSpacer() + + Text( + text = stringResource(R.string.show_read_articles) + ) + } + + ShortSpacer() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onSetSortTypeState) + ) { + Checkbox( + checked = filters.sortType == ListSortType.OLDEST_TO_NEWEST, + onCheckedChange = { onSetSortTypeState() } + ) + + ShortSpacer() + + Text( + text = stringResource(R.string.show_oldest_articles_first) + ) + } + + LargeSpacer() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/TimelineItem.kt b/app/src/main/java/com/readrops/app/timelime/TimelineItem.kt new file mode 100644 index 00000000..e26c9ef4 --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/TimelineItem.kt @@ -0,0 +1,113 @@ +package com.readrops.app.timelime + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.readrops.app.util.DefaultPreview +import com.readrops.app.util.theme.ReadropsTheme +import com.readrops.db.entities.Folder +import com.readrops.db.pojo.ItemWithFeed +import java.time.LocalDateTime + +enum class TimelineItemSize { + COMPACT, + REGULAR, + LARGE +} + +@Composable +fun TimelineItem( + itemWithFeed: ItemWithFeed, + onClick: () -> Unit, + onFavorite: () -> Unit, + onShare: () -> Unit, + modifier: Modifier = Modifier, + size: TimelineItemSize = TimelineItemSize.LARGE, +) { + when (size) { + TimelineItemSize.COMPACT -> { + CompactTimelineItem( + itemWithFeed = itemWithFeed, + onClick = onClick, + onFavorite = onFavorite, + onShare = onShare, + modifier = modifier + ) + } + TimelineItemSize.REGULAR -> { + RegularTimelineItem( + itemWithFeed = itemWithFeed, + onClick = onClick, + onFavorite = onFavorite, + onShare = onShare, + modifier = modifier + ) + } + TimelineItemSize.LARGE -> { + LargeTimelineItem( + itemWithFeed = itemWithFeed, + onClick = onClick, + onFavorite = onFavorite, + onShare = onShare, + modifier = modifier + ) + } + } +} + +private val itemWithFeed = ItemWithFeed( + item = com.readrops.db.entities.Item( + title = "This is a not so long item title", + pubDate = LocalDateTime.now(), + cleanDescription = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Donec a tortor neque. Nam ultrices, diam ac congue finibus, tortor sem congue urna, + at finibus elit libero at mi. Etiam hendrerit sapien eu porta feugiat. Duis porttitor""" + .replace("\n", "") + .trimMargin(), + imageLink = "" + ), + feedName = "feed name", + color = 0, + feedId = 0, + feedIconUrl = "", + websiteUrl = "", + folder = Folder(name = "Folder name") +) + +@DefaultPreview +@Composable +private fun RegularTimelineItemPreview() { + ReadropsTheme { + RegularTimelineItem( + itemWithFeed = itemWithFeed, + onClick = {}, + onFavorite = {}, + onShare = {}, + ) + } +} + +@DefaultPreview +@Composable +private fun CompactTimelineItemPreview() { + ReadropsTheme { + CompactTimelineItem( + itemWithFeed = itemWithFeed, + onClick = {}, + onFavorite = {}, + onShare = {}, + ) + } +} + +@DefaultPreview +@Composable +private fun LargeTimelineItemPreview() { + ReadropsTheme { + LargeTimelineItem( + itemWithFeed = itemWithFeed, + onClick = {}, + onFavorite = {}, + onShare = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/TimelineItemParts.kt b/app/src/main/java/com/readrops/app/timelime/TimelineItemParts.kt new file mode 100644 index 00000000..7bda6964 --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/TimelineItemParts.kt @@ -0,0 +1,393 @@ +package com.readrops.app.timelime + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.readrops.app.R +import com.readrops.app.util.components.FeedIcon +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.app.util.theme.spacing +import com.readrops.db.pojo.ItemWithFeed +import com.readrops.db.util.DateUtils +import java.time.LocalDateTime +import kotlin.math.roundToInt + +@Composable +fun RegularTimelineItem( + itemWithFeed: ItemWithFeed, + onClick: () -> Unit, + onFavorite: () -> Unit, + onShare: () -> Unit, + modifier: Modifier = Modifier +) { + TimelineItemContainer( + isRead = itemWithFeed.item.isRead, + onClick = onClick, + modifier = modifier + ) { + Column( + modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing) + ) { + TimelineItemHeader( + feedName = itemWithFeed.feedName, + feedIconUrl = itemWithFeed.feedIconUrl, + feedColor = itemWithFeed.color, + folderName = itemWithFeed.folder?.name, + date = itemWithFeed.item.pubDate!!, + duration = itemWithFeed.item.readTime, + isStarred = itemWithFeed.item.isStarred, + onFavorite = onFavorite, + onShare = onShare + ) + + ShortSpacer() + + TimelineItemTitle(title = itemWithFeed.item.title!!) + + ShortSpacer() + + TimelineItemBadge( + date = itemWithFeed.item.pubDate!!, + duration = itemWithFeed.item.readTime, + color = itemWithFeed.color + ) + } + } +} + +@Composable +fun CompactTimelineItem( + itemWithFeed: ItemWithFeed, + onClick: () -> Unit, + onFavorite: () -> Unit, + onShare: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = modifier + .fillMaxWidth() + .alpha(if (itemWithFeed.item.isRead) 0.6f else 1f) + .clickable { onClick() } + ) { + Column( + modifier = Modifier.padding( + start = MaterialTheme.spacing.shortSpacing, + end = MaterialTheme.spacing.shortSpacing, + top = MaterialTheme.spacing.shortSpacing + ) + ) { + TimelineItemHeader( + feedName = itemWithFeed.feedName, + feedIconUrl = itemWithFeed.feedIconUrl, + feedColor = itemWithFeed.color, + folderName = itemWithFeed.folder?.name, + onFavorite = onFavorite, + onShare = onShare, + date = itemWithFeed.item.pubDate!!, + duration = itemWithFeed.item.readTime, + isStarred = itemWithFeed.item.isStarred, + displayActions = false + ) + + ShortSpacer() + + TimelineItemTitle(title = itemWithFeed.item.title!!) + + ShortSpacer() + + HorizontalDivider( + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.shortSpacing) + ) + } + } +} + +@Composable +fun LargeTimelineItem( + itemWithFeed: ItemWithFeed, + onClick: () -> Unit, + onFavorite: () -> Unit, + onShare: () -> Unit, + modifier: Modifier = Modifier +) { + if (itemWithFeed.item.cleanDescription == null && !itemWithFeed.item.hasImage) { + RegularTimelineItem( + itemWithFeed = itemWithFeed, + onClick = onClick, + onFavorite = onFavorite, + onShare = onShare + ) + } else { + TimelineItemContainer( + isRead = itemWithFeed.item.isRead, + onClick = onClick, + modifier = modifier + ) { + Column { + Column( + modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing) + ) { + TimelineItemHeader( + feedName = itemWithFeed.feedName, + feedIconUrl = itemWithFeed.feedIconUrl, + feedColor = itemWithFeed.color, + folderName = itemWithFeed.folder?.name, + date = itemWithFeed.item.pubDate!!, + duration = itemWithFeed.item.readTime, + isStarred = itemWithFeed.item.isStarred, + onFavorite = onFavorite, + onShare = onShare + ) + + ShortSpacer() + + TimelineItemBadge( + date = itemWithFeed.item.pubDate!!, + duration = itemWithFeed.item.readTime, + color = itemWithFeed.color + ) + + ShortSpacer() + + TimelineItemTitle(title = itemWithFeed.item.title!!) + + if (itemWithFeed.item.cleanDescription != null) { + ShortSpacer() + + Text( + text = itemWithFeed.item.cleanDescription!!, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (itemWithFeed.item.hasImage) { + AsyncImage( + model = if (!LocalInspectionMode.current) { + itemWithFeed.item.imageLink + } else { + ImageRequest.Builder(LocalContext.current) + .data(R.drawable.ic_broken_image) + .build() + }, + contentDescription = itemWithFeed.item.title!!, + contentScale = ContentScale.Crop, + modifier = Modifier + .aspectRatio(16f / 9f) + .fillMaxWidth() + ) + } + } + } + } + +} + +@Composable +fun TimelineItemContainer( + isRead: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Card( + modifier = modifier + .padding(horizontal = MaterialTheme.spacing.shortSpacing) + .fillMaxWidth() + .alpha(if (isRead) 0.6f else 1f) + .clickable { onClick() } + ) { + content() + } +} + +@Composable +fun TimelineItemHeader( + feedName: String, + feedIconUrl: String?, + feedColor: Int, + folderName: String?, + date: LocalDateTime, + duration: Double, + isStarred: Boolean, + onFavorite: () -> Unit, + onShare: () -> Unit, + displayActions: Boolean = true +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + FeedIcon( + iconUrl = feedIconUrl, + name = feedName + ) + + ShortSpacer() + + Column { + Text( + text = feedName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (feedColor != 0) { + Color(feedColor) + } else { + MaterialTheme.colorScheme.primary + }, + ) + + if (!folderName.isNullOrEmpty()) { + Text( + text = folderName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + ShortSpacer() + + if (displayActions) { + Row { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + IconButton( + onClick = onFavorite + ) { + Icon( + imageVector = if (isStarred) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = null, + ) + } + } + + ShortSpacer() + + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer + ) { + IconButton( + onClick = onShare + ) { + Icon( + imageVector = Icons.Outlined.Share, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } else { + TimelineItemBadge( + date = date, + duration = duration, + color = feedColor + ) + } + } +} + + +@Composable +fun TimelineItemTitle( + title: String +) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + ) +} + +@Composable +fun TimelineItemBadge( + date: LocalDateTime, + duration: Double, + color: Int, +) { + val textColor = if (color != 0) Color.White else MaterialTheme.colorScheme.onPrimary + + + Surface( + color = if (color != 0) Color(color) else MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(48.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding( + horizontal = MaterialTheme.spacing.shortSpacing, + vertical = MaterialTheme.spacing.veryShortSpacing + ) + ) { + Text( + text = DateUtils.formattedDateByLocal(date), + style = MaterialTheme.typography.labelSmall, + color = textColor + ) + + Text( + text = "·", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.veryShortSpacing), + color = textColor + ) + + Text( + text = if (duration > 1) { + stringResource(id = R.string.read_time, duration.roundToInt()) + } else { + stringResource(id = R.string.read_time_lower_than_1) + }, + style = MaterialTheme.typography.labelSmall, + color = textColor + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/TimelineScreenModel.kt b/app/src/main/java/com/readrops/app/timelime/TimelineScreenModel.kt new file mode 100644 index 00000000..beee2d41 --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/TimelineScreenModel.kt @@ -0,0 +1,416 @@ +package com.readrops.app.timelime + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Stable +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.work.workDataOf +import cafe.adriel.voyager.core.model.screenModelScope +import com.readrops.app.base.TabScreenModel +import com.readrops.app.repositories.ErrorResult +import com.readrops.app.repositories.GetFoldersWithFeeds +import com.readrops.app.sync.SyncWorker +import com.readrops.app.util.Preferences +import com.readrops.app.util.clearSerializables +import com.readrops.app.util.getSerializable +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.Item +import com.readrops.db.filters.ListSortType +import com.readrops.db.filters.MainFilter +import com.readrops.db.filters.SubFilter +import com.readrops.db.pojo.ItemWithFeed +import com.readrops.db.queries.ItemsQueryBuilder +import com.readrops.db.queries.QueryFilters +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class TimelineScreenModel( + private val database: Database, + private val getFoldersWithFeeds: GetFoldersWithFeeds, + private val preferences: Preferences, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : TabScreenModel(database) { + + private val _timelineState = MutableStateFlow(TimelineState()) + val timelineState = _timelineState.asStateFlow() + + // separate this from main Timeline state for performances + // as it will be very often updated + private val _listIndexState = MutableStateFlow(0) + val listIndexState = _listIndexState.asStateFlow() + + private val filters = MutableStateFlow(_timelineState.value.filters) + + init { + screenModelScope.launch(dispatcher) { + combine( + accountEvent, + filters + ) { account, filters -> + Pair(account, filters.copy(accountId = account.id)) + }.collectLatest { (account, filters) -> + val query = + ItemsQueryBuilder.buildItemsQuery(filters, account.config.useSeparateState) + + _timelineState.update { + it.copy( + itemState = Pager( + config = PagingConfig( + initialLoadSize = 50, + pageSize = 50, + prefetchDistance = 15 + ), + pagingSourceFactory = { + database.itemDao().selectAll(query) + }, + ).flow + .transformLatest { value -> + if (!timelineState.value.isRefreshing) { + emit(value) + } + } + .cachedIn(screenModelScope), + isAccountLocal = account.isLocal, + scrollToTop = true + ) + } + + _listIndexState.update { 0 } + + preferences.hideReadFeeds.flow + .flatMapLatest { hideReadFeeds -> + getFoldersWithFeeds.get( + accountId = account.id, + mainFilter = filters.mainFilter, + useSeparateState = account.config.useSeparateState, + hideReadFeeds = hideReadFeeds + ) + } + .collect { foldersAndFeeds -> + _timelineState.update { + it.copy( + foldersAndFeeds = foldersAndFeeds + ) + } + } + + } + } + + screenModelScope.launch(dispatcher) { + accountEvent.flatMapLatest { + getFoldersWithFeeds.getNewItemsUnreadCount(it.id, it.config.useSeparateState) + }.collectLatest { count -> + _timelineState.update { + it.copy(unreadNewItemsCount = count) + } + } + } + + screenModelScope.launch(dispatcher) { + combine( + preferences.timelineItemSize.flow, + preferences.scrollRead.flow, + preferences.displayNotificationsPermission.flow + ) { a, b, c -> Triple(a, b, c) } + .collect { (itemSize, scrollRead, notificationPermission) -> + _timelineState.update { + it.copy( + itemSize = when (itemSize) { + "compact" -> TimelineItemSize.COMPACT + "regular" -> TimelineItemSize.REGULAR + else -> TimelineItemSize.LARGE + }, + markReadOnScroll = scrollRead, + displayNotificationsPermission = notificationPermission + ) + } + } + } + } + + @Suppress("UNCHECKED_CAST") + fun refreshTimeline(context: Context) { + screenModelScope.launch(dispatcher) { + val filterPair = with(filters.value) { + when (subFilter) { + SubFilter.FEED -> SyncWorker.FEED_ID_KEY to filterFeedId + SubFilter.FOLDER -> SyncWorker.FOLDER_ID_KEY to filterFolderId + else -> null + } + } + val accountPair = SyncWorker.ACCOUNT_ID_KEY to currentAccount!!.id + + val workData = if (filterPair != null) { + workDataOf(filterPair, accountPair) + } else { + workDataOf(accountPair) + } + + if (!currentAccount!!.isLocal) { + _timelineState.update { + it.copy(isRefreshing = true) + } + } + + SyncWorker.startNow(context, workData) { workInfo -> + when { + workInfo.outputData.getBoolean(SyncWorker.END_SYNC_KEY, false) -> { + val errors = workInfo.outputData.getSerializable(SyncWorker.LOCAL_SYNC_ERRORS_KEY) as ErrorResult? + workInfo.outputData.clearSerializables() + + _timelineState.update { + it.copy( + isRefreshing = false, + hideReadAllFAB = false, + scrollToTop = true, + localSyncErrors = errors?.ifEmpty { null } + ) + } + } + workInfo.outputData.getBoolean(SyncWorker.SYNC_FAILURE_KEY, false) -> { + val error = workInfo.outputData.getSerializable(SyncWorker.SYNC_FAILURE_EXCEPTION_KEY) as Exception? + workInfo.outputData.clearSerializables() + + _timelineState.update { + it.copy( + syncError = error, + isRefreshing = false, + hideReadAllFAB = false + ) + } + } + workInfo.progress.getString(SyncWorker.FEED_NAME_KEY) != null -> { + _timelineState.update { + it.copy( + isRefreshing = true, + hideReadAllFAB = true, + currentFeed = workInfo.progress.getString(SyncWorker.FEED_NAME_KEY) ?: "", + feedCount = workInfo.progress.getInt(SyncWorker.FEED_COUNT_KEY, 0), + feedMax = workInfo.progress.getInt(SyncWorker.FEED_MAX_KEY, 0) + ) + } + } + } + } + } + } + + fun openDrawer() { + _timelineState.update { it.copy(isDrawerOpen = true) } + } + + fun closeDrawer() { + _timelineState.update { it.copy(isDrawerOpen = false) } + } + + fun updateDrawerDefaultItem(selection: MainFilter) { + _timelineState.update { + it.copy( + filters = updateFilters { + it.filters.copy( + mainFilter = selection, + subFilter = SubFilter.ALL, + filterFeedId = 0, + filterFolderId = 0 + ) + }, + isDrawerOpen = false + ) + } + } + + fun updateDrawerFolderSelection(folder: Folder) { + _timelineState.update { + it.copy( + filters = updateFilters { + it.filters.copy( + subFilter = SubFilter.FOLDER, + filterFolderId = folder.id, + filterFeedId = 0 + ) + }, + filterFolderName = folder.name!!, + isDrawerOpen = false + ) + } + } + + fun updateDrawerFeedSelection(feed: Feed) { + _timelineState.update { + it.copy( + filters = updateFilters { + it.filters.copy( + subFilter = SubFilter.FEED, + filterFeedId = feed.id, + filterFolderId = 0 + ) + }, + filterFeedName = feed.name!!, + isDrawerOpen = false + ) + } + } + + private fun updateFilters(block: () -> QueryFilters): QueryFilters { + val filter = block() + filters.update { filter } + + return filter + } + + fun setItemRead(item: Item) { + item.isRead = true + updateItemReadState(item) + } + + private fun updateItemReadState(item: Item) { + screenModelScope.launch(dispatcher) { + repository?.setItemReadState(item) + } + } + + fun updateStarState(item: Item) { + screenModelScope.launch(dispatcher) { + with(item) { + isStarred = isStarred.not() + repository?.setItemStarState(this) + } + } + } + + fun shareItem(item: Item, context: Context) { + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, item.link) + }.also { + context.startActivity(Intent.createChooser(it, null)) + } + } + + fun setAllItemsRead() { + screenModelScope.launch(dispatcher) { + when (_timelineState.value.filters.subFilter) { + SubFilter.FEED -> + repository?.setAllItemsReadByFeed( + feedId = _timelineState.value.filters.filterFeedId + ) + + SubFilter.FOLDER -> repository?.setAllItemsReadByFolder( + folderId = _timelineState.value.filters.filterFolderId + ) + + else -> when (_timelineState.value.filters.mainFilter) { + MainFilter.STARS -> repository?.setAllStarredItemsRead() + MainFilter.ALL -> repository?.setAllItemsRead() + MainFilter.NEW -> repository?.setAllNewItemsRead() + } + } + } + } + + fun openDialog(dialog: DialogState) = _timelineState.update { it.copy(dialog = dialog) } + + fun closeDialog(dialog: DialogState? = null) { + if (dialog is DialogState.ErrorList) { + _timelineState.update { it.copy(localSyncErrors = null) } + } + + _timelineState.update { it.copy(dialog = null) } + } + + fun setShowReadItemsState(showReadItems: Boolean) { + _timelineState.update { + it.copy( + filters = updateFilters { + it.filters.copy( + showReadItems = showReadItems + ) + } + ) + } + } + + fun setSortTypeState(sortType: ListSortType) { + _timelineState.update { + it.copy( + filters = updateFilters { + it.filters.copy( + sortType = sortType + ) + } + ) + } + } + + fun resetScrollToTop() { + _timelineState.update { it.copy(scrollToTop = false) } + } + + fun resetSyncError() { + _timelineState.update { it.copy(syncError = null) } + } + + fun updateLastFirstVisibleItemIndex(index: Int) { + _listIndexState.update { index } + } + + fun disableDisplayNotificationsPermission() { + screenModelScope.launch { + preferences.displayNotificationsPermission.write(false) + } + } +} + +@Stable +data class TimelineState( + val isRefreshing: Boolean = false, + val isDrawerOpen: Boolean = false, + val currentFeed: String = "", + val unreadNewItemsCount: Int = 0, + val feedCount: Int = 0, + val feedMax: Int = 0, + val scrollToTop: Boolean = false, + val localSyncErrors: ErrorResult? = null, + val syncError: Exception? = null, + val filters: QueryFilters = QueryFilters(), + val filterFeedName: String = "", + val filterFolderName: String = "", + val foldersAndFeeds: Map> = emptyMap(), + val itemState: Flow> = emptyFlow(), + val dialog: DialogState? = null, + val isAccountLocal: Boolean = false, + val hideReadAllFAB: Boolean = false, + val itemSize: TimelineItemSize = TimelineItemSize.LARGE, + val markReadOnScroll: Boolean = false, + val displayNotificationsPermission: Boolean = false +) { + + val showSubtitle = filters.subFilter != SubFilter.ALL + + val displayRefreshScreen = isRefreshing && isAccountLocal +} + +sealed interface DialogState { + data object ConfirmDialog : DialogState + data object FilterSheet : DialogState + class ErrorList(val errorResult: ErrorResult) : DialogState +} diff --git a/app/src/main/java/com/readrops/app/timelime/TimelineTab.kt b/app/src/main/java/com/readrops/app/timelime/TimelineTab.kt new file mode 100644 index 00000000..ce706d6b --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/TimelineTab.kt @@ -0,0 +1,491 @@ +package com.readrops.app.timelime + +import android.Manifest +import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.rememberDrawerState +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import cafe.adriel.voyager.koin.getScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabOptions +import com.readrops.app.R +import com.readrops.app.item.ItemScreen +import com.readrops.app.timelime.drawer.TimelineDrawer +import com.readrops.app.util.ErrorMessage +import com.readrops.app.util.components.CenteredProgressIndicator +import com.readrops.app.util.components.Placeholder +import com.readrops.app.util.components.RefreshScreen +import com.readrops.app.util.components.dialog.TwoChoicesDialog +import com.readrops.app.util.theme.spacing +import com.readrops.db.filters.ListSortType +import com.readrops.db.filters.MainFilter +import com.readrops.db.filters.SubFilter +import com.readrops.db.pojo.ItemWithFeed +import kotlinx.coroutines.flow.filter + + +object TimelineTab : Tab { + + override val options: TabOptions + @Composable + get() = TabOptions( + index = 1u, + title = stringResource(id = R.string.timeline), + ) + + @SuppressLint("InlinedApi") + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val context = LocalContext.current + + val screenModel = getScreenModel() + val state by screenModel.timelineState.collectAsStateWithLifecycle() + val items = state.itemState.collectAsLazyPagingItems() + + val lazyListState = rememberLazyListState() + val pullToRefreshState = rememberPullToRefreshState() + val snackbarHostState = remember { SnackbarHostState() } + val topAppBarState = rememberTopAppBarState() + val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) + + val launcher = rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { + screenModel.disableDisplayNotificationsPermission() + } + + LaunchedEffect(state.displayNotificationsPermission) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU + && state.displayNotificationsPermission + ) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + LaunchedEffect(state.isRefreshing) { + if (state.isRefreshing) { + pullToRefreshState.startRefresh() + } else { + pullToRefreshState.endRefresh() + } + } + + // Material3 pull to refresh doesn't have a onRefresh callback, + // so we need to listen to the internal state change to trigger the refresh + LaunchedEffect(pullToRefreshState.isRefreshing) { + if (pullToRefreshState.isRefreshing && !state.isRefreshing) { + screenModel.refreshTimeline(context) + } + } + + LaunchedEffect(state.scrollToTop) { + if (state.scrollToTop) { + lazyListState.scrollToItem(0) + screenModel.resetScrollToTop() + topAppBarState.contentOffset = 0f + } + } + + val drawerState = rememberDrawerState( + initialValue = DrawerValue.Closed, + confirmStateChange = { + if (it == DrawerValue.Closed) { + screenModel.closeDrawer() + } else { + screenModel.openDrawer() + } + + true + } + ) + + BackHandler( + enabled = state.isDrawerOpen, + onBack = { screenModel.closeDrawer() } + ) + + LaunchedEffect(state.isDrawerOpen) { + if (state.isDrawerOpen) { + drawerState.open() + } else { + drawerState.close() + } + } + + LaunchedEffect(state.localSyncErrors) { + if (state.localSyncErrors != null) { + val action = snackbarHostState.showSnackbar( + message = context.resources.getQuantityString( + R.plurals.error_occurred, + state.localSyncErrors!!.size + ), + actionLabel = context.getString(R.string.details), + duration = SnackbarDuration.Short + ) + + if (action == SnackbarResult.ActionPerformed) { + screenModel.openDialog(DialogState.ErrorList(state.localSyncErrors!!)) + } else { + // remove errors from state + screenModel.closeDialog(DialogState.ErrorList(state.localSyncErrors!!)) + } + } + } + + LaunchedEffect(state.syncError) { + if (state.syncError != null) { + snackbarHostState.showSnackbar(ErrorMessage.get(state.syncError!!, context)) + screenModel.resetSyncError() + } + } + + when (val dialog = state.dialog) { + is DialogState.ConfirmDialog -> { + TwoChoicesDialog( + title = stringResource(R.string.mark_all_articles_read), + text = stringResource(R.string.mark_all_articles_read_question), + icon = painterResource(id = R.drawable.ic_rss_feed_grey), + confirmText = stringResource(id = R.string.validate), + dismissText = stringResource(id = R.string.cancel), + onDismiss = { screenModel.closeDialog() }, + onConfirm = { + screenModel.closeDialog() + screenModel.setAllItemsRead() + } + ) + } + + is DialogState.FilterSheet -> { + FilterBottomSheet( + filters = state.filters, + onSetShowReadItemsState = { + screenModel.setShowReadItemsState(!state.filters.showReadItems) + }, + onSetSortTypeState = { + screenModel.setSortTypeState( + if (state.filters.sortType == ListSortType.NEWEST_TO_OLDEST) + ListSortType.OLDEST_TO_NEWEST + else + ListSortType.NEWEST_TO_OLDEST + ) + }, + onDismiss = { screenModel.closeDialog() } + ) + } + + is DialogState.ErrorList -> { + ErrorListDialog( + errorResult = dialog.errorResult, + onDismiss = { screenModel.closeDialog(dialog) } + ) + } + + null -> {} + } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + TimelineDrawer( + state = state, + onClickDefaultItem = { + screenModel.updateDrawerDefaultItem(it) + }, + onFolderClick = { + screenModel.updateDrawerFolderSelection(it) + }, + onFeedClick = { + screenModel.updateDrawerFeedSelection(it) + } + ) + } + ) { + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = when (state.filters.mainFilter) { + MainFilter.STARS -> stringResource(R.string.favorites) + MainFilter.ALL -> stringResource(R.string.articles) + MainFilter.NEW -> stringResource(R.string.new_articles) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (state.showSubtitle) { + Text( + text = when (state.filters.subFilter) { + SubFilter.FEED -> state.filterFeedName + SubFilter.FOLDER -> state.filterFolderName + else -> "" + }, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + }, + navigationIcon = { + IconButton( + onClick = { screenModel.openDrawer() } + ) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = null + ) + } + }, + actions = { + IconButton( + onClick = { screenModel.openDialog(DialogState.FilterSheet) } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_filter_list), + contentDescription = null + ) + } + + IconButton( + onClick = { screenModel.refreshTimeline(context) } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_sync), + contentDescription = null + ) + } + }, + scrollBehavior = topAppBarScrollBehavior + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + if (!state.hideReadAllFAB) { + FloatingActionButton( + onClick = { + if (state.filters.mainFilter == MainFilter.ALL) { + screenModel.openDialog(DialogState.ConfirmDialog) + } else { + screenModel.setAllItemsRead() + } + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_done_all), + contentDescription = null + ) + } + } + }, + ) { paddingValues -> + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .nestedScroll(pullToRefreshState.nestedScrollConnection) + .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) + ) { + when { + state.displayRefreshScreen -> RefreshScreen( + currentFeed = state.currentFeed, + feedCount = state.feedCount, + feedMax = state.feedMax + ) + + items.isLoading() -> { + CenteredProgressIndicator() + } + + items.isError() -> { + Placeholder( + text = stringResource(R.string.error_occured), + painter = painterResource(id = R.drawable.ic_error) + ) + } + + else -> { + if (items.itemCount > 0) { + MarkItemsRead( + lazyListState = lazyListState, + items = items, + markReadOnScroll = state.markReadOnScroll, + screenModel = screenModel + ) + + LazyColumn( + state = lazyListState, + contentPadding = PaddingValues( + vertical = if (state.itemSize == TimelineItemSize.COMPACT) { + 0.dp + } else { + MaterialTheme.spacing.shortSpacing + } + ), + verticalArrangement = Arrangement.spacedBy( + if (state.itemSize == TimelineItemSize.COMPACT) { + 0.dp + } else + MaterialTheme.spacing.shortSpacing + ) + ) { + items( + count = items.itemCount, + key = items.itemKey { it.item.id }, + ) { itemCount -> + val itemWithFeed = items[itemCount] + + if (itemWithFeed != null) { + TimelineItem( + itemWithFeed = itemWithFeed, + onClick = { + screenModel.setItemRead(itemWithFeed.item) + navigator.push(ItemScreen(itemWithFeed.item.id)) + }, + onFavorite = { + screenModel.updateStarState(itemWithFeed.item) + }, + onShare = { + screenModel.shareItem( + itemWithFeed.item, + context + ) + }, + size = state.itemSize + ) + + } + } + } + + PullToRefreshContainer( + state = pullToRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } else { + // Empty lazyColumn to let the pull to refresh be usable + // when the no item placeholder is displayed + LazyColumn( + modifier = Modifier.fillMaxSize() + ) {} + + PullToRefreshContainer( + state = pullToRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + + Placeholder( + text = stringResource(R.string.no_article), + painter = painterResource(R.drawable.ic_timeline), + ) + } + } + } + } + } + } + } + + @Composable + private fun MarkItemsRead( + lazyListState: LazyListState, + items: LazyPagingItems, + markReadOnScroll: Boolean, + screenModel: TimelineScreenModel + ) { + val lastFirstVisibleItemIndex by screenModel.listIndexState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + snapshotFlow { lazyListState.firstVisibleItemIndex } + .filter { + if (it < lastFirstVisibleItemIndex) { + screenModel.updateLastFirstVisibleItemIndex(it) + } + + it > lastFirstVisibleItemIndex + } + .collect { newLastFirstVisibleItemIndex -> + if (newLastFirstVisibleItemIndex - lastFirstVisibleItemIndex > 1) { + val difference = newLastFirstVisibleItemIndex - lastFirstVisibleItemIndex + + for (subCount in 0 until difference) { + val item = items[lastFirstVisibleItemIndex + subCount]?.item + + if (item != null && !item.isRead && markReadOnScroll) { + screenModel.setItemRead(item) + } + } + } else { + val item = items[lastFirstVisibleItemIndex]?.item + + if (item != null && !item.isRead && markReadOnScroll) { + screenModel.setItemRead(item) + } + } + + screenModel.updateLastFirstVisibleItemIndex(newLastFirstVisibleItemIndex) + } + } + } +} + + +fun LazyPagingItems.isLoading(): Boolean { + return loadState.refresh is LoadState.Loading && itemCount == 0 +} + +fun LazyPagingItems.isError(): Boolean { + return loadState.append is LoadState.Error //|| loadState.refresh is LoadState.Error +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/drawer/DrawerFeedItem.kt b/app/src/main/java/com/readrops/app/timelime/drawer/DrawerFeedItem.kt new file mode 100644 index 00000000..a7da5580 --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/drawer/DrawerFeedItem.kt @@ -0,0 +1,59 @@ +package com.readrops.app.timelime.drawer + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.readrops.app.util.theme.DrawerSpacing + +@Composable +fun DrawerFeedItem( + label: @Composable () -> Unit, + icon: @Composable () -> Unit, + badge: @Composable () -> Unit, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = NavigationDrawerItemDefaults.colors() + + Surface( + selected = selected, + onClick = onClick, + color = colors.containerColor(selected = selected).value, + shape = CircleShape, + modifier = modifier + .height(36.dp) + .fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 24.dp) + ) { + val iconColor = colors.iconColor(selected).value + CompositionLocalProvider(LocalContentColor provides iconColor, content = icon) + + DrawerSpacing() + + Box(Modifier.weight(1f)) { + val labelColor = colors.textColor(selected).value + CompositionLocalProvider(LocalContentColor provides labelColor, content = label) + } + + DrawerSpacing() + + val badgeColor = colors.badgeColor(selected).value + CompositionLocalProvider(LocalContentColor provides badgeColor, content = badge) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/drawer/DrawerFolderItem.kt b/app/src/main/java/com/readrops/app/timelime/drawer/DrawerFolderItem.kt new file mode 100644 index 00000000..6b1dc10c --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/drawer/DrawerFolderItem.kt @@ -0,0 +1,135 @@ +package com.readrops.app.timelime.drawer + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.readrops.app.util.components.FeedIcon +import com.readrops.app.util.theme.DrawerSpacing +import com.readrops.db.entities.Feed + +@Composable +fun DrawerFolderItem( + label: @Composable () -> Unit, + icon: @Composable () -> Unit, + badge: @Composable () -> Unit, + selected: Boolean, + onClick: () -> Unit, + feeds: List, + selectedFeed: Int, + onFeedClick: (Feed) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = NavigationDrawerItemDefaults.colors() + + var isExpanded by remember { mutableStateOf(feeds.any { it.id == selectedFeed }) } + val rotationState by animateFloatAsState( + targetValue = if (isExpanded) 180f else 0f, + label = "drawer item arrow rotation" + ) + + Column( + modifier = Modifier.animateContentSize( + animationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing, + ) + ) + ) { + Surface( + selected = selected, + onClick = onClick, + color = colors.containerColor(selected = selected).value, + shape = CircleShape, + modifier = modifier + .height(56.dp) + .fillMaxWidth() + .animateContentSize( + animationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing, + ) + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 24.dp) + ) { + val iconColor = colors.iconColor(selected).value + CompositionLocalProvider(LocalContentColor provides iconColor, content = icon) + + DrawerSpacing() + + Box(Modifier.weight(1f)) { + val labelColor = colors.textColor(selected).value + CompositionLocalProvider(LocalContentColor provides labelColor, content = label) + } + + DrawerSpacing() + + val badgeColor = colors.badgeColor(selected).value + CompositionLocalProvider(LocalContentColor provides badgeColor, content = badge) + + DrawerSpacing() + + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier + .clickable { isExpanded = isExpanded.not() } + .rotate(rotationState), + ) + } + } + + if (isExpanded && feeds.isNotEmpty()) { + for (feed in feeds) { + DrawerFeedItem( + label = { + Text( + text = feed.name!!, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + icon = { + FeedIcon( + iconUrl = feed.iconUrl, + name = feed.name!! + ) + }, + badge = { Text(feed.unreadCount.toString()) }, + selected = feed.id == selectedFeed, + onClick = { onFeedClick(feed) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/drawer/TimelineDrawer.kt b/app/src/main/java/com/readrops/app/timelime/drawer/TimelineDrawer.kt new file mode 100644 index 00000000..6049594c --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/drawer/TimelineDrawer.kt @@ -0,0 +1,180 @@ +package com.readrops.app.timelime.drawer + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.readrops.app.R +import com.readrops.app.timelime.TimelineState +import com.readrops.app.util.components.FeedIcon +import com.readrops.app.util.theme.spacing +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.filters.MainFilter + +@Composable +fun TimelineDrawer( + state: TimelineState, + onClickDefaultItem: (MainFilter) -> Unit, + onFolderClick: (Folder) -> Unit, + onFeedClick: (Feed) -> Unit, +) { + val scrollState = rememberScrollState() + + ModalDrawerSheet( + modifier = Modifier + .fillMaxHeight() + .verticalScroll(scrollState) + ) { + Spacer(modifier = Modifier.size(MaterialTheme.spacing.drawerSpacing)) + + DrawerDefaultItems( + selectedItem = state.filters.mainFilter, + unreadNewItemsCount = state.unreadNewItemsCount, + onClick = { onClickDefaultItem(it) } + ) + + DrawerDivider() + + Column { + for (folderEntry in state.foldersAndFeeds) { + val folder = folderEntry.key + + if (folder != null) { + DrawerFolderItem( + label = { + Text( + text = folder.name!!, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + icon = { + Icon( + painterResource(id = R.drawable.ic_folder_grey), + contentDescription = null + ) + }, + badge = { + Text(folderEntry.value.sumOf { it.unreadCount }.toString()) + }, + selected = state.filters.filterFolderId == folder.id, + onClick = { onFolderClick(folder) }, + feeds = folderEntry.value, + selectedFeed = state.filters.filterFeedId, + onFeedClick = { onFeedClick(it) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + } else { + val feeds = folderEntry.value + + for (feed in feeds) { + DrawerFeedItem( + label = { + Text( + text = feed.name!!, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + icon = { + FeedIcon( + iconUrl = feed.iconUrl, + name = feed.name!! + ) + }, + badge = { Text(feed.unreadCount.toString()) }, + selected = feed.id == state.filters.filterFeedId, + onClick = { onFeedClick(feed) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + } + } + } + } + } +} + +@Composable +fun DrawerDefaultItems( + selectedItem: MainFilter, + unreadNewItemsCount: Int, + onClick: (MainFilter) -> Unit, +) { + NavigationDrawerItem( + label = { Text(text = stringResource(R.string.articles)) }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_timeline), + contentDescription = null + ) + }, + selected = selectedItem == MainFilter.ALL, + onClick = { onClick(MainFilter.ALL) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + + NavigationDrawerItem( + label = { + Text( + "${stringResource(id = R.string.new_articles)} (${ + stringResource( + id = R.string.unread, + unreadNewItemsCount + ) + })" + ) + }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_new), + contentDescription = null + ) + }, + selected = selectedItem == MainFilter.NEW, + onClick = { onClick(MainFilter.NEW) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + + NavigationDrawerItem( + label = { Text(text = stringResource(R.string.favorites)) }, + icon = { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = null + ) + }, + selected = selectedItem == MainFilter.STARS, + onClick = { onClick(MainFilter.STARS) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) +} + +@Composable +fun DrawerDivider() { + Divider( + thickness = 2.dp, + modifier = Modifier.padding( + vertical = MaterialTheme.spacing.drawerSpacing, + horizontal = 28.dp // M3 guidelines + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/CrashActivity.kt b/app/src/main/java/com/readrops/app/util/CrashActivity.kt new file mode 100644 index 00000000..69b91f04 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/CrashActivity.kt @@ -0,0 +1,178 @@ +package com.readrops.app.util + +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.readrops.app.R +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.ReadropsTheme +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.app.util.theme.VeryLargeSpacer +import com.readrops.app.util.theme.VeryShortSpacer +import com.readrops.app.util.theme.spacing + +class CrashActivity : ComponentActivity() { + + @Suppress("DEPRECATION") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge(statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT)) + + val throwable = intent.getSerializableExtra(THROWABLE_KEY) as Throwable? + + val stackTrace = try { + throwable?.stackTraceToString() + + } catch (e: Exception) { + Log.e("CrashActivity", "Unable to get crash exception") + throwable?.let { it::class.simpleName + it.message } + } + + setContent { + ReadropsTheme { + CrashScreen(stackTrace.orEmpty()) + } + } + } + + companion object { + const val THROWABLE_KEY = "THROWABLE" + } +} + +@Composable +fun CrashScreen(stackTrace: String) { + val uriHandler = LocalUriHandler.current + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + + Surface( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + .padding(MaterialTheme.spacing.mediumSpacing) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + VeryLargeSpacer() + + Icon( + painter = painterResource(id = R.drawable.ic_bug), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(64.dp) + ) + + MediumSpacer() + + Text( + text = stringResource(R.string.readrops_crashed), + style = MaterialTheme.typography.titleLarge + ) + + ShortSpacer() + + Text( + text = stringResource(R.string.crash_message), + style = MaterialTheme.typography.bodyMedium, + ) + + MediumSpacer() + + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier + .weight(1f, fill = true) + .fillMaxWidth() + ) { + Text( + text = stackTrace, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Justify, + lineHeight = 20.sp, + modifier = Modifier + .padding(MaterialTheme.spacing.mediumSpacing) + .verticalScroll(rememberScrollState()) + .horizontalScroll(rememberScrollState()) + ) + } + + MediumSpacer() + + Column { + Button( + onClick = { + uriHandler.openUri("https://github.com/readrops/Readrops/issues/new") + clipboardManager.setText(AnnotatedString(stackTrace)) + displayToast(context) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.report_error_github)) + } + + VeryShortSpacer() + + OutlinedButton( + onClick = { + clipboardManager.setText(AnnotatedString(stackTrace)) + displayToast(context) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.copy_error_clipboard)) + } + } + } + } +} + +fun displayToast(context: Context) { + Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() +} + +@DefaultPreview +@Composable +private fun CrashScreenPreview() { + ReadropsTheme { + CrashScreen("") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/DefaultPreview.kt b/app/src/main/java/com/readrops/app/util/DefaultPreview.kt new file mode 100644 index 00000000..349a6772 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/DefaultPreview.kt @@ -0,0 +1,14 @@ +package com.readrops.app.util + +import android.content.res.Configuration +import androidx.compose.ui.tooling.preview.Preview + +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true +) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true +) +annotation class DefaultPreview diff --git a/app/src/main/java/com/readrops/app/util/ErrorMessage.kt b/app/src/main/java/com/readrops/app/util/ErrorMessage.kt new file mode 100644 index 00000000..574c1674 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/ErrorMessage.kt @@ -0,0 +1,40 @@ +package com.readrops.app.util + +import android.content.Context +import com.readrops.api.utils.exceptions.HttpException +import com.readrops.api.utils.exceptions.ParseException +import com.readrops.api.utils.exceptions.UnknownFormatException +import com.readrops.app.R +import java.io.IOException +import java.net.UnknownHostException + +object ErrorMessage { + + fun get(exception: Exception, context: Context) = when (exception) { + is HttpException -> getHttpMessage(exception, context) + is UnknownHostException -> context.resources.getString(R.string.unreachable_url) + is NoSuchFileException -> context.resources.getString(R.string.unable_open_file) + is IOException -> context.resources.getString(R.string.network_failure, exception.message.orEmpty()) + is ParseException, is UnknownFormatException -> context.resources.getString(R.string.processing_feed_error) + else -> "${exception.javaClass.simpleName}: ${exception.message}" + } + + private fun getHttpMessage(exception: HttpException, context: Context): String { + return when (exception.code) { + in 400..499 -> { + when (exception.code) { + 400 -> context.resources.getString(R.string.http_error_400) + 401 -> context.resources.getString(R.string.http_error_401) + 403 -> context.resources.getString(R.string.http_error_403) + 404 -> context.resources.getString(R.string.http_error_404) + else -> context.resources.getString(R.string.http_error_4XX, exception.code) + } + } + + in 500..599 -> { + context.resources.getString(R.string.http_error_5XX, exception.code) + } + else -> context.resources.getString(R.string.http_error, exception.code) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/Extensions.kt b/app/src/main/java/com/readrops/app/util/Extensions.kt new file mode 100644 index 00000000..876dddf0 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/Extensions.kt @@ -0,0 +1,29 @@ +package com.readrops.app.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.work.Data +import java.io.Serializable + +fun TextStyle.toDp(): Dp = fontSize.value.dp + +fun Context.openUrl(url: String) = startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + +val Data.serializables by lazy { + mutableMapOf() +} + +fun Data.putSerializable(key: String, parcelable: Serializable): Data { + serializables[key] = parcelable + return this +} + +fun Data.getSerializable(key: String): Serializable? = serializables[key] + +fun Data.clearSerializables() { + serializables.clear() +} diff --git a/app/src/main/java/com/readrops/app/util/FeedColors.kt b/app/src/main/java/com/readrops/app/util/FeedColors.kt new file mode 100644 index 00000000..fce3d24a --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/FeedColors.kt @@ -0,0 +1,49 @@ +package com.readrops.app.util + +import android.graphics.BitmapFactory +import androidx.annotation.ColorInt +import androidx.palette.graphics.Palette +import okhttp3.OkHttpClient +import okhttp3.Request +import org.koin.core.component.KoinComponent +import org.koin.core.component.get + +object FeedColors : KoinComponent { + + suspend fun getFeedColor(feedUrl: String): Int { + // use OkHttp directly instead of Coil as Coil doesn't respect OkHttp timeout + val response = get().newCall( + Request.Builder() + .url(feedUrl) + .build() + ).execute() + + val bitmap = BitmapFactory.decodeStream(response.body?.byteStream()) ?: return 0 + val palette = Palette.from(bitmap).generate() + + val dominantSwatch = palette.dominantSwatch + return if (dominantSwatch != null && !isColorTooBright(dominantSwatch.rgb) + && !isColorTooDark(dominantSwatch.rgb) + ) { + dominantSwatch.rgb + } else 0 + } + + private fun isColorTooBright(@ColorInt color: Int): Boolean { + return getColorLuma(color) > 210 + } + + private fun isColorTooDark(@ColorInt color: Int): Boolean { + return getColorLuma(color) < 40 + } + + private fun getColorLuma(@ColorInt color: Int): Double { + val r = color shr 16 and 0xff + val g = color shr 8 and 0xff + val b = color shr 0 and 0xff + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + + fun isColorDark(color: Int) = getColorLuma(color) < 130 + +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/Preferences.kt b/app/src/main/java/com/readrops/app/util/Preferences.kt new file mode 100644 index 00000000..81774c6f --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/Preferences.kt @@ -0,0 +1,85 @@ +package com.readrops.app.util + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +data class Preference( + val dataStore: DataStorePreferences, + val key: Preferences.Key, + val default: T, + val flow: Flow = dataStore.read(key, default) +) { + + suspend fun write(value: T) { + dataStore.write(key, value) + } +} + +class Preferences( + dataStore: DataStorePreferences, +) { + + val theme = Preference( + dataStore = dataStore, + key = stringPreferencesKey("theme"), + default = "system" + ) + + val backgroundSynchronization = Preference( + dataStore = dataStore, + key = stringPreferencesKey("synchro"), + default = "manual" + ) + + val scrollRead = Preference( + dataStore = dataStore, + key = booleanPreferencesKey("scroll_read"), + default = false + ) + + val hideReadFeeds = Preference( + dataStore = dataStore, + key = booleanPreferencesKey("hide_read_feeds"), + default = false + ) + + val openLinksWith = Preference( + dataStore = dataStore, + key = stringPreferencesKey("open_links_with"), + default = "navigator_view" + ) + + val timelineItemSize = Preference( + dataStore = dataStore, + key = stringPreferencesKey("timeline_item_size"), + default = "large" + ) + + val displayNotificationsPermission = Preference( + dataStore = dataStore, + key = booleanPreferencesKey("display_notification_permission"), + default = true + ) +} + + +class DataStorePreferences(private val dataStore: DataStore) { + + fun read(key: Preferences.Key, default: T): Flow { + return dataStore.data + .map { it[key] ?: default } + .distinctUntilChanged() + } + + suspend fun write(key: Preferences.Key, value: T) { + dataStore.edit { settings -> + settings[key] = value + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/Utils.kt b/app/src/main/java/com/readrops/app/util/Utils.kt new file mode 100644 index 00000000..0f5b9179 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/Utils.kt @@ -0,0 +1,25 @@ +package com.readrops.app.util + +import android.graphics.Color +import androidx.annotation.ColorInt +import java.util.Locale + +object Utils { + + private const val AVERAGE_WORDS_PER_MINUTE = 250 + + fun readTimeFromString(value: String): Double { + val nbWords = value.split(Regex("\\s+")).size + return nbWords.toDouble() / AVERAGE_WORDS_PER_MINUTE + } + + fun getCssColor(@ColorInt color: Int): String { + return String.format( + Locale.US, "rgba(%d,%d,%d,%.2f)", + Color.red(color), + Color.green(color), + Color.blue(color), + Color.alpha(color) / 255.0 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/AndroidScreen.kt b/app/src/main/java/com/readrops/app/util/components/AndroidScreen.kt new file mode 100644 index 00000000..969c7910 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/AndroidScreen.kt @@ -0,0 +1,10 @@ +package com.readrops.app.util.components + +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey + +abstract class AndroidScreen : Screen { + + override val key: ScreenKey = uniqueScreenKey +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/CenteredProgressIndicator.kt b/app/src/main/java/com/readrops/app/util/components/CenteredProgressIndicator.kt new file mode 100644 index 00000000..a53c5dab --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/CenteredProgressIndicator.kt @@ -0,0 +1,14 @@ +package com.readrops.app.util.components + +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun CenteredProgressIndicator( + modifier: Modifier = Modifier +) { + CenteredColumn { + CircularProgressIndicator(modifier) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/ErrorMessage.kt b/app/src/main/java/com/readrops/app/util/components/ErrorMessage.kt new file mode 100644 index 00000000..009cd92c --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/ErrorMessage.kt @@ -0,0 +1,51 @@ +package com.readrops.app.util.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.readrops.app.R +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.app.util.theme.VeryShortSpacer +import com.readrops.app.util.theme.spacing + +@Composable +fun ErrorMessage( + exception: Exception? +) { + CenteredColumn { + Icon( + painter = painterResource(id = R.drawable.ic_error), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing) + ) + + ShortSpacer() + + Text( + text = stringResource(R.string.error_occurred), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.error, + ) + + VeryShortSpacer() + + if (exception != null) { + val name = exception.javaClass.simpleName + val message = exception.message + + Text( + text = "$name: $message", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/FeedIcon.kt b/app/src/main/java/com/readrops/app/util/components/FeedIcon.kt new file mode 100644 index 00000000..bf8f92b1 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/FeedIcon.kt @@ -0,0 +1,26 @@ +package com.readrops.app.util.components + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.readrops.app.R + +@Composable +fun FeedIcon( + iconUrl: String?, + name: String, + size: Dp = 24.dp +) { + AsyncImage( + model = iconUrl, + error = painterResource(id = R.drawable.ic_rss_feed_grey), + placeholder = painterResource(R.drawable.ic_rss_feed_grey), + fallback = painterResource(id = R.drawable.ic_rss_feed_grey), + contentDescription = name, + modifier = Modifier.size(size) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/IconText.kt b/app/src/main/java/com/readrops/app/util/components/IconText.kt new file mode 100644 index 00000000..f22b00f0 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/IconText.kt @@ -0,0 +1,178 @@ +package com.readrops.app.util.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import com.readrops.app.util.theme.spacing +import com.readrops.app.util.toDp + +@Composable +fun BaseText( + text: String, + style: TextStyle, + modifier: Modifier = Modifier, + color: Color = LocalContentColor.current, + spacing: Dp = MaterialTheme.spacing.veryShortSpacing, + onClick: (() -> Unit)? = null, + leftContent: @Composable () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = if (onClick != null) modifier.clickable { onClick() } else modifier, + ) { + leftContent() + + Spacer(Modifier.width(spacing)) + + Text( + text = text, + style = style, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +fun IconText( + icon: Painter, + text: String, + style: TextStyle, + modifier: Modifier = Modifier, + color: Color = LocalContentColor.current, + tint: Color = LocalContentColor.current, + spacing: Dp = MaterialTheme.spacing.veryShortSpacing, + onClick: (() -> Unit)? = null, +) { + BaseText( + text = text, + style = style, + color = color, + spacing = spacing, + modifier = modifier, + onClick = onClick + ) { + Icon( + painter = icon, + tint = tint, + contentDescription = null, + modifier = Modifier.size(style.toDp()), + ) + } +} + +@Composable +fun ImageText( + image: Painter, + text: String, + style: TextStyle, + modifier: Modifier = Modifier, + color: Color = LocalContentColor.current, + spacing: Dp = MaterialTheme.spacing.veryShortSpacing, + imageSize: Dp = style.toDp(), + onClick: (() -> Unit)? = null +) { + BaseText( + text = text, + style = style, + color = color, + spacing = spacing, + modifier = modifier, + onClick = onClick + ) { + Image( + painter = image, + contentDescription = null, + modifier = Modifier.size(imageSize), + ) + } +} + + + +@Composable +fun SelectableIconText( + icon: Painter, + text: String, + style: TextStyle, + onClick: () -> Unit, + modifier: Modifier = Modifier, + color: Color = LocalContentColor.current, + tint: Color = LocalContentColor.current, + iconSize: Dp = style.toDp(), + spacing: Dp = MaterialTheme.spacing.veryShortSpacing, + padding: Dp = MaterialTheme.spacing.shortSpacing +) { + Box( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(padding) + ) { + BaseText( + text = text, + style = style, + color = color, + spacing = spacing + ) { + Icon( + painter = icon, + tint = tint, + contentDescription = null, + modifier = Modifier.size(iconSize), + ) + } + } +} + +@Composable +fun SelectableImageText( + image: Painter, + text: String, + style: TextStyle, + onClick: () -> Unit, + modifier: Modifier = Modifier, + color: Color = LocalContentColor.current, + spacing: Dp = MaterialTheme.spacing.veryShortSpacing, + padding: Dp = MaterialTheme.spacing.shortSpacing, + imageSize: Dp = style.toDp() +) { + Box( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(padding) + ) { + BaseText( + text = text, + style = style, + color = color, + spacing = spacing + ) { + Image( + painter = image, + contentDescription = null, + modifier = Modifier.size(imageSize), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/LoadingTextButton.kt b/app/src/main/java/com/readrops/app/util/components/LoadingTextButton.kt new file mode 100644 index 00000000..ea3b255f --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/LoadingTextButton.kt @@ -0,0 +1,29 @@ +package com.readrops.app.util.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun LoadingTextButton( + text: String, + isLoading: Boolean, + onClick: () -> Unit +) { + TextButton( + onClick = onClick + ) { + if (isLoading) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp) + ) + } else { + Text(text = text) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/Placeholder.kt b/app/src/main/java/com/readrops/app/util/components/Placeholder.kt new file mode 100644 index 00000000..9e1f12e1 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/Placeholder.kt @@ -0,0 +1,61 @@ +package com.readrops.app.util.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.readrops.app.util.theme.ShortSpacer + + +@Composable +fun CenteredColumn( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.fillMaxSize() + ) { + content() + } +} + +@Composable +fun Placeholder( + text: String, + painter: Painter, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleLarge, + iconSize: Dp = 48.dp, + tint: Color = MaterialTheme.colorScheme.primary +) { + CenteredColumn( + modifier = modifier + ) { + Icon( + painter = painter, + tint = tint, + contentDescription = null, + modifier = Modifier.size(iconSize) + ) + + ShortSpacer() + + Text( + text = text, + style = textStyle + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/RefreshScreen.kt b/app/src/main/java/com/readrops/app/util/components/RefreshScreen.kt new file mode 100644 index 00000000..95a0b78e --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/RefreshScreen.kt @@ -0,0 +1,52 @@ +package com.readrops.app.util.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import com.readrops.app.util.theme.VeryShortSpacer +import com.readrops.app.util.theme.spacing + +@Composable +fun RefreshScreen( + currentFeed: String, + feedCount: Int, + feedMax: Int +) { + CenteredColumn { + RefreshIndicator( + currentFeed = currentFeed, + feedCount = feedCount, + feedMax = feedMax + ) + } +} + +@Composable +fun RefreshIndicator( + currentFeed: String, + feedCount: Int, + feedMax: Int +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing) + ) { + LinearProgressIndicator( + progress = { feedCount.toFloat() / feedMax.toFloat() } + ) + + VeryShortSpacer() + + Text( + text = "$currentFeed ($feedCount/$feedMax)", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/TextFieldUtils.kt b/app/src/main/java/com/readrops/app/util/components/TextFieldUtils.kt new file mode 100644 index 00000000..92c6534f --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/TextFieldUtils.kt @@ -0,0 +1,25 @@ +package com.readrops.app.util.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.readrops.app.R + + +sealed class TextFieldError { + data object EmptyField : TextFieldError() + data object BadUrl : TextFieldError() + data object UnreachableUrl : TextFieldError() + data object NoRSSFeed : TextFieldError() + data object NoRSSUrl : TextFieldError() + + @Composable + fun errorText(): String = + when (this) { + BadUrl -> stringResource(R.string.not_valid_url) + EmptyField -> stringResource(R.string.field_cant_be_empty) + NoRSSFeed -> stringResource(R.string.no_rss_feed_found) + NoRSSUrl -> stringResource(R.string.not_valid_rss_feed) + UnreachableUrl -> stringResource(R.string.unreachable_url) + } +} + diff --git a/app/src/main/java/com/readrops/app/util/components/ThreeDotsMenu.kt b/app/src/main/java/com/readrops/app/util/components/ThreeDotsMenu.kt new file mode 100644 index 00000000..3cc7563f --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/ThreeDotsMenu.kt @@ -0,0 +1,57 @@ +package com.readrops.app.util.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier + +@Composable +fun ThreeDotsMenu( + items: Map, + onItemClick: (Int) -> Unit, + modifier: Modifier = Modifier +) { + var isExpanded by remember { mutableStateOf(false) } + + Box( + modifier = modifier + ) { + IconButton( + onClick = { isExpanded = !isExpanded } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null, + ) + } + + DropdownMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = !isExpanded }, + ) { + for ((index, value) in items) { + DropdownMenuItem( + text = { + Text( + text = value + ) + }, + onClick = { + isExpanded = false + onItemClick(index) + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/dialog/BaseDialog.kt b/app/src/main/java/com/readrops/app/util/components/dialog/BaseDialog.kt new file mode 100644 index 00000000..69ced4cd --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/dialog/BaseDialog.kt @@ -0,0 +1,64 @@ +package com.readrops.app.util.components.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.spacing + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseDialog( + title: String, + icon: Painter, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + BasicAlertDialog( + onDismissRequest = onDismiss + ) { + Surface( + tonalElevation = AlertDialogDefaults.TonalElevation, + shape = AlertDialogDefaults.shape, + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .padding(MaterialTheme.spacing.largeSpacing) + ) { + Icon( + painter = icon, + tint = AlertDialogDefaults.iconContentColor, + contentDescription = null, + modifier = Modifier.size(MaterialTheme.spacing.largeSpacing) + ) + + MediumSpacer() + + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = AlertDialogDefaults.titleContentColor + ) + + MediumSpacer() + + content() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/dialog/ErrorDialog.kt b/app/src/main/java/com/readrops/app/util/components/dialog/ErrorDialog.kt new file mode 100644 index 00000000..84216758 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/dialog/ErrorDialog.kt @@ -0,0 +1,28 @@ +package com.readrops.app.util.components.dialog + +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.readrops.app.R +import com.readrops.app.util.ErrorMessage + +@Composable +fun ErrorDialog( + exception: Exception, + onDismiss: () -> Unit +) { + BaseDialog( + title = stringResource(id = R.string.error_occured), + icon = painterResource(id = R.drawable.ic_error), + onDismiss = onDismiss + ) { + Text( + text = ErrorMessage.get(exception, LocalContext.current), + color = AlertDialogDefaults.textContentColor + ) + } +} + diff --git a/app/src/main/java/com/readrops/app/util/components/dialog/TextFieldDialog.kt b/app/src/main/java/com/readrops/app/util/components/dialog/TextFieldDialog.kt new file mode 100644 index 00000000..c79af6de --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/dialog/TextFieldDialog.kt @@ -0,0 +1,84 @@ +package com.readrops.app.util.components.dialog + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.readrops.app.R +import com.readrops.app.util.ErrorMessage +import com.readrops.app.util.components.LoadingTextButton +import com.readrops.app.util.components.TextFieldError +import com.readrops.app.util.theme.LargeSpacer + +data class TextFieldDialogState( + val value: String = "", + val textFieldError: TextFieldError? = null, + val exception: Exception? = null, + val isLoading: Boolean = false +) { + val isTextFieldError + get() = textFieldError != null +} + +@Composable +fun TextFieldDialog( + title: String, + icon: Painter, + label: String, + state: TextFieldDialogState, + onValueChange: (String) -> Unit, + onValidate: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + BaseDialog( + title = title, + icon = icon, + onDismiss = { if (!state.isLoading) onDismiss() }, + modifier = modifier + ) { + OutlinedTextField( + value = state.value, + label = { Text(text = label) }, + onValueChange = onValueChange, + singleLine = true, + trailingIcon = { + if (state.value.isNotEmpty()) { + IconButton( + onClick = { onValueChange("") } + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = null + ) + } + } + }, + isError = state.isTextFieldError, + supportingText = { Text(text = state.textFieldError?.errorText().orEmpty()) } + ) + + if (state.exception != null) { + Text( + text = ErrorMessage.get(state.exception, LocalContext.current), + color = MaterialTheme.colorScheme.error + ) + } + + LargeSpacer() + + LoadingTextButton( + text = stringResource(R.string.validate), + isLoading = state.isLoading, + onClick = { onValidate() }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/dialog/TwoChoicesDialog.kt b/app/src/main/java/com/readrops/app/util/components/dialog/TwoChoicesDialog.kt new file mode 100644 index 00000000..a1260fc1 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/dialog/TwoChoicesDialog.kt @@ -0,0 +1,41 @@ +package com.readrops.app.util.components.dialog + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter + +@Composable +fun TwoChoicesDialog( + title: String, + text: String, + icon: Painter, + confirmText: String, + dismissText: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + painter = icon, + contentDescription = null, + ) + }, + title = { Text(text = title) }, + text = { Text(text = text) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = confirmText) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = dismissText) + } + }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/theme/Color.kt b/app/src/main/java/com/readrops/app/util/theme/Color.kt new file mode 100644 index 00000000..286ad61d --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/theme/Color.kt @@ -0,0 +1,68 @@ +package com.readrops.app.util.theme + +import androidx.compose.ui.graphics.Color + +val md_theme_light_primary = Color(0xFF0062A2) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFFD1E4FF) +val md_theme_light_onPrimaryContainer = Color(0xFF001D35) +val md_theme_light_secondary = Color(0xFFA43D00) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFFFDBCD) +val md_theme_light_onSecondaryContainer = Color(0xFF360F00) +val md_theme_light_tertiary = Color(0xFF006D3D) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFF97F7B7) +val md_theme_light_onTertiaryContainer = Color(0xFF00210F) +val md_theme_light_error = Color(0xFFBA1A1A) +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_onErrorContainer = Color(0xFF410002) +val md_theme_light_background = Color(0xFFF8FDFF) +val md_theme_light_onBackground = Color(0xFF001F25) +val md_theme_light_surface = Color(0xFFF8FDFF) +val md_theme_light_onSurface = Color(0xFF001F25) +val md_theme_light_surfaceVariant = Color(0xFFDFE2EB) +val md_theme_light_onSurfaceVariant = Color(0xFF42474E) +val md_theme_light_outline = Color(0xFF73777F) +val md_theme_light_inverseOnSurface = Color(0xFFD6F6FF) +val md_theme_light_inverseSurface = Color(0xFF00363F) +val md_theme_light_inversePrimary = Color(0xFF9DCAFF) +val md_theme_light_shadow = Color(0xFF000000) +val md_theme_light_surfaceTint = Color(0xFF0062A2) +val md_theme_light_outlineVariant = Color(0xFFC3C7CF) +val md_theme_light_scrim = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFF9DCAFF) +val md_theme_dark_onPrimary = Color(0xFF003257) +val md_theme_dark_primaryContainer = Color(0xFF00497C) +val md_theme_dark_onPrimaryContainer = Color(0xFFD1E4FF) +val md_theme_dark_secondary = Color(0xFFFFB597) +val md_theme_dark_onSecondary = Color(0xFF581D00) +val md_theme_dark_secondaryContainer = Color(0xFF7D2D00) +val md_theme_dark_onSecondaryContainer = Color(0xFFFFDBCD) +val md_theme_dark_tertiary = Color(0xFF7BDA9C) +val md_theme_dark_onTertiary = Color(0xFF00391D) +val md_theme_dark_tertiaryContainer = Color(0xFF00522C) +val md_theme_dark_onTertiaryContainer = Color(0xFF97F7B7) +val md_theme_dark_error = Color(0xFFFFB4AB) +val md_theme_dark_errorContainer = Color(0xFF93000A) +val md_theme_dark_onError = Color(0xFF690005) +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val md_theme_dark_background = Color(0xFF001F25) +val md_theme_dark_onBackground = Color(0xFFA6EEFF) +val md_theme_dark_surface = Color(0xFF001F25) +val md_theme_dark_onSurface = Color(0xFFA6EEFF) +val md_theme_dark_surfaceVariant = Color(0xFF42474E) +val md_theme_dark_onSurfaceVariant = Color(0xFFC3C7CF) +val md_theme_dark_outline = Color(0xFF8D9199) +val md_theme_dark_inverseOnSurface = Color(0xFF001F25) +val md_theme_dark_inverseSurface = Color(0xFFA6EEFF) +val md_theme_dark_inversePrimary = Color(0xFF0062A2) +val md_theme_dark_shadow = Color(0xFF000000) +val md_theme_dark_surfaceTint = Color(0xFF9DCAFF) +val md_theme_dark_outlineVariant = Color(0xFF42474E) +val md_theme_dark_scrim = Color(0xFF000000) + + +val seed = Color(0xFF0072BC) diff --git a/app/src/main/java/com/readrops/app/util/theme/Spacing.kt b/app/src/main/java/com/readrops/app/util/theme/Spacing.kt new file mode 100644 index 00000000..3c4d4cc1 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/theme/Spacing.kt @@ -0,0 +1,45 @@ +package com.readrops.app.util.theme + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +data class Spacing( + val veryShortSpacing: Dp = 4.dp, + val shortSpacing: Dp = 8.dp, + val mediumSpacing: Dp = 16.dp, + val largeSpacing: Dp = 24.dp, + val veryLargeSpacing: Dp = 48.dp, + val drawerSpacing: Dp = 12.dp +) + +val LocalSpacing = compositionLocalOf { Spacing() } + +val MaterialTheme.spacing + @Composable + @ReadOnlyComposable + get() = LocalSpacing.current + +@Composable +fun VeryShortSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.veryShortSpacing)) + +@Composable +fun ShortSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.shortSpacing)) + +@Composable +fun MediumSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.mediumSpacing)) + +@Composable +fun LargeSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.largeSpacing)) + +@Composable +fun VeryLargeSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.veryLargeSpacing)) + +@Composable +fun DrawerSpacing() = Spacer(Modifier.size(MaterialTheme.spacing.drawerSpacing)) \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/theme/Theme.kt b/app/src/main/java/com/readrops/app/util/theme/Theme.kt new file mode 100644 index 00000000..908a92a4 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/theme/Theme.kt @@ -0,0 +1,90 @@ +package com.readrops.app.util.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + + +private val LightColors = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, +) + + +private val DarkColors = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, +) + +@Composable +fun ReadropsTheme( + useDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (!useDarkTheme) { + LightColors + } else { + DarkColors + } + + MaterialTheme( + colorScheme = colors, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/utils/FileUtils.kt b/app/src/main/java/com/readrops/app/utils/FileUtils.kt deleted file mode 100644 index e0aba2ff..00000000 --- a/app/src/main/java/com/readrops/app/utils/FileUtils.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.readrops.app.utils - -import android.content.ContentValues -import android.content.Context -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import androidx.annotation.RequiresApi -import java.io.File -import java.io.FileOutputStream -import java.io.OutputStream - -object FileUtils { - - @JvmStatic - fun writeDownloadFile(context: Context, fileName: String, mimeType: String, listener: (OutputStream) -> Unit): String { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) - writeFileApi29(context, fileName, mimeType, listener) - else - writeFileApi28(fileName, listener) - } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun writeFileApi29(context: Context, fileName: String, mimeType: String, listener: (OutputStream) -> Unit): String { - val resolver = context.contentResolver - val downloadsUri = MediaStore.Downloads - .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - - val fileDetails = ContentValues().apply { - put(MediaStore.Downloads.DISPLAY_NAME, fileName) - put(MediaStore.Downloads.IS_PENDING, 1) - put(MediaStore.Downloads.MIME_TYPE, mimeType) - } - - val contentUri = resolver.insert(downloadsUri, fileDetails) - - resolver.openOutputStream(contentUri!!)!!.use { stream -> - try { - listener(stream) - } catch (e: Exception) { - throw e - } finally { - stream.flush() - stream.close() - } - - fileDetails.put(MediaStore.Downloads.IS_PENDING, 0) - resolver.update(contentUri, fileDetails, null, null) - } - - fileDetails.put(MediaStore.Downloads.IS_PENDING, 0) - resolver.update(contentUri, fileDetails, null, null) - - return contentUri.path!! - } - - private fun writeFileApi28(fileName: String, listener: (OutputStream) -> Unit): String { - val filePath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath - val file = File(filePath, fileName) - - val outputStream = FileOutputStream(file) - listener(outputStream) - - outputStream.flush() - outputStream.close() - - return file.absolutePath - } -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/utils/HtmlParser.java b/app/src/main/java/com/readrops/app/utils/HtmlParser.java deleted file mode 100644 index b1e328eb..00000000 --- a/app/src/main/java/com/readrops/app/utils/HtmlParser.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.readrops.app.utils; - -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.readrops.api.localfeed.LocalRSSHelper; -import com.readrops.api.utils.ApiUtils; -import com.readrops.api.utils.AuthInterceptor; -import com.readrops.app.addfeed.ParsingResult; - -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.nodes.Element; -import org.jsoup.select.Elements; -import org.koin.java.KoinJavaComponent; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; - -public final class HtmlParser { - - private static final String TAG = HtmlParser.class.getSimpleName(); - - /** - * Parse the html page to get all rss urls - * - * @param url url to request - * @return a list of rss urls with their title - */ - public static List getFeedLink(String url) { - List results = new ArrayList<>(); - - String head = getHTMLHeadFromUrl(url); - if (head != null) { - Document document = Jsoup.parse(head, url); - - Elements elements = document.select("link"); - - for (Element element : elements) { - String type = element.attributes().get("type"); - - if (LocalRSSHelper.isRSSType(type)) { - String feedUrl = element.absUrl("href"); - String label = element.attributes().get("title"); - - results.add(new ParsingResult(feedUrl, label)); - } - } - - return results; - } else { - return Collections.emptyList(); - } - } - - @Nullable - public static String getFaviconLink(@NonNull String url) { - String favUrl = null; - - String head = getHTMLHeadFromUrl(url); - if (head == null) - return null; - - Document document = Jsoup.parse(head, url); - Elements elements = document.select("link"); - - for (Element element : elements) { - if (element.attributes().get("rel").toLowerCase().contains("icon")) { - favUrl = element.absUrl("href"); - break; - } - } - - return favUrl; - } - - @Nullable - private static String getHTMLHeadFromUrl(@NonNull String url) { - long start = System.currentTimeMillis(); - - try { - Response response = KoinJavaComponent.get(OkHttpClient.class) - .newCall(new Request.Builder().url(url).build()).execute(); - KoinJavaComponent.get(AuthInterceptor.class).setCredentials(null); - - if (response.header("Content-Type").contains(ApiUtils.HTML_CONTENT_TYPE)) { - String body = response.body().string(); - String head = body.substring(body.indexOf("")); - - long end = System.currentTimeMillis(); - Log.d(TAG, "parsing time : " + (end - start)); - - return head; - } else { - return null; - } - } catch (Exception e) { - Log.d(TAG, e.getMessage()); - return null; - } - - } -} diff --git a/app/src/main/java/com/readrops/app/utils/OPMLHelper.kt b/app/src/main/java/com/readrops/app/utils/OPMLHelper.kt deleted file mode 100644 index 8bef58b9..00000000 --- a/app/src/main/java/com/readrops/app/utils/OPMLHelper.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.readrops.app.utils - -import android.app.Activity -import android.content.Intent -import androidx.fragment.app.Fragment - -object OPMLHelper { - - const val OPEN_OPML_FILE_REQUEST = 1 - - @JvmStatic - fun openFileIntent(activity: Activity) = - activity.startActivityForResult(createIntent(), OPEN_OPML_FILE_REQUEST) - - @JvmStatic - fun openFileIntent(fragment: Fragment) = - fragment.startActivityForResult(createIntent(), OPEN_OPML_FILE_REQUEST) - - - private fun createIntent(): Intent { - return Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/*", "text/*")) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/utils/PermissionManager.kt b/app/src/main/java/com/readrops/app/utils/PermissionManager.kt deleted file mode 100644 index 6d72c145..00000000 --- a/app/src/main/java/com/readrops/app/utils/PermissionManager.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.readrops.app.utils - -import android.app.Activity -import android.content.Context -import android.content.pm.PackageManager -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment - -object PermissionManager { - - @JvmStatic - fun isPermissionGranted(context: Context, permission: String): Boolean = - ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED - - @JvmStatic - fun requestPermissions(activity: Activity, requestCode: Int, vararg permissions: String) = - ActivityCompat.requestPermissions(activity, permissions, requestCode) - - @JvmStatic - fun requestPermissions(fragment: Fragment, requestCode: Int, vararg permissions: String) = - fragment.requestPermissions(permissions, requestCode) - -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/utils/ReadropsGlideModule.kt b/app/src/main/java/com/readrops/app/utils/ReadropsGlideModule.kt deleted file mode 100644 index aebc4491..00000000 --- a/app/src/main/java/com/readrops/app/utils/ReadropsGlideModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.readrops.app.utils - -import android.content.Context -import com.bumptech.glide.Glide -import com.bumptech.glide.Registry -import com.bumptech.glide.annotation.GlideModule -import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader -import com.bumptech.glide.load.model.GlideUrl -import com.bumptech.glide.module.AppGlideModule -import okhttp3.OkHttpClient -import org.koin.core.component.KoinComponent -import org.koin.core.component.get -import java.io.InputStream - -@GlideModule -class ReadropsGlideModule : AppGlideModule(), KoinComponent { - - override fun registerComponents(context: Context, glide: Glide, registry: Registry) { - val factory = OkHttpUrlLoader.Factory(get()) - - glide.registry.replace(GlideUrl::class.java, InputStream::class.java, factory) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/utils/ReadropsKeys.kt b/app/src/main/java/com/readrops/app/utils/ReadropsKeys.kt deleted file mode 100644 index 01833caf..00000000 --- a/app/src/main/java/com/readrops/app/utils/ReadropsKeys.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.readrops.app.utils - -object ReadropsKeys { - - const val ACCOUNT = "ACCOUNT_KEY" - const val ACCOUNT_ID = "ACCOUNT_ID" - const val ACCOUNT_TYPE = "ACCOUNT_TYPE_KEY" - const val EDIT_ACCOUNT = "EDIT_ACCOUNT" - - const val FROM_MAIN_ACTIVITY = "FROM_MAIN_ACTIVITY_KEY" - - const val ITEM_ID = "ITEM_ID_KEY" - const val IMAGE_URL = "IMAGE_URL_KEY" - - const val SYNCING = "SYNCING_KEY" - - const val SETTINGS = "SETTINGS_KEY" - - const val WEB_URL = "WEB_URL_KEY" - const val ACTION_BAR_COLOR = "ACTION_BAR_COLOR_KEY" - - const val FEEDS = "FEEDS" - - const val STARRED_ITEM = "STARRED_ITEM" -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/utils/SharedPreferencesManager.java b/app/src/main/java/com/readrops/app/utils/SharedPreferencesManager.java deleted file mode 100644 index 6a4cd5c4..00000000 --- a/app/src/main/java/com/readrops/app/utils/SharedPreferencesManager.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.readrops.app.utils; - -import android.content.SharedPreferences; - -import androidx.annotation.NonNull; - -import org.koin.java.KoinJavaComponent; - -public final class SharedPreferencesManager { - - public static void writeValue(String key, Object value) { - SharedPreferences sharedPref = KoinJavaComponent.get(SharedPreferences.class); - SharedPreferences.Editor editor = sharedPref.edit(); - - if (value instanceof Boolean) - editor.putBoolean(key, (Boolean) value); - else if (value instanceof String) - editor.putString(key, (String) value); - - editor.apply(); - } - - public static void writeValue(SharedPrefKey sharedPrefKey, Object value) { - writeValue(sharedPrefKey.key, value); - } - - public static int readInt(SharedPrefKey sharedPrefKey) { - SharedPreferences sharedPreferences = KoinJavaComponent.get(SharedPreferences.class); - return sharedPreferences.getInt(sharedPrefKey.key, sharedPrefKey.getIntDefaultValue()); - } - - public static boolean readBoolean(SharedPrefKey sharedPrefKey) { - SharedPreferences sharedPreferences = KoinJavaComponent.get(SharedPreferences.class); - return sharedPreferences.getBoolean(sharedPrefKey.key, sharedPrefKey.getBooleanDefaultValue()); - } - - public static String readString(String key) { - SharedPreferences sharedPreferences = KoinJavaComponent.get(SharedPreferences.class); - return sharedPreferences.getString(key, null); - } - - public static String readString(SharedPrefKey sharedPrefKey) { - SharedPreferences sharedPreferences = KoinJavaComponent.get(SharedPreferences.class); - return sharedPreferences.getString(sharedPrefKey.key, sharedPrefKey.getStringDefaultValue()); - } - - public static void remove(String key) { - SharedPreferences sharedPreferences = KoinJavaComponent.get(SharedPreferences.class); - SharedPreferences.Editor editor = sharedPreferences.edit(); - - editor.remove(key); - editor.apply(); - } - - public enum SharedPrefKey { - SHOW_READ_ARTICLES("show_read_articles", false), - ITEMS_TO_PARSE_MAX_NB("items_to_parse_max_nb", "20"), - OPEN_ITEMS_IN("open_items_in", "0"), - DARK_THEME("dark_theme", "false"), - AUTO_SYNCHRO("auto_synchro", "0"), - HIDE_SHOW_FEEDS("show_hide_feeds", "true"), - MARK_ITEMS_READ_ON_SCROLL("mark_items_read", false); - - @NonNull - private String key; - @NonNull - private Object defaultValue; - - public boolean getBooleanDefaultValue() { - return Boolean.valueOf(defaultValue.toString()); - } - - public String getStringDefaultValue() { - return (String) defaultValue; - } - - public int getIntDefaultValue() { - return Integer.parseInt(defaultValue.toString()); - } - - SharedPrefKey(@NonNull String key, @NonNull Object defaultValue) { - this.key = key; - this.defaultValue = defaultValue; - } - } -} diff --git a/app/src/main/java/com/readrops/app/utils/Utils.java b/app/src/main/java/com/readrops/app/utils/Utils.java deleted file mode 100644 index 1e3047ac..00000000 --- a/app/src/main/java/com/readrops/app/utils/Utils.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.readrops.app.utils; - -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffColorFilter; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.ShapeDrawable; -import android.graphics.drawable.shapes.OvalShape; -import android.view.View; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; - -import com.google.android.material.snackbar.Snackbar; - -import org.koin.java.KoinJavaComponent; - -import java.io.InputStream; -import java.util.Locale; - -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; - -public final class Utils { - - public static final String HTTP_PREFIX = "http://"; - - public static final String HTTPS_PREFIX = "https://"; - - private static final int AVERAGE_WORDS_PER_MINUTE = 250; - - public static Bitmap getImageFromUrl(String url) { - try { - Request request = new Request.Builder().url(url).build(); - - Response response = KoinJavaComponent.get(OkHttpClient.class).newCall(request).execute(); - - if (response.isSuccessful()) { - InputStream inputStream = response.body().byteStream(); - return BitmapFactory.decodeStream(inputStream); - } else - return null; - } catch (Exception e) { - return null; // no way to get the favicon - } - } - - public static double readTimeFromString(String value) { - int nbWords = value.split("\\s+").length; - - return (double) nbWords / AVERAGE_WORDS_PER_MINUTE; - } - - public static String getCssColor(@ColorInt int color) { - return String.format(Locale.US, "rgba(%d,%d,%d,%.2f)", - Color.red(color), - Color.green(color), - Color.blue(color), - Color.alpha(color) / 255.0); - } - - public static boolean isTypeImage(@NonNull String type) { - return type.equals("image") || type.equals("image/jpeg") || type.equals("image/jpg") - || type.equals("image/png"); - } - - public static void setDrawableColor(Drawable drawable, @ColorInt int color) { - drawable.mutate().setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)); - } - - public static Drawable drawableWithColor(@ColorInt int color) { - ShapeDrawable drawable = new ShapeDrawable(new OvalShape()); - drawable.setIntrinsicWidth(50); - drawable.setIntrinsicHeight(50); - - drawable.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)); - - return drawable; - } - - - public static void showSnackBarWithAction(View root, String message, String action, View.OnClickListener listener) { - Snackbar snackbar = Snackbar.make(root, message, Snackbar.LENGTH_LONG); - snackbar.setAction(action, listener); - - snackbar.show(); - } - - public static void showSnackbar(View root, String message) { - Snackbar snackbar = Snackbar.make(root, message, Snackbar.LENGTH_LONG); - snackbar.show(); - } - - public static Bitmap getBitmapFromDrawable(Drawable drawable) { - Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), - drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - drawable.draw(canvas); - - return bitmap; - - } - - public static boolean isColorTooBright(@ColorInt int color) { - return getColorLuma(color) > 210; - } - - public static boolean isColorTooDark(@ColorInt int color) { - return getColorLuma(color) < 40; - } - - private static double getColorLuma(@ColorInt int color) { - int r = (color >> 16) & 0xff; - int g = (color >> 8) & 0xff; - int b = (color >> 0) & 0xff; - - return 0.2126 * r + 0.7152 * g + 0.0722 * b; - } -} diff --git a/app/src/main/java/com/readrops/app/utils/customviews/CustomExpandableBadgeDrawerItem.java b/app/src/main/java/com/readrops/app/utils/customviews/CustomExpandableBadgeDrawerItem.java deleted file mode 100644 index 31277365..00000000 --- a/app/src/main/java/com/readrops/app/utils/customviews/CustomExpandableBadgeDrawerItem.java +++ /dev/null @@ -1,174 +0,0 @@ -package com.readrops.app.utils.customviews; - -import android.content.Context; -import android.graphics.Color; -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.LayoutRes; -import androidx.annotation.StringRes; - -import com.mikepenz.fastadapter.IClickable; -import com.mikepenz.fastadapter.IItem; -import com.mikepenz.fastadapter.listeners.OnClickListener; -import com.mikepenz.iconics.IconicsDrawable; -import com.mikepenz.materialdrawer.Drawer; -import com.mikepenz.materialdrawer.holder.BadgeStyle; -import com.mikepenz.materialdrawer.holder.ColorHolder; -import com.mikepenz.materialdrawer.holder.StringHolder; -import com.mikepenz.materialdrawer.icons.MaterialDrawerFont; -import com.mikepenz.materialdrawer.model.BaseDescribeableDrawerItem; -import com.mikepenz.materialdrawer.model.BaseViewHolder; -import com.mikepenz.materialdrawer.model.interfaces.ColorfulBadgeable; -import com.readrops.app.R; - -import java.util.List; - -/** - * This a simple modification of original ExpandableBadgeDrawerItem from MaterialDrawer lib to get two click events from an expandable drawer item - */ -public class CustomExpandableBadgeDrawerItem extends BaseDescribeableDrawerItem - implements ColorfulBadgeable, IClickable { - - protected ColorHolder arrowColor; - - protected int arrowRotationAngleStart = 0; - - protected int arrowRotationAngleEnd = 180; - - protected StringHolder mBadge; - protected BadgeStyle mBadgeStyle = new BadgeStyle(); - - @Override - public int getType() { - return R.id.material_drawer_item_expandable_badge; - } - - @Override - @LayoutRes - public int getLayoutRes() { - return R.layout.custom_expandable_drawer_item; - } - - @Override - public void bindView(CustomExpandableBadgeDrawerItem.ViewHolder viewHolder, List payloads) { - super.bindView(viewHolder, payloads); - - Context ctx = viewHolder.itemView.getContext(); - //bind the basic view parts - bindViewHelper(viewHolder); - - //set the text for the badge or hide - boolean badgeVisible = StringHolder.applyToOrHide(mBadge, viewHolder.badge); - //style the badge if it is visible - if (true) { - mBadgeStyle.style(viewHolder.badge, getTextColorStateList(getColor(ctx), getSelectedTextColor(ctx))); - viewHolder.badgeContainer.setVisibility(View.VISIBLE); - } else { - viewHolder.badgeContainer.setVisibility(View.GONE); - } - - //define the typeface for our textViews - if (getTypeface() != null) { - viewHolder.badge.setTypeface(getTypeface()); - } - - //make sure all animations are stopped - if (viewHolder.arrow.getDrawable() instanceof IconicsDrawable) { - ((IconicsDrawable) viewHolder.arrow.getDrawable()).color(this.arrowColor != null ? this.arrowColor.color(ctx) : getIconColor(ctx)); - } - viewHolder.arrow.clearAnimation(); - if (!isExpanded()) { - viewHolder.arrow.setRotation(this.arrowRotationAngleStart); - } else { - viewHolder.arrow.setRotation(this.arrowRotationAngleEnd); - } - - //call the onPostBindView method to trigger post bind view actions (like the listener to modify the item if required) - onPostBindView(this, viewHolder.itemView); - } - - @Override - public CustomExpandableBadgeDrawerItem withOnDrawerItemClickListener(Drawer.OnDrawerItemClickListener onDrawerItemClickListener) { - mOnDrawerItemClickListener = null; - return this; - } - - @Override - public Drawer.OnDrawerItemClickListener getOnDrawerItemClickListener() { - return null; - } - - @Override - public CustomExpandableBadgeDrawerItem withBadge(StringHolder badge) { - this.mBadge = badge; - return this; - } - - @Override - public CustomExpandableBadgeDrawerItem withBadge(String badge) { - this.mBadge = new StringHolder(badge); - return this; - } - - @Override - public CustomExpandableBadgeDrawerItem withBadge(@StringRes int badgeRes) { - this.mBadge = new StringHolder(badgeRes); - return this; - } - - @Override - public CustomExpandableBadgeDrawerItem withBadgeStyle(BadgeStyle badgeStyle) { - this.mBadgeStyle = badgeStyle; - return this; - } - - public StringHolder getBadge() { - return mBadge; - } - - public BadgeStyle getBadgeStyle() { - return mBadgeStyle; - } - - @Override - public ViewHolder getViewHolder(View v) { - return new ViewHolder(v); - } - - @Override - public IItem withOnItemPreClickListener(OnClickListener onItemPreClickListener) { - return null; - } - - @Override - public OnClickListener getOnPreItemClickListener() { - return null; - } - - @Override - public IItem withOnItemClickListener(OnClickListener onItemClickListener) { - return null; - } - - @Override - public OnClickListener getOnItemClickListener() { - return null; - } - - public static class ViewHolder extends BaseViewHolder { - public ImageView arrow; - public View badgeContainer; - public TextView badge; - - public ViewHolder(View view) { - super(view); - badgeContainer = view.findViewById(R.id.material_drawer_badge_container); - badge = view.findViewById(R.id.material_drawer_badge); - arrow = view.findViewById(R.id.material_drawer_arrow); - arrow.setImageDrawable(new IconicsDrawable(view.getContext(), MaterialDrawerFont.Icon.mdf_expand_more).sizeDp(16).paddingDp(2).color(Color.BLACK)); - } - } -} diff --git a/app/src/main/java/com/readrops/app/utils/customviews/EmptyListView.kt b/app/src/main/java/com/readrops/app/utils/customviews/EmptyListView.kt deleted file mode 100644 index c353c825..00000000 --- a/app/src/main/java/com/readrops/app/utils/customviews/EmptyListView.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.readrops.app.utils.customviews - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.LinearLayout -import com.readrops.app.R -import com.readrops.app.databinding.EmptyListViewBinding - -/** - * A simple custom view to display a empty list message - */ -class EmptyListView(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) { - - val binding: EmptyListViewBinding = EmptyListViewBinding.inflate(LayoutInflater.from(context), this, true) - - init { - val attributes = context.obtainStyledAttributes(attrs, R.styleable.EmptyListView) - binding.emptyListImage.setImageDrawable(attributes.getDrawable(R.styleable.EmptyListView_image)) - binding.emptyListText.text = attributes.getString(R.styleable.EmptyListView_text) - - attributes.recycle() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/utils/customviews/ReadropsItemTouchCallback.kt b/app/src/main/java/com/readrops/app/utils/customviews/ReadropsItemTouchCallback.kt deleted file mode 100644 index 8c61fb14..00000000 --- a/app/src/main/java/com/readrops/app/utils/customviews/ReadropsItemTouchCallback.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.readrops.app.utils.customviews - -import android.content.Context -import android.graphics.Canvas -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.view.View -import androidx.annotation.ColorInt -import androidx.annotation.DrawableRes -import androidx.annotation.Nullable -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import kotlin.math.abs - -class ReadropsItemTouchCallback(private val context: Context, private val config: Config) : - ItemTouchHelper.SimpleCallback(config.dragDirs, config.swipeDirs) { - - private val iconHorizontalMargin = 40 - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - config.moveCallback?.onMove() - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - config.swipeCallback?.onSwipe(viewHolder, direction) - } - - override fun onChildDraw(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) { - super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) - - val background: ColorDrawable - var icon: Drawable? = null - val itemView: View = viewHolder.itemView - var draw = true // variable used to draw under some conditions - - // do not draw anymore if the view has reached the screen's left/right side - if (abs(dX).toInt() == itemView.right) { - draw = false - } else if (abs(dX).toInt() == 0) { - draw = true - } - - // left swipe - if (dX > 0 && config.leftDraw != null && draw) { - background = ColorDrawable(config.leftDraw.bgColor) - background.setBounds(itemView.left, itemView.top, dX.toInt(), itemView.bottom) - - icon = config.leftDraw.drawable - ?: ContextCompat.getDrawable(context, config.leftDraw.iconRes)!! - val iconMargin = (itemView.height - icon.intrinsicHeight) / 2 - icon.setBounds(itemView.left + iconHorizontalMargin, itemView.top + iconMargin, - itemView.left + iconHorizontalMargin + icon.intrinsicWidth, itemView.bottom - iconMargin) - // right swipe - } else if (dX < 0 && config.rightDraw != null && draw) { - background = ColorDrawable(config.rightDraw.bgColor) - background.setBounds(itemView.right + dX.toInt(), itemView.top, itemView.right, itemView.bottom) - - icon = config.rightDraw.drawable - ?: ContextCompat.getDrawable(context, config.rightDraw.iconRes)!! - val iconMargin = (itemView.height - icon.intrinsicHeight) / 2 - icon.setBounds(itemView.right - iconHorizontalMargin - icon.intrinsicWidth, itemView.top + iconMargin, - itemView.right - iconHorizontalMargin, itemView.bottom - iconMargin) - } else { - background = ColorDrawable() - } - - background.draw(c) - - if (dX > 0) - c.clipRect(itemView.left, itemView.top, dX.toInt(), itemView.bottom) - else if (dX < 0) - c.clipRect(itemView.right + dX.toInt(), itemView.top, itemView.right, itemView.bottom) - - icon?.draw(c) - } - - override fun isItemViewSwipeEnabled(): Boolean { - return config.swipeCallback != null - } - - override fun isLongPressDragEnabled(): Boolean { - return config.moveCallback != null - } - - interface MoveCallback { - fun onMove() - } - - interface SwipeCallback { - fun onSwipe(viewHolder: RecyclerView.ViewHolder, direction: Int) - } - - class SwipeDraw(@ColorInt val bgColor: Int, @DrawableRes val iconRes: Int = 0, val drawable: Drawable?) - - class Config(val dragDirs: Int = 0, val swipeDirs: Int = 0, val moveCallback: MoveCallback? = null, - val swipeCallback: SwipeCallback? = null, val leftDraw: SwipeDraw? = null, val rightDraw: SwipeDraw? = null) { - - private constructor(builder: Builder) : this(builder.dragDirs, builder.swipeDirs, - builder.moveCallback, builder.swipeCallback, builder.leftDraw, builder.rightDraw) - - class Builder { - var dragDirs: Int = 0 - private set - - var swipeDirs: Int = 0 - private set - - var moveCallback: MoveCallback? = null - private set - - var swipeCallback: SwipeCallback? = null - private set - - var leftDraw: SwipeDraw? = null - private set - - var rightDraw: SwipeDraw? = null - private set - - fun dragDirs(dragDirs: Int) = apply { this.dragDirs = dragDirs } - - fun swipeDirs(swipeDirs: Int) = apply { this.swipeDirs = swipeDirs } - - fun moveCallback(moveCallback: MoveCallback) = apply { this.moveCallback = moveCallback } - - fun swipeCallback(swipeCallback: SwipeCallback) = apply { this.swipeCallback = swipeCallback } - - fun leftDraw(@ColorInt bgColor: Int, @DrawableRes iconRes: Int, @Nullable icon: Drawable? = null) = apply { leftDraw = SwipeDraw(bgColor, iconRes, icon) } - - fun rightDraw(@ColorInt bgColor: Int, @DrawableRes iconRes: Int, @Nullable icon: Drawable? = null) = apply { this.rightDraw = SwipeDraw(bgColor, iconRes, icon) } - - fun build(): Config { - return Config(this) - } - } - } - - -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/utils/customviews/ReadropsWebView.java b/app/src/main/java/com/readrops/app/utils/customviews/ReadropsWebView.java deleted file mode 100644 index 60d35341..00000000 --- a/app/src/main/java/com/readrops/app/utils/customviews/ReadropsWebView.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.readrops.app.utils.customviews; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.res.TypedArray; -import android.util.AttributeSet; -import android.util.Base64; -import android.webkit.WebSettings; -import android.webkit.WebView; - -import androidx.annotation.ColorInt; -import androidx.annotation.Nullable; - -import com.readrops.app.R; -import com.readrops.app.utils.Utils; -import com.readrops.db.pojo.ItemWithFeed; - -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.nodes.Element; -import org.jsoup.parser.Parser; -import org.jsoup.select.Elements; - -public class ReadropsWebView extends WebView { - - private ItemWithFeed itemWithFeed; - - @ColorInt - private int textColor; - @ColorInt - private int backgroundColor; - - public ReadropsWebView(Context context, AttributeSet attrs) { - super(context, attrs); - - getColors(context, attrs); - init(); - } - - public void setItem(ItemWithFeed itemWithFeed) { - this.itemWithFeed = itemWithFeed; - - String text = getText(); - String base64Content = null; - - if (text != null) - base64Content = Base64.encodeToString(text.getBytes(), Base64.NO_PADDING); - - loadData(base64Content, "text/html; charset=utf-8", "base64"); - } - - public String getItemContent() { - String content = itemWithFeed.getItem().getContent(); - return content; - } - - private void getColors(Context context, AttributeSet attrs) { - TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.ReadropsWebView); - textColor = typedArray.getColor(R.styleable.ReadropsWebView_textColor, 0); - backgroundColor = typedArray.getColor(R.styleable.ReadropsWebView_backgroundColor, 0); - - typedArray.recycle(); - } - - @SuppressLint("SetJavaScriptEnabled") - private void init() { - if (!isInEditMode()) { - WebSettings settings = getSettings(); - - settings.setJavaScriptEnabled(true); - settings.setBuiltInZoomControls(true); - settings.setDisplayZoomControls(false); - } - - setVerticalScrollBarEnabled(false); - setBackgroundColor(backgroundColor); - } - - @Nullable - private String getText() { - if (itemWithFeed.getItem().getText() != null) { - Document document; - - if (itemWithFeed.getWebsiteUrl() != null) - document = Jsoup.parse(Parser.unescapeEntities(itemWithFeed.getItem().getText(), false), itemWithFeed.getWebsiteUrl()); - else - document = Jsoup.parse(Parser.unescapeEntities(itemWithFeed.getItem().getText(), false)); - - formatDocument(document); - - int color = itemWithFeed.getColor() != 0 ? itemWithFeed.getColor() : getResources().getColor(R.color.colorPrimary); - return getContext().getString(R.string.webview_html_template, - Utils.getCssColor(itemWithFeed.getBgColor() != 0 ? itemWithFeed.getBgColor() : - color), - Utils.getCssColor(this.textColor), - Utils.getCssColor(backgroundColor), - document.body().html()); - - } else - return null; - } - - private void formatDocument(Document document) { - Elements elements = document.select("figure,figcaption"); - for (Element element : elements) { - element.unwrap(); - } - - elements.clear(); - elements = document.select("div,span"); - - for (Element element : elements) { - element.clearAttributes(); - } - } - - -} diff --git a/app/src/main/java/com/readrops/app/utils/feedscolors/FeedColors.kt b/app/src/main/java/com/readrops/app/utils/feedscolors/FeedColors.kt deleted file mode 100644 index 17a18c7f..00000000 --- a/app/src/main/java/com/readrops/app/utils/feedscolors/FeedColors.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.readrops.app.utils.feedscolors - -import androidx.palette.graphics.Palette -import com.readrops.app.utils.HtmlParser -import com.readrops.app.utils.Utils -import com.readrops.db.entities.Feed - -fun setFeedColors(feed: Feed) { - getFaviconLink(feed) - - if (feed.iconUrl != null) { - val bitmap = Utils.getImageFromUrl(feed.iconUrl) ?: return - val palette = Palette.from(bitmap).generate() - - val dominantSwatch = palette.dominantSwatch - feed.textColor = if (dominantSwatch != null && !Utils.isColorTooBright(dominantSwatch.rgb) - && !Utils.isColorTooDark(dominantSwatch.rgb)) { - dominantSwatch.rgb - } else 0 - - - val mutedSwatch = palette.mutedSwatch - feed.backgroundColor = if (mutedSwatch != null && !Utils.isColorTooBright(mutedSwatch.rgb) - && !Utils.isColorTooDark(mutedSwatch.rgb)) { - mutedSwatch.rgb - } else 0 - } -} - -fun getFaviconLink(feed: Feed) { - feed.iconUrl = if (feed.iconUrl != null) - feed.iconUrl - else - HtmlParser.getFaviconLink(feed.siteUrl!!) -} - - - diff --git a/app/src/main/java/com/readrops/app/utils/feedscolors/FeedsColorsIntentService.kt b/app/src/main/java/com/readrops/app/utils/feedscolors/FeedsColorsIntentService.kt deleted file mode 100644 index 464b0603..00000000 --- a/app/src/main/java/com/readrops/app/utils/feedscolors/FeedsColorsIntentService.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.readrops.app.utils.feedscolors - -import android.app.IntentService -import android.content.Intent -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import com.readrops.app.R -import com.readrops.app.ReadropsApp -import com.readrops.app.utils.ReadropsKeys.FEEDS -import com.readrops.db.Database -import com.readrops.db.entities.Feed -import org.koin.core.component.KoinComponent -import org.koin.core.component.get - -class FeedsColorsIntentService : IntentService("FeedsColorsIntentService"), KoinComponent { - - override fun onHandleIntent(intent: Intent?) { - val feeds: List = intent!!.getParcelableArrayListExtra(FEEDS)!! - val database = get() - - val notificationBuilder = NotificationCompat.Builder(this, ReadropsApp.FEEDS_COLORS_CHANNEL_ID) - .setContentTitle(getString(R.string.get_feeds_colors)) - .setProgress(feeds.size, 0, false) - .setSmallIcon(R.drawable.ic_notif) - .setOnlyAlertOnce(true) - - startForeground(NOTIFICATION_ID, notificationBuilder.build()) - val notificationManager = NotificationManagerCompat.from(this) - - var feedsNb = 0 - feeds.forEach { - notificationBuilder.setContentText(it.name) - notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()) - setFeedColors(it) - - database.feedDao().updateColors(it.id, it.textColor, it.backgroundColor) - notificationBuilder.setProgress(feeds.size, ++feedsNb, false) - notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()) - } - - stopForeground(true) - } - - companion object { - private const val NOTIFICATION_ID = 1 - } - -} \ No newline at end of file diff --git a/app/src/main/res/color/generic_button_color_selector.xml b/app/src/main/res/color/generic_button_color_selector.xml deleted file mode 100644 index b7b39637..00000000 --- a/app/src/main/res/color/generic_button_color_selector.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-v23/splash_background.xml b/app/src/main/res/drawable-v23/splash_background.xml deleted file mode 100644 index 08c59cad..00000000 --- a/app/src/main/res/drawable-v23/splash_background.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/header_background.png b/app/src/main/res/drawable/header_background.png deleted file mode 100644 index 7ee31640..00000000 Binary files a/app/src/main/res/drawable/header_background.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_about_grey.xml b/app/src/main/res/drawable/ic_about_grey.xml deleted file mode 100644 index 6d7fbe06..00000000 --- a/app/src/main/res/drawable/ic_about_grey.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_account.xml b/app/src/main/res/drawable/ic_account.xml deleted file mode 100644 index 9e11be41..00000000 --- a/app/src/main/res/drawable/ic_account.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_add_account.xml b/app/src/main/res/drawable/ic_add_account.xml new file mode 100644 index 00000000..e0e8a950 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_account.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_account_grey.xml b/app/src/main/res/drawable/ic_add_account_grey.xml deleted file mode 100644 index d5d51b07..00000000 --- a/app/src/main/res/drawable/ic_add_account_grey.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_add_white.xml b/app/src/main/res/drawable/ic_add_white.xml deleted file mode 100644 index e3979cd7..00000000 --- a/app/src/main/res/drawable/ic_add_white.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_backup.xml b/app/src/main/res/drawable/ic_backup.xml new file mode 100644 index 00000000..24f2a3d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_backup.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_broken_image.xml b/app/src/main/res/drawable/ic_broken_image.xml new file mode 100644 index 00000000..fa7a353f --- /dev/null +++ b/app/src/main/res/drawable/ic_broken_image.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_bug.xml b/app/src/main/res/drawable/ic_bug.xml new file mode 100644 index 00000000..af8bc848 --- /dev/null +++ b/app/src/main/res/drawable/ic_bug.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bug_report.xml b/app/src/main/res/drawable/ic_bug_report.xml new file mode 100644 index 00000000..e9dbae4d --- /dev/null +++ b/app/src/main/res/drawable/ic_bug_report.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cancel_grey.xml b/app/src/main/res/drawable/ic_cancel_grey.xml deleted file mode 100644 index 12c616ef..00000000 --- a/app/src/main/res/drawable/ic_cancel_grey.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_changelog.xml b/app/src/main/res/drawable/ic_changelog.xml new file mode 100644 index 00000000..30d5d26b --- /dev/null +++ b/app/src/main/res/drawable/ic_changelog.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_check_green.xml b/app/src/main/res/drawable/ic_check_green.xml deleted file mode 100644 index 123c5bb0..00000000 --- a/app/src/main/res/drawable/ic_check_green.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_color.xml b/app/src/main/res/drawable/ic_color.xml new file mode 100644 index 00000000..c3d2c7b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_color.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml deleted file mode 100644 index 39e64d69..00000000 --- a/app/src/main/res/drawable/ic_delete.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_delete_grey.xml b/app/src/main/res/drawable/ic_delete_grey.xml deleted file mode 100644 index 8561676b..00000000 --- a/app/src/main/res/drawable/ic_delete_grey.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_donation.xml b/app/src/main/res/drawable/ic_donation.xml new file mode 100644 index 00000000..3a8a4aea --- /dev/null +++ b/app/src/main/res/drawable/ic_donation.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_done_all.xml b/app/src/main/res/drawable/ic_done_all.xml new file mode 100644 index 00000000..9635085d --- /dev/null +++ b/app/src/main/res/drawable/ic_done_all.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 00000000..818e0a01 --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml deleted file mode 100644 index 15b7a280..00000000 --- a/app/src/main/res/drawable/ic_edit.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_edit_grey.xml b/app/src/main/res/drawable/ic_edit_grey.xml deleted file mode 100644 index d1f47502..00000000 --- a/app/src/main/res/drawable/ic_edit_grey.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_error.xml b/app/src/main/res/drawable/ic_error.xml index 26d5a65e..391e8b4c 100644 --- a/app/src/main/res/drawable/ic_error.xml +++ b/app/src/main/res/drawable/ic_error.xml @@ -1,5 +1,5 @@ - - + diff --git a/app/src/main/res/drawable/ic_favorite_border.xml b/app/src/main/res/drawable/ic_favorite_border.xml new file mode 100644 index 00000000..2acceabb --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_border.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_filter.xml b/app/src/main/res/drawable/ic_filter.xml deleted file mode 100644 index 5d4ec18e..00000000 --- a/app/src/main/res/drawable/ic_filter.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_filter_list.xml b/app/src/main/res/drawable/ic_filter_list.xml new file mode 100644 index 00000000..b8090430 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_list.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/ic_github.xml new file mode 100644 index 00000000..62989ed4 --- /dev/null +++ b/app/src/main/res/drawable/ic_github.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_hourglass_empty.xml b/app/src/main/res/drawable/ic_hourglass_empty.xml new file mode 100644 index 00000000..76d02ef7 --- /dev/null +++ b/app/src/main/res/drawable/ic_hourglass_empty.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_image.xml b/app/src/main/res/drawable/ic_image.xml new file mode 100644 index 00000000..fd890cef --- /dev/null +++ b/app/src/main/res/drawable/ic_image.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_library.xml b/app/src/main/res/drawable/ic_library.xml new file mode 100644 index 00000000..479d5ab6 --- /dev/null +++ b/app/src/main/res/drawable/ic_library.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_new.xml b/app/src/main/res/drawable/ic_new.xml new file mode 100644 index 00000000..437fdb50 --- /dev/null +++ b/app/src/main/res/drawable/ic_new.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_notif.xml b/app/src/main/res/drawable/ic_notif.xml deleted file mode 100644 index 4366e94e..00000000 --- a/app/src/main/res/drawable/ic_notif.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_notifications.xml b/app/src/main/res/drawable/ic_notifications.xml index 6c01015e..1d038a44 100644 --- a/app/src/main/res/drawable/ic_notifications.xml +++ b/app/src/main/res/drawable/ic_notifications.xml @@ -1,5 +1,5 @@ - - + diff --git a/app/src/main/res/drawable/ic_open_in_browser_white.xml b/app/src/main/res/drawable/ic_open_in_browser_white.xml deleted file mode 100644 index 3fb9799c..00000000 --- a/app/src/main/res/drawable/ic_open_in_browser_white.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml new file mode 100644 index 00000000..f4cc4181 --- /dev/null +++ b/app/src/main/res/drawable/ic_person.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_read.xml b/app/src/main/res/drawable/ic_read.xml deleted file mode 100644 index f26dd5e2..00000000 --- a/app/src/main/res/drawable/ic_read.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_reading_time.xml b/app/src/main/res/drawable/ic_reading_time.xml deleted file mode 100644 index ef0c47e6..00000000 --- a/app/src/main/res/drawable/ic_reading_time.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml deleted file mode 100644 index cc2d1e04..00000000 --- a/app/src/main/res/drawable/ic_refresh.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_remove_done.xml b/app/src/main/res/drawable/ic_remove_done.xml new file mode 100644 index 00000000..589665ba --- /dev/null +++ b/app/src/main/res/drawable/ic_remove_done.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_select_all_white.xml b/app/src/main/res/drawable/ic_select_all_white.xml deleted file mode 100644 index bc75904c..00000000 --- a/app/src/main/res/drawable/ic_select_all_white.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml index f7c249c4..298a5a1f 100644 --- a/app/src/main/res/drawable/ic_settings.xml +++ b/app/src/main/res/drawable/ic_settings.xml @@ -1,9 +1,5 @@ - - + + diff --git a/app/src/main/res/drawable/ic_share_white.xml b/app/src/main/res/drawable/ic_share_white.xml deleted file mode 100644 index 045bbc0c..00000000 --- a/app/src/main/res/drawable/ic_share_white.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_star.xml b/app/src/main/res/drawable/ic_star.xml index c172fe62..9b295a03 100644 --- a/app/src/main/res/drawable/ic_star.xml +++ b/app/src/main/res/drawable/ic_star.xml @@ -1,5 +1,5 @@ - + + + diff --git a/app/src/main/res/drawable/ic_empty_star.xml b/app/src/main/res/drawable/ic_star_outline.xml similarity index 58% rename from app/src/main/res/drawable/ic_empty_star.xml rename to app/src/main/res/drawable/ic_star_outline.xml index 26a48774..925b9731 100644 --- a/app/src/main/res/drawable/ic_empty_star.xml +++ b/app/src/main/res/drawable/ic_star_outline.xml @@ -1,5 +1,5 @@ - + + + diff --git a/app/src/main/res/drawable/ic_sync.xml b/app/src/main/res/drawable/ic_sync.xml index f16a1b06..6c2e2b73 100644 --- a/app/src/main/res/drawable/ic_sync.xml +++ b/app/src/main/res/drawable/ic_sync.xml @@ -1,4 +1,4 @@ - diff --git a/app/src/main/res/drawable/ic_timeline.xml b/app/src/main/res/drawable/ic_timeline.xml new file mode 100644 index 00000000..68d26c91 --- /dev/null +++ b/app/src/main/res/drawable/ic_timeline.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_unfold_less.xml b/app/src/main/res/drawable/ic_unfold_less.xml new file mode 100644 index 00000000..5b06bf91 --- /dev/null +++ b/app/src/main/res/drawable/ic_unfold_less.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_unfold_more.xml b/app/src/main/res/drawable/ic_unfold_more.xml new file mode 100644 index 00000000..df47d078 --- /dev/null +++ b/app/src/main/res/drawable/ic_unfold_more.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_unread.xml b/app/src/main/res/drawable/ic_unread.xml deleted file mode 100644 index 7b0203d3..00000000 --- a/app/src/main/res/drawable/ic_unread.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_version.xml b/app/src/main/res/drawable/ic_version.xml new file mode 100644 index 00000000..e78b6839 --- /dev/null +++ b/app/src/main/res/drawable/ic_version.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_visible.xml b/app/src/main/res/drawable/ic_visible.xml new file mode 100644 index 00000000..f843e291 --- /dev/null +++ b/app/src/main/res/drawable/ic_visible.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_visible_off.xml b/app/src/main/res/drawable/ic_visible_off.xml new file mode 100644 index 00000000..5993ca39 --- /dev/null +++ b/app/src/main/res/drawable/ic_visible_off.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_warning_red.xml b/app/src/main/res/drawable/ic_warning_red.xml deleted file mode 100644 index 745c0e76..00000000 --- a/app/src/main/res/drawable/ic_warning_red.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/item_date_background.xml b/app/src/main/res/drawable/item_date_background.xml deleted file mode 100644 index 5b493614..00000000 --- a/app/src/main/res/drawable/item_date_background.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/logo.png b/app/src/main/res/drawable/logo.png deleted file mode 100644 index 72048919..00000000 Binary files a/app/src/main/res/drawable/logo.png and /dev/null differ diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml deleted file mode 100644 index ae2054f9..00000000 --- a/app/src/main/res/drawable/splash_background.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/tab_indicator.xml b/app/src/main/res/drawable/tab_indicator.xml deleted file mode 100644 index f1e295c6..00000000 --- a/app/src/main/res/drawable/tab_indicator.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/toolbar_scrim.xml b/app/src/main/res/drawable/toolbar_scrim.xml deleted file mode 100644 index 0d65b59f..00000000 --- a/app/src/main/res/drawable/toolbar_scrim.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/account_type_item.xml b/app/src/main/res/layout/account_type_item.xml deleted file mode 100644 index be5446c3..00000000 --- a/app/src/main/res/layout/account_type_item.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_account_type_list.xml b/app/src/main/res/layout/activity_account_type_list.xml deleted file mode 100644 index 8ce3e3a1..00000000 --- a/app/src/main/res/layout/activity_account_type_list.xml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - -