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