From 2ae8f8da186891a99eb7175b27675c65fd252b75 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sun, 29 Oct 2023 07:53:49 +0530 Subject: [PATCH] Add support for importing and exporting OPML (#121) * Add platform specific file managers * Add outlined button component * Add query to get number of feeds * Check if app has feeds when settings screen is initialised * Add xmlutil library for xml serialization * Implement `FeedsOpml` * Add support for passing title when adding feed in the repository * Add function to add OPML feeds in `RssRepository` * Add function to fetch all feeds in blocking manner * Capture exceptions when encoding and decoding feeds opml * Add OPML manager for managing OPML import and export * Make border nullable in `OutlineButton` component * Add OPML section to settings screen * Fix feed fetching stuck in a loop when fetching link from the HTML * Ignore redirects if the feed link didn't change from original * Remove custom network timeouts The custom timeouts are a bit long when importing hundreds of feeds, so removed those and using default values * Move `addOpmlFeeds` function from repository to OPML manager There is no need for our manager to know about OPML feeds. Since it's just logic of looping over OPML feeds, moved it to the manager. * Add space between progress text and progress value * Launch iOS document picker from main thread * Reverse OPML feeds list when importing Since the app orders based on when the feed is created, we are reversing the OPML list in order to try and preserve the order as much as we can. It might still get affected, because we are doing parallel processing (since we are doing joinAll it should be fine, but not entirely sure). --- build.gradle.kts | 1 + gradle/libs.versions.toml | 6 + .../resources/strings/EnTwineStrings.kt | 6 + .../reader/resources/strings/TwineStrings.kt | 6 + shared/build.gradle.kts | 2 + .../reader/filemanager/AndroidFileManager.kt | 120 +++++++++++++ .../AndroidFileManagerInitialiser.kt | 30 ++++ .../filemanager/FileManagerComponent.kt | 32 ++++ .../rss/reader/network/NetworkComponent.kt | 10 +- .../rss/reader/components/OutlinedButton.kt | 51 ++++++ .../reader/di/SharedApplicationComponent.kt | 8 +- .../rss/reader/filemanager/FileManager.kt | 23 +++ .../filemanager/FileManagerComponent.kt | 19 +++ .../rss/reader/network/FeedFetcher.kt | 8 +- .../sasikanth/rss/reader/opml/FeedsOpml.kt | 88 ++++++++++ .../dev/sasikanth/rss/reader/opml/Opml.kt | 31 ++++ .../sasikanth/rss/reader/opml/OpmlManager.kt | 157 ++++++++++++++++++ .../rss/reader/repository/RssRepository.kt | 11 +- .../rss/reader/settings/SettingsEvent.kt | 6 + .../rss/reader/settings/SettingsPresenter.kt | 51 +++++- .../rss/reader/settings/SettingsState.kt | 11 +- .../rss/reader/settings/ui/SettingsScreen.kt | 135 +++++++++++++-- .../sasikanth/rss/reader/utils/Constants.kt | 1 + .../dev/sasikanth/rss/reader/database/Feed.sq | 3 + .../rss/reader/opml/FeedsOpmlTest.kt | 115 +++++++++++++ .../rss/reader/di/ApplicationComponent.kt | 3 + .../filemanager/FileManagerComponent.kt | 24 +++ .../rss/reader/filemanager/IOSFileManager.kt | 109 ++++++++++++ .../rss/reader/network/NetworkComponent.kt | 9 +- 29 files changed, 1029 insertions(+), 47 deletions(-) create mode 100644 shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/filemanager/AndroidFileManager.kt create mode 100644 shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/filemanager/AndroidFileManagerInitialiser.kt create mode 100644 shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/OutlinedButton.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManager.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/FeedsOpml.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/Opml.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/OpmlManager.kt create mode 100644 shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/opml/FeedsOpmlTest.kt create mode 100644 shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.kt create mode 100644 shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/filemanager/IOSFileManager.kt diff --git a/build.gradle.kts b/build.gradle.kts index f6c298cb8..d33ebf984 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,6 +30,7 @@ plugins { alias(libs.plugins.buildKonfig).apply(false) alias(libs.plugins.sentry.android).apply(false) alias(libs.plugins.kotlin.parcelize).apply(false) + alias(libs.plugins.kotlinx.serialization).apply(false) } allprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 655980ab0..9a605afb7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ napier = "2.6.1" kotlinx_coroutines = "1.7.3" kotlinx_date_time = "0.4.1" kotlinx_immutable_collections = "0.3.6" +kotlinx_serialization = "1.6.0" decompose = "2.1.3-compose-experimental" essenty = "1.2.0" androidx_activity = "1.8.0" @@ -43,6 +44,7 @@ atomicfu = "0.22.0" okio = "3.6.0" paging = "3.3.0-alpha02-0.4.0" stately = "2.0.5" +xmlutil = "0.86.2" [libraries] compose_runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" } @@ -98,6 +100,8 @@ paging-common = { module = "app.cash.paging:paging-common", version.ref = "pagin paging-compose = { module = "app.cash.paging:paging-compose-common", version.ref = "paging" } stately-isolate = { module = "co.touchlab:stately-isolate", version.ref = "stately" } stately-iso-collections = { module = "co.touchlab:stately-iso-collections", version.ref = "stately" } +xmlutil-core = { module = "io.github.pdvrieze.xmlutil:core", version.ref = "xmlutil" } +xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", version.ref = "xmlutil" } [plugins] android_application = { id = "com.android.application", version.ref = "android_gradle_plugin" } @@ -106,6 +110,7 @@ kotlin_multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin_cocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } kotlin_parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlinx_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinx_serialization" } compose = { id = "org.jetbrains.compose", version.ref = "compose" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } @@ -117,3 +122,4 @@ sentry_android = { id = "io.sentry.android.gradle", version.ref = "sentry_androi compose = [ "compose_runtime", "compose_foundation", "compose_material", "compose_material3", "compose_resources", "compose_ui", "compose_ui_util" ] kotlinx = [ "kotlinx_coroutines", "kotlinx_datetime", "kotlinx_immutable_collections" ] androidx_test = [ "androidx_test_runner", "androidx_test_rules" ] +xmlutil = [ "xmlutil-core", "xmlutil-serialization" ] diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt index 6faaa3079..79eb2c1e6 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt @@ -62,6 +62,7 @@ val EnTwineStrings = moreMenuOptions = "More menu options", settingsHeaderBehaviour = "Behavior", settingsHeaderFeedback = "Feedback & bug reports", + settingsHeaderOpml = "OPML", settingsBrowserTypeTitle = "Use in-app browser", settingsBrowserTypeSubtitle = "When turned off, links will open in your default browser.", settingsEnableBlurTitle = "Enable blur in homepage", @@ -71,6 +72,11 @@ val EnTwineStrings = settingsVersion = { versionName, versionCode -> "$versionName ($versionCode)" }, settingsAboutTitle = "About Twine", settingsAboutSubtitle = "Get to know the authors", + settingsOpmlImport = "Import", + settingsOpmlExport = "Export", + settingsOpmlImporting = { progress -> "Importing.. $progress%" }, + settingsOpmlExporting = { progress -> "Exporting.. $progress%" }, + settingsOpmlCancel = "Cancel", feeds = "Feeds", editFeeds = "Edit feeds", comments = "Comments", diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt index 2a4ae6d1b..6af0cb943 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt @@ -50,6 +50,7 @@ data class TwineStrings( val settings: String, val moreMenuOptions: String, val settingsHeaderBehaviour: String, + val settingsHeaderOpml: String, val settingsHeaderFeedback: String, val settingsBrowserTypeTitle: String, val settingsBrowserTypeSubtitle: String, @@ -59,6 +60,11 @@ data class TwineStrings( val settingsVersion: (String, Int) -> String, val settingsAboutTitle: String, val settingsAboutSubtitle: String, + val settingsOpmlImport: String, + val settingsOpmlExport: String, + val settingsOpmlImporting: (Int) -> String, + val settingsOpmlExporting: (Int) -> String, + val settingsOpmlCancel: String, val feeds: String, val editFeeds: String, val comments: String, diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 00a9f8292..937d06843 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -27,6 +27,7 @@ plugins { alias(libs.plugins.ksp) alias(libs.plugins.buildKonfig) alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlinx.serialization) } buildkonfig { @@ -113,6 +114,7 @@ kotlin { implementation(libs.paging.compose) implementation(libs.stately.isolate) implementation(libs.stately.iso.collections) + implementation(libs.bundles.xmlutil) } } val commonTest by getting { diff --git a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/filemanager/AndroidFileManager.kt b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/filemanager/AndroidFileManager.kt new file mode 100644 index 000000000..0ad280424 --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/filemanager/AndroidFileManager.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.filemanager + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import dev.sasikanth.rss.reader.di.scopes.AppScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import me.tatarka.inject.annotations.Inject + +@Inject +@AppScope +class AndroidFileManager(context: Context) : FileManager { + + private val application = context as Application + private val result = Channel() + + private lateinit var createDocumentLauncher: ActivityResultLauncher + private lateinit var openDocumentLauncher: ActivityResultLauncher> + + private var content: String? = null + + override suspend fun save(name: String, content: String) { + this.content = content + + if (!this.content.isNullOrBlank()) { + createDocumentLauncher.launch(name) + } + } + + override suspend fun read(): String? { + openDocumentLauncher.launch(arrayOf("application/xml", "text/xml", "text/x-opml")) + return result.receiveAsFlow().first() + } + + internal fun registerActivityWatcher() { + val callback = + object : ActivityLifecycleCallbacksAdapter() { + val launcherIntent = + Intent(Intent.ACTION_MAIN, null).apply { addCategory(Intent.CATEGORY_LAUNCHER) } + val appList = application.packageManager.queryIntentActivities(launcherIntent, 0) + + override fun onActivityCreated(activity: Activity, bundle: Bundle?) { + if ( + activity is ComponentActivity && + appList.any { it.activityInfo.name == activity::class.qualifiedName } + ) { + registerDocumentCreateActivityResult(activity) + registerDocumentOpenActivityResult(activity) + } + } + } + application.registerActivityLifecycleCallbacks(callback) + } + + private fun registerDocumentCreateActivityResult(activity: ComponentActivity) { + createDocumentLauncher = + activity.registerForActivityResult( + ActivityResultContracts.CreateDocument("application/xml") + ) { uri -> + if (uri == null) return@registerForActivityResult + + val outputStream = application.contentResolver.openOutputStream(uri) + outputStream?.use { it.write(content?.toByteArray()) } + + content = null + } + } + + private fun registerDocumentOpenActivityResult(activity: ComponentActivity) { + openDocumentLauncher = + activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@registerForActivityResult + + val inputStream = application.contentResolver.openInputStream(uri) + inputStream?.use { + val content = it.bufferedReader().readText() + result.trySend(content) + } + } + } +} + +private open class ActivityLifecycleCallbacksAdapter : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, bundle: Bundle?) = Unit + + override fun onActivityStarted(activity: Activity) = Unit + + override fun onActivityResumed(activity: Activity) = Unit + + override fun onActivityPaused(activity: Activity) = Unit + + override fun onActivityStopped(activity: Activity) = Unit + + override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) = Unit + + override fun onActivityDestroyed(activity: Activity) = Unit +} diff --git a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/filemanager/AndroidFileManagerInitialiser.kt b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/filemanager/AndroidFileManagerInitialiser.kt new file mode 100644 index 000000000..cb404514b --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/filemanager/AndroidFileManagerInitialiser.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.filemanager + +import dev.sasikanth.rss.reader.initializers.Initializer +import me.tatarka.inject.annotations.Inject + +@Inject +class AndroidFileManagerInitializer( + private val fileManager: AndroidFileManager, +) : Initializer { + + override fun initialize() { + fileManager.registerActivityWatcher() + } +} diff --git a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.kt b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.kt new file mode 100644 index 000000000..5a81f041c --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.filemanager + +import dev.sasikanth.rss.reader.di.scopes.AppScope +import dev.sasikanth.rss.reader.initializers.Initializer +import me.tatarka.inject.annotations.IntoSet +import me.tatarka.inject.annotations.Provides + +actual interface FileManagerComponent { + + @IntoSet + @Provides + @AppScope + fun providesAndroidFileManagerInitializer(bind: AndroidFileManagerInitializer): Initializer = bind + + @Provides fun AndroidFileManager.bind(): FileManager = this +} diff --git a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/network/NetworkComponent.kt b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/network/NetworkComponent.kt index 4bf1172e8..d27ddf004 100644 --- a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/network/NetworkComponent.kt +++ b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/network/NetworkComponent.kt @@ -18,7 +18,6 @@ package dev.sasikanth.rss.reader.network import dev.sasikanth.rss.reader.di.scopes.AppScope import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp -import java.time.Duration import me.tatarka.inject.annotations.Provides internal actual interface NetworkComponent { @@ -29,13 +28,6 @@ internal actual interface NetworkComponent { @Provides @AppScope fun providesHttpClient(): HttpClient { - return HttpClient(OkHttp) { - engine { - config { - retryOnConnectionFailure(true) - callTimeout(Duration.ofMinutes(2)) - } - } - } + return HttpClient(OkHttp) { engine { config { retryOnConnectionFailure(true) } } } } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/OutlinedButton.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/OutlinedButton.kt new file mode 100644 index 000000000..e70539612 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/OutlinedButton.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.sasikanth.rss.reader.ui.AppTheme + +@Composable +fun OutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: ButtonColors = + ButtonDefaults.outlinedButtonColors( + containerColor = AppTheme.colorScheme.surfaceContainerLow, + contentColor = AppTheme.colorScheme.tintedForeground + ), + border: BorderStroke? = BorderStroke(1.dp, AppTheme.colorScheme.surfaceContainerHigh), + content: @Composable RowScope.() -> Unit +) { + androidx.compose.material3.OutlinedButton( + modifier = modifier, + onClick = onClick, + border = border, + colors = colors, + shape = MaterialTheme.shapes.medium, + content = content, + enabled = enabled + ) +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/SharedApplicationComponent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/SharedApplicationComponent.kt index 0c27a70b2..2e9bca966 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/SharedApplicationComponent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/SharedApplicationComponent.kt @@ -17,6 +17,7 @@ package dev.sasikanth.rss.reader.di import dev.sasikanth.rss.reader.components.image.ImageLoader import dev.sasikanth.rss.reader.di.scopes.AppScope +import dev.sasikanth.rss.reader.filemanager.FileManagerComponent import dev.sasikanth.rss.reader.initializers.Initializer import dev.sasikanth.rss.reader.logging.LoggingComponent import dev.sasikanth.rss.reader.network.NetworkComponent @@ -27,7 +28,12 @@ import dev.sasikanth.rss.reader.utils.DispatchersProvider import me.tatarka.inject.annotations.Provides abstract class SharedApplicationComponent : - DataComponent, ImageLoaderComponent, SentryComponent, NetworkComponent, LoggingComponent { + DataComponent, + ImageLoaderComponent, + SentryComponent, + NetworkComponent, + LoggingComponent, + FileManagerComponent { abstract val imageLoader: ImageLoader diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManager.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManager.kt new file mode 100644 index 000000000..693c78c20 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManager.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.filemanager + +interface FileManager { + suspend fun save(name: String, content: String) + + suspend fun read(): String? +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.kt new file mode 100644 index 000000000..d5bfe497f --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.filemanager + +expect interface FileManagerComponent diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/network/FeedFetcher.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/network/FeedFetcher.kt index 9ae509cd9..edd0b54b7 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/network/FeedFetcher.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/network/FeedFetcher.kt @@ -79,7 +79,7 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe HttpStatusCode.PermanentRedirect -> { if (redirectCount < MAX_REDIRECTS_ALLOWED) { val newUrl = response.headers["Location"] - if (newUrl != null) { + if (newUrl != url && newUrl != null) { redirectCount += 1 fetch(url = newUrl, fetchPosts = fetchPosts) } else { @@ -113,10 +113,14 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe // There are situation where XML parsers fail to identify if it's // a HTML document and fail, so trying to fetch link with HTML one // last time just to be safe if it fails with XML parsing issue. + // + // In some cases the link that is returned might be same as the original + // causing it to loop. So, we are using the redirect check here. is HtmlContentException, is XmlParsingError -> { val feedUrl = fetchFeedLinkFromHtmlIfExists(responseContent, url) - if (!feedUrl.isNullOrBlank()) { + if (feedUrl != url && !feedUrl.isNullOrBlank() && redirectCount < MAX_REDIRECTS_ALLOWED) { + redirectCount += 1 fetch(url = feedUrl, fetchPosts = fetchPosts) } else { if (e is XmlParsingError) { diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/FeedsOpml.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/FeedsOpml.kt new file mode 100644 index 000000000..1bc2415d1 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/FeedsOpml.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.sasikanth.rss.reader.opml + +import dev.sasikanth.rss.reader.di.scopes.AppScope +import dev.sasikanth.rss.reader.models.local.Feed +import io.sentry.kotlin.multiplatform.Sentry +import kotlinx.serialization.serializer +import me.tatarka.inject.annotations.Inject +import nl.adaptivity.xmlutil.serialization.XML + +@Inject +@AppScope +class FeedsOpml { + + private val xml = XML { + autoPolymorphic = true + indentString = " " + defaultPolicy { + pedantic = false + ignoreUnknownChildren() + } + } + + fun encode(feeds: List): String { + return try { + val opml = + Opml( + version = "2.0", + head = Head("Twine RSS Feeds"), + body = Body(outlines = feeds.map(::mapFeedToOutline)) + ) + + val xmlString = xml.encodeToString(serializer(), opml) + + StringBuilder(xmlString) + .insert(0, "\n") + .appendLine() + .toString() + } catch (e: Exception) { + Sentry.captureException(e) + "" + } + } + + fun decode(content: String): List { + return try { + val opml = xml.decodeFromString(serializer(), content) + val opmlFeeds = mutableListOf() + + fun flatten(outline: Outline) { + if (outline.outlines.isNullOrEmpty() && !outline.xmlUrl.isNullOrBlank()) { + opmlFeeds.add(mapOutlineToOpmlFeed(outline)) + } + + outline.outlines?.forEach { nestedOutline -> flatten(nestedOutline) } + } + + opml.body.outlines.forEach { outline -> flatten(outline) } + + opmlFeeds.distinctBy { it.link } + } catch (e: Exception) { + Sentry.captureException(e) + emptyList() + } + } + + private fun mapFeedToOutline(feed: Feed) = + Outline(text = feed.name, title = feed.name, type = "rss", xmlUrl = feed.link, outlines = null) + + private fun mapOutlineToOpmlFeed(outline: Outline): OpmlFeed { + val title = outline.title ?: outline.text ?: outline.xmlUrl!! + return OpmlFeed(title = title, link = outline.xmlUrl!!) + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/Opml.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/Opml.kt new file mode 100644 index 000000000..cd6a5e770 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/Opml.kt @@ -0,0 +1,31 @@ +package dev.sasikanth.rss.reader.opml + +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName + +@Serializable +@XmlSerialName("opml") +internal data class Opml( + @XmlElement(value = false) val version: String?, + val head: Head, + val body: Body +) + +@Serializable @XmlSerialName("head") internal data class Head(@XmlElement val title: String) + +@Serializable +@XmlSerialName("body") +internal data class Body(@XmlSerialName("outline") val outlines: List) + +@Serializable +@XmlSerialName("outline") +internal data class Outline( + @XmlElement(value = false) val title: String?, + @XmlElement(value = false) val text: String?, + @XmlElement(value = false) val type: String?, + @XmlElement(value = false) val xmlUrl: String?, + @XmlSerialName("outline") val outlines: List? +) + +data class OpmlFeed(val title: String, val link: String) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/OpmlManager.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/OpmlManager.kt new file mode 100644 index 000000000..bab0d827d --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/OpmlManager.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.opml + +import co.touchlab.stately.concurrency.AtomicInt +import dev.sasikanth.rss.reader.di.scopes.AppScope +import dev.sasikanth.rss.reader.filemanager.FileManager +import dev.sasikanth.rss.reader.repository.RssRepository +import dev.sasikanth.rss.reader.utils.Constants.BACKUP_FILE_NAME +import dev.sasikanth.rss.reader.utils.DispatchersProvider +import kotlin.math.roundToInt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.tatarka.inject.annotations.Inject + +@Inject +@AppScope +class OpmlManager( + dispatchersProvider: DispatchersProvider, + private val fileManager: FileManager, + private val feedsOpml: FeedsOpml, + private val rssRepository: RssRepository, +) { + + private val job = SupervisorJob() + dispatchersProvider.io + + private val _result = MutableSharedFlow(replay = 1) + val result: SharedFlow = _result + + companion object { + private const val IMPORT_CHUNKS = 20 + } + + init { + _result.tryEmit(OpmlResult.Idle) + } + + suspend fun import() { + try { + withContext(job) { + val opmlXmlContent = fileManager.read() + + if (!opmlXmlContent.isNullOrBlank()) { + _result.emit(OpmlResult.InProgress.Importing(0)) + val opmlFeeds = feedsOpml.decode(opmlXmlContent) + + addOpmlFeeds(opmlFeeds) + .onEach { progress -> _result.emit(OpmlResult.InProgress.Importing(progress)) } + .onCompletion { _result.emit(OpmlResult.Idle) } + .collect() + } else { + _result.emit(OpmlResult.Error.NoContentInOpmlFile) + } + } + } catch (e: Exception) { + if (e is CancellationException) { + return + } + + _result.emit(OpmlResult.Error.UnknownFailure(e)) + } + } + + suspend fun export() { + try { + withContext(job) { + _result.emit(OpmlResult.InProgress.Exporting(0)) + + // TODO: Use pagination for fetching feeds? + // will be much more memory efficient if there are lot of feeds. + // Need to modify encode as well to support paginated input + + // TODO: Should we track real time progress as we loop through the feeds? + // It's a quick action, so not sure. Maybe once pagination support is added here + // I can do that + + val opmlString = + rssRepository.allFeedsBlocking().run { + _result.emit(OpmlResult.InProgress.Exporting(50)) + feedsOpml.encode(this) + } + fileManager.save(BACKUP_FILE_NAME, opmlString) + + _result.emit(OpmlResult.InProgress.Exporting(100)) + _result.emit(OpmlResult.Idle) + } + } catch (e: Exception) { + if (e is CancellationException) { + return + } + + _result.emit(OpmlResult.Error.UnknownFailure(e)) + } + } + + fun cancel() { + job.cancelChildren() + _result.tryEmit(OpmlResult.Idle) + } + + private fun addOpmlFeeds(feedLinks: List): Flow = channelFlow { + val totalFeedCount = feedLinks.size + val processedFeedsCount = AtomicInt(0) + + feedLinks.reversed().chunked(IMPORT_CHUNKS).forEach { feedsGroup -> + feedsGroup + .map { feed -> launch { rssRepository.addFeed(feedLink = feed.link, title = feed.title) } } + .joinAll() + + val size = processedFeedsCount.addAndGet(feedsGroup.size) + // We are converting the total feed count to float + // so that we can get the precise progress like 0.1, 0.2..etc., + send(((size / totalFeedCount.toFloat()) * 100).roundToInt()) + } + } +} + +sealed interface OpmlResult { + object Idle : OpmlResult + + sealed interface InProgress : OpmlResult { + data class Importing(val progress: Int) : OpmlResult + + data class Exporting(val progress: Int) : OpmlResult + } + + sealed interface Error : OpmlResult { + object NoContentInOpmlFile : OpmlResult + + data class UnknownFailure(val error: Exception) : OpmlResult + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/RssRepository.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/RssRepository.kt index 26b17c3c1..2b670ffac 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/RssRepository.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/RssRepository.kt @@ -61,6 +61,7 @@ class RssRepository( suspend fun addFeed( feedLink: String, + title: String? = null, transformUrl: Boolean = true, fetchPosts: Boolean = true ): FeedAddResult { @@ -73,7 +74,7 @@ class RssRepository( return@withContext try { val feedPayload = feedFetchResult.feedPayload feedQueries.upsert( - name = feedPayload.name, + name = title ?: feedPayload.name, icon = feedPayload.icon, description = feedPayload.description, homepageLink = feedPayload.homepageLink, @@ -176,6 +177,10 @@ class RssRepository( ) } + suspend fun allFeedsBlocking(): List { + return withContext(ioDispatcher) { feedQueries.feeds(mapper = ::mapToFeed).executeAsList() } + } + /** Search feeds, returns all feeds if [searchQuery] is empty */ fun searchFeed(searchQuery: String): PagingSource { return QueryPagingSource( @@ -269,6 +274,10 @@ class RssRepository( return feedQueries.numberOfPinnedFeeds().asFlow().mapToOne(ioDispatcher) } + fun numberOfFeeds(): Flow { + return feedQueries.numberOfFeeds().asFlow().mapToOne(ioDispatcher) + } + private fun mapToPostWithMetadata( title: String, description: String, diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt index 0c1140686..76e69a78d 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt @@ -26,4 +26,10 @@ sealed interface SettingsEvent { data class ToggleFeaturedItemBlur(val value: Boolean) : SettingsEvent object AboutClicked : SettingsEvent + + object ImportOpmlClicked : SettingsEvent + + object ExportOpmlClicked : SettingsEvent + + object CancelOpmlImportOrExport : SettingsEvent } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt index f6a4d6eb6..8dec5c58b 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt @@ -19,7 +19,9 @@ import com.arkivanov.decompose.ComponentContext import com.arkivanov.essenty.instancekeeper.InstanceKeeper import com.arkivanov.essenty.instancekeeper.getOrCreate import dev.sasikanth.rss.reader.app.AppInfo +import dev.sasikanth.rss.reader.opml.OpmlManager import dev.sasikanth.rss.reader.repository.BrowserType +import dev.sasikanth.rss.reader.repository.RssRepository import dev.sasikanth.rss.reader.repository.SettingsRepository import dev.sasikanth.rss.reader.utils.DispatchersProvider import kotlinx.coroutines.CoroutineScope @@ -41,7 +43,9 @@ import me.tatarka.inject.annotations.Inject class SettingsPresenter( dispatchersProvider: DispatchersProvider, private val settingsRepository: SettingsRepository, + private val rssRepository: RssRepository, private val appInfo: AppInfo, + private val opmlManager: OpmlManager, @Assisted componentContext: ComponentContext, @Assisted private val goBack: () -> Unit, @Assisted private val openAbout: () -> Unit, @@ -52,7 +56,9 @@ class SettingsPresenter( PresenterInstance( dispatchersProvider = dispatchersProvider, appInfo = appInfo, - settingsRepository = settingsRepository + settingsRepository = settingsRepository, + rssRepository = rssRepository, + opmlManager = opmlManager, ) } @@ -73,7 +79,9 @@ class SettingsPresenter( private class PresenterInstance( dispatchersProvider: DispatchersProvider, appInfo: AppInfo, + private val rssRepository: RssRepository, private val settingsRepository: SettingsRepository, + private val opmlManager: OpmlManager, ) : InstanceKeeper.Instance { private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchersProvider.main) @@ -87,17 +95,31 @@ class SettingsPresenter( ) init { - settingsRepository.browserType - .combine(settingsRepository.enableFeaturedItemBlur) { browserType, featuredItemBlurEnabled - -> - browserType to featuredItemBlurEnabled + combine( + settingsRepository.browserType, + settingsRepository.enableFeaturedItemBlur, + rssRepository.numberOfFeeds() + ) { browserType, featuredItemBlurEnabled, numberOfFeeds -> + val hasFeeds = numberOfFeeds > 0 + Triple(browserType, featuredItemBlurEnabled, hasFeeds) } - .onEach { (browserType, featuredItemBlurEnabled) -> + .onEach { (browserType, featuredItemBlurEnabled, hasFeeds) -> _state.update { - it.copy(browserType = browserType, enableHomePageBlur = featuredItemBlurEnabled) + it.copy( + browserType = browserType, + enableHomePageBlur = featuredItemBlurEnabled, + hasFeeds = hasFeeds + ) } } .launchIn(coroutineScope) + + opmlManager.result + .onEach { result -> + println(result) + _state.update { it.copy(opmlResult = result) } + } + .launchIn(coroutineScope) } fun dispatch(event: SettingsEvent) { @@ -110,9 +132,24 @@ class SettingsPresenter( SettingsEvent.AboutClicked -> { // no-op } + SettingsEvent.ImportOpmlClicked -> importOpmlClicked() + SettingsEvent.ExportOpmlClicked -> exportOpmlClicked() + SettingsEvent.CancelOpmlImportOrExport -> cancelOpmlImportOrExport() } } + private fun cancelOpmlImportOrExport() { + opmlManager.cancel() + } + + private fun exportOpmlClicked() { + coroutineScope.launch { opmlManager.export() } + } + + private fun importOpmlClicked() { + coroutineScope.launch { opmlManager.import() } + } + private fun toggleFeaturedItemBlur(value: Boolean) { coroutineScope.launch { settingsRepository.toggleFeaturedItemBlur(value) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt index 3a7ef41a4..1c0faab64 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt @@ -17,18 +17,27 @@ package dev.sasikanth.rss.reader.settings import androidx.compose.runtime.Immutable import dev.sasikanth.rss.reader.app.AppInfo +import dev.sasikanth.rss.reader.opml.OpmlResult import dev.sasikanth.rss.reader.repository.BrowserType @Immutable internal data class SettingsState( val browserType: BrowserType, val enableHomePageBlur: Boolean, + val hasFeeds: Boolean, val appInfo: AppInfo, + val opmlResult: OpmlResult?, ) { companion object { fun default(appInfo: AppInfo) = - SettingsState(browserType = BrowserType.Default, enableHomePageBlur = true, appInfo = appInfo) + SettingsState( + browserType = BrowserType.Default, + enableHomePageBlur = true, + hasFeeds = false, + appInfo = appInfo, + opmlResult = null + ) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt index 63eb7a291..10d40d006 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt @@ -16,6 +16,7 @@ package dev.sasikanth.rss.reader.settings.ui 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.PaddingValues @@ -26,12 +27,14 @@ import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.Divider as MaterialDivider import androidx.compose.material3.Icon @@ -56,10 +59,13 @@ import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.sasikanth.rss.reader.app.AppInfo +import dev.sasikanth.rss.reader.components.OutlinedButton import dev.sasikanth.rss.reader.components.SubHeader import dev.sasikanth.rss.reader.components.image.AsyncImage +import dev.sasikanth.rss.reader.opml.OpmlResult import dev.sasikanth.rss.reader.platform.LocalLinkHandler import dev.sasikanth.rss.reader.repository.BrowserType import dev.sasikanth.rss.reader.resources.strings.LocalStrings @@ -133,7 +139,7 @@ internal fun SettingsScreen( } if (canBlurImage) { - item { InsetDivider() } + item { Divider(horizontalInsets = 24.dp) } item { FeaturedItemBlurSettingItem( @@ -145,13 +151,22 @@ internal fun SettingsScreen( } } + item { Divider(24.dp) } + item { - MaterialDivider( - modifier = Modifier.fillMaxWidth(), - color = AppTheme.colorScheme.surfaceContainer + OPMLSettingItem( + opmlResult = state.opmlResult, + hasFeeds = state.hasFeeds, + onImportClicked = { settingsPresenter.dispatch(SettingsEvent.ImportOpmlClicked) }, + onExportClicked = { settingsPresenter.dispatch(SettingsEvent.ExportOpmlClicked) }, + onCancelClicked = { + settingsPresenter.dispatch(SettingsEvent.CancelOpmlImportOrExport) + } ) } + item { Divider() } + item { SubHeader( text = LocalStrings.current.settingsHeaderFeedback, @@ -167,21 +182,11 @@ internal fun SettingsScreen( ) } - item { - MaterialDivider( - modifier = Modifier.fillMaxWidth(), - color = AppTheme.colorScheme.surfaceContainer - ) - } + item { Divider() } item { AboutItem { settingsPresenter.dispatch(SettingsEvent.AboutClicked) } } - item { - MaterialDivider( - modifier = Modifier.fillMaxWidth(), - color = AppTheme.colorScheme.surfaceContainer - ) - } + item { Divider() } } } }, @@ -307,6 +312,100 @@ private fun BrowserTypeSettingItem( } } +@Composable +private fun OPMLSettingItem( + opmlResult: OpmlResult?, + hasFeeds: Boolean, + onImportClicked: () -> Unit, + onExportClicked: () -> Unit, + onCancelClicked: () -> Unit +) { + Column { + SubHeader(text = LocalStrings.current.settingsHeaderOpml) + + when (opmlResult) { + is OpmlResult.InProgress.Importing, + is OpmlResult.InProgress.Exporting -> { + Row( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + // no-op + }, + enabled = false, + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = AppTheme.colorScheme.tintedSurface, + disabledContainerColor = AppTheme.colorScheme.tintedSurface, + contentColor = AppTheme.colorScheme.tintedForeground, + disabledContentColor = AppTheme.colorScheme.tintedForeground, + ), + border = null + ) { + val string = + when (opmlResult) { + is OpmlResult.InProgress.Importing -> { + LocalStrings.current.settingsOpmlImporting(opmlResult.progress) + } + is OpmlResult.InProgress.Exporting -> { + LocalStrings.current.settingsOpmlExporting(opmlResult.progress) + } + else -> { + "" + } + } + + Text(string) + } + + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onCancelClicked, + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = Color.Unspecified, + contentColor = AppTheme.colorScheme.tintedForeground, + ), + ) { + Text(LocalStrings.current.settingsOpmlCancel) + } + } + } + + // TODO: Handle error states + OpmlResult.Idle, + OpmlResult.Error.NoContentInOpmlFile, + is OpmlResult.Error.UnknownFailure, -> { + Row( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onImportClicked, + ) { + Text(LocalStrings.current.settingsOpmlImport) + } + + OutlinedButton( + modifier = Modifier.weight(1f), + enabled = hasFeeds, + onClick = onExportClicked, + ) { + Text(LocalStrings.current.settingsOpmlExport) + } + } + } + null -> { + Box(Modifier.requiredHeight(64.dp)) + } + } + } +} + @Composable private fun ReportIssueItem(appInfo: AppInfo, onClick: () -> Unit) { Box(modifier = Modifier.clickable(onClick = onClick)) { @@ -398,9 +497,9 @@ private fun AboutProfileImages() { } @Composable -private fun InsetDivider() { +private fun Divider(horizontalInsets: Dp = 0.dp) { MaterialDivider( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 24.dp), + modifier = Modifier.padding(vertical = 8.dp, horizontal = horizontalInsets), color = AppTheme.colorScheme.surfaceContainer ) } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/Constants.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/Constants.kt index 60accbb55..2dd02a0cf 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/Constants.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/Constants.kt @@ -17,6 +17,7 @@ package dev.sasikanth.rss.reader.utils internal object Constants { const val DATA_STORE_FILE_NAME = "twine.preferences_pb" + const val BACKUP_FILE_NAME = "twine_backup.xml" const val EPSILON = 1e-6f const val REPORT_ISSUE_LINK = "https://github.com/msasikanth/twine/issues" diff --git a/shared/src/commonMain/sqldelight/dev/sasikanth/rss/reader/database/Feed.sq b/shared/src/commonMain/sqldelight/dev/sasikanth/rss/reader/database/Feed.sq index fcb658058..96766d304 100644 --- a/shared/src/commonMain/sqldelight/dev/sasikanth/rss/reader/database/Feed.sq +++ b/shared/src/commonMain/sqldelight/dev/sasikanth/rss/reader/database/Feed.sq @@ -50,3 +50,6 @@ UPDATE feed SET pinnedAt = :pinnedAt WHERE link = :link; numberOfPinnedFeeds: SELECT COUNT(*) FROM feed WHERE pinnedAt IS NOT NULL; + +numberOfFeeds: +SELECT COUNT(*) FROM feed; diff --git a/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/opml/FeedsOpmlTest.kt b/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/opml/FeedsOpmlTest.kt new file mode 100644 index 000000000..3d8d0ecb0 --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/opml/FeedsOpmlTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.opml + +import dev.sasikanth.rss.reader.models.local.Feed +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.datetime.Instant + +class FeedsOpmlTest { + + private val feedsOpml = FeedsOpml() + + @Test + fun encodingFeedsToOpmlShouldWorkCorrectly() { + // given + val feeds = + listOf( + Feed( + name = "The Verge", + icon = "https://icon.horse/icon/theverge.com", + description = "The Verge", + homepageLink = "https://theverge.com", + createdAt = Instant.parse("2018-01-01T00:00:00Z"), + link = "https://www.theverge.com/rss/index.xml", + pinnedAt = null + ), + Feed( + name = "Hacker News", + icon = "https://icon.horse/icon/news.ycombinator.com", + description = "Hacker News", + homepageLink = "https://news.ycombinator.com", + createdAt = Instant.parse("2018-01-01T00:00:00Z"), + link = "https://news.ycombinator.com/rss", + pinnedAt = null + ), + ) + + // when + val opmlXml = feedsOpml.encode(feeds) + + // then + val expected = + """ + + + + Twine RSS Feeds + + + + + + + + """ + .trimIndent() + + assertEquals(expected, opmlXml) + } + + @Test + fun decodingOpmlXmlToOpmlFeedsShouldWorkCorrectly() { + // given + val xml = + """ + + + + Twine RSS Feeds + + + + + + + + + + + """ + .trimIndent() + + // when + val opmlFeeds = feedsOpml.decode(xml) + + // then + val expected = + listOf( + OpmlFeed(title = "The Verge", link = "https://www.theverge.com/rss/index.xml"), + OpmlFeed(title = "Hacker News", link = "https://news.ycombinator.com/rss"), + OpmlFeed( + title = "NYT", + link = + "https://www.nytimes.com/svc/collections/v1/publish/https://www.nytimes.com/section/world/rss.xml" + ), + ) + + assertEquals(expected, opmlFeeds) + } +} diff --git a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ApplicationComponent.kt b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ApplicationComponent.kt index a8f655150..c3d5e87fd 100644 --- a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ApplicationComponent.kt +++ b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ApplicationComponent.kt @@ -17,6 +17,7 @@ package dev.sasikanth.rss.reader.di import dev.sasikanth.rss.reader.app.AppInfo import dev.sasikanth.rss.reader.di.scopes.AppScope +import dev.sasikanth.rss.reader.filemanager.FileManager import dev.sasikanth.rss.reader.repository.RssRepository import me.tatarka.inject.annotations.Component import me.tatarka.inject.annotations.Provides @@ -29,6 +30,8 @@ abstract class ApplicationComponent( @get:Provides val uiViewControllerProvider: () -> UIViewController, ) : SharedApplicationComponent() { + abstract val fileManager: FileManager + abstract val rssRepository: RssRepository @Provides diff --git a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.kt b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.kt new file mode 100644 index 000000000..98e934317 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.filemanager + +import me.tatarka.inject.annotations.Provides + +actual interface FileManagerComponent { + + @Provides fun IOSFileManager.bind(): FileManager = this +} diff --git a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/filemanager/IOSFileManager.kt b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/filemanager/IOSFileManager.kt new file mode 100644 index 000000000..9864a5e80 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/filemanager/IOSFileManager.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.filemanager + +import dev.sasikanth.rss.reader.di.scopes.AppScope +import dev.sasikanth.rss.reader.utils.DispatchersProvider +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import me.tatarka.inject.annotations.Inject +import platform.Foundation.NSString +import platform.Foundation.NSURL +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.stringWithContentsOfFile +import platform.Foundation.writeToFile +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIModalPresentationPageSheet +import platform.UIKit.UIViewController +import platform.UniformTypeIdentifiers.UTType +import platform.UniformTypeIdentifiers.UTTypeFolder +import platform.UniformTypeIdentifiers.UTTypeXML +import platform.darwin.NSObject + +@Inject +@AppScope +@OptIn(ExperimentalForeignApi::class) +class IOSFileManager( + private val dispatchersProvider: DispatchersProvider, + private val viewControllerProvider: () -> UIViewController +) : FileManager { + + private val mainScope = CoroutineScope(dispatchersProvider.main) + + @Suppress("CAST_NEVER_SUCCEEDS") + override suspend fun save(name: String, content: String) { + suspendCoroutine { continuation -> + if (content.isNotBlank()) { + val delegate = DocumentPickerDelegate { url -> + (content as NSString).writeToFile( + path = "${url.path}/$name", + atomically = true, + encoding = NSUTF8StringEncoding, + error = null + ) + + continuation.resume(Unit) + } + + presentDocumentPicker(type = UTTypeFolder, delegate = delegate) + } + } + } + + override suspend fun read(): String? { + return suspendCoroutine { continuation -> + val delegate = DocumentPickerDelegate { url -> + val content = + NSString.stringWithContentsOfFile( + path = url.path!!, + encoding = NSUTF8StringEncoding, + error = null + ) + + continuation.resume(content) + } + presentDocumentPicker(type = UTTypeXML, delegate = delegate) + } + } + + private fun presentDocumentPicker(type: UTType, delegate: UIDocumentPickerDelegateProtocol) { + mainScope.launch { + val documentPickerViewController = + UIDocumentPickerViewController(forOpeningContentTypes = listOf(type)) + documentPickerViewController.delegate = delegate + documentPickerViewController.allowsMultipleSelection = false + documentPickerViewController.modalPresentationStyle = UIModalPresentationPageSheet + + viewControllerProvider().presentViewController(documentPickerViewController, true, null) + } + } +} + +private class DocumentPickerDelegate(private val didPickDocument: (url: NSURL) -> Unit) : + NSObject(), UIDocumentPickerDelegateProtocol { + + override fun documentPicker( + controller: UIDocumentPickerViewController, + didPickDocumentAtURL: NSURL + ) { + didPickDocument(didPickDocumentAtURL) + } +} diff --git a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/network/NetworkComponent.kt b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/network/NetworkComponent.kt index 7112b244f..123bd5add 100644 --- a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/network/NetworkComponent.kt +++ b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/network/NetworkComponent.kt @@ -28,13 +28,6 @@ internal actual interface NetworkComponent { @Provides @AppScope fun providesHttpClient(): HttpClient { - return HttpClient(Darwin) { - engine { - configureRequest { - setTimeoutInterval(60.0) - setAllowsCellularAccess(true) - } - } - } + return HttpClient(Darwin) { engine { configureRequest { setAllowsCellularAccess(true) } } } } }