From 0978eaa6280995bc02816da7d63e910d1165b797 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Fri, 20 Oct 2023 06:53:27 +0530 Subject: [PATCH 01/20] Add platform specific file managers --- .../reader/filemanager/AndroidFileManager.kt | 120 ++++++++++++++++++ .../AndroidFileManagerInitialiser.kt | 30 +++++ .../filemanager/FileManagerComponent.kt | 32 +++++ .../reader/di/SharedApplicationComponent.kt | 8 +- .../rss/reader/filemanager/FileManager.kt | 23 ++++ .../filemanager/FileManagerComponent.kt | 19 +++ .../sasikanth/rss/reader/utils/Constants.kt | 1 + .../rss/reader/di/ApplicationComponent.kt | 3 + .../filemanager/FileManagerComponent.kt | 24 ++++ .../rss/reader/filemanager/IOSFileManager.kt | 99 +++++++++++++++ 10 files changed, 358 insertions(+), 1 deletion(-) 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/filemanager/FileManager.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/filemanager/FileManagerComponent.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/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/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/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/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..e94c6f4b2 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/filemanager/IOSFileManager.kt @@ -0,0 +1,99 @@ +/* + * 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 kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.cinterop.ExperimentalForeignApi +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 viewControllerProvider: () -> UIViewController) : FileManager { + + @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) { + 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) + } +} From 93d8788a80edadd4b1db9f296c22f27d52ba079b Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Mon, 9 Oct 2023 09:11:50 +0530 Subject: [PATCH 02/20] Add outlined button component --- .../rss/reader/components/OutlinedButton.kt | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/OutlinedButton.kt 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..b5404431f --- /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 + ) +} From dc16981afe2fd13394ba94843562c3e0ff40edd0 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Fri, 20 Oct 2023 07:17:33 +0530 Subject: [PATCH 03/20] Add query to get number of feeds --- .../dev/sasikanth/rss/reader/repository/RssRepository.kt | 4 ++++ .../sqldelight/dev/sasikanth/rss/reader/database/Feed.sq | 3 +++ 2 files changed, 7 insertions(+) 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..5b8be3be9 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 @@ -269,6 +269,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/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; From ec9d8afcb125b91f82c3ead3a9e1263da8c5f5e2 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Fri, 20 Oct 2023 07:32:52 +0530 Subject: [PATCH 04/20] Check if app has feeds when settings screen is initialised --- .../rss/reader/settings/SettingsPresenter.kt | 25 +++++++++++++------ .../rss/reader/settings/SettingsState.kt | 8 +++++- 2 files changed, 25 insertions(+), 8 deletions(-) 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..1d3f23e85 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 @@ -20,6 +20,7 @@ import com.arkivanov.essenty.instancekeeper.InstanceKeeper import com.arkivanov.essenty.instancekeeper.getOrCreate import dev.sasikanth.rss.reader.app.AppInfo 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,6 +42,7 @@ import me.tatarka.inject.annotations.Inject class SettingsPresenter( dispatchersProvider: DispatchersProvider, private val settingsRepository: SettingsRepository, + private val rssRepository: RssRepository, private val appInfo: AppInfo, @Assisted componentContext: ComponentContext, @Assisted private val goBack: () -> Unit, @@ -52,7 +54,8 @@ class SettingsPresenter( PresenterInstance( dispatchersProvider = dispatchersProvider, appInfo = appInfo, - settingsRepository = settingsRepository + settingsRepository = settingsRepository, + rssRepository = rssRepository ) } @@ -73,6 +76,7 @@ class SettingsPresenter( private class PresenterInstance( dispatchersProvider: DispatchersProvider, appInfo: AppInfo, + rssRepository: RssRepository, private val settingsRepository: SettingsRepository, ) : InstanceKeeper.Instance { @@ -87,14 +91,21 @@ 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) 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..bf9ae8865 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 @@ -23,12 +23,18 @@ import dev.sasikanth.rss.reader.repository.BrowserType internal data class SettingsState( val browserType: BrowserType, val enableHomePageBlur: Boolean, + val hasFeeds: Boolean, val appInfo: AppInfo, ) { companion object { fun default(appInfo: AppInfo) = - SettingsState(browserType = BrowserType.Default, enableHomePageBlur = true, appInfo = appInfo) + SettingsState( + browserType = BrowserType.Default, + enableHomePageBlur = true, + hasFeeds = false, + appInfo = appInfo + ) } } From aefab2310053977dcf76363f3ae195261f2a6e49 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Fri, 20 Oct 2023 10:00:48 +0530 Subject: [PATCH 05/20] Add xmlutil library for xml serialization --- build.gradle.kts | 1 + gradle/libs.versions.toml | 6 ++++++ shared/build.gradle.kts | 2 ++ 3 files changed, 9 insertions(+) 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/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 { From 443ce60a1594d83fabb8f82bd738a5869a5a8bc3 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Fri, 20 Oct 2023 10:21:33 +0530 Subject: [PATCH 06/20] Implement `FeedsOpml` --- .../sasikanth/rss/reader/opml/FeedsOpml.kt | 77 ++++++++++++ .../dev/sasikanth/rss/reader/opml/Opml.kt | 31 +++++ .../rss/reader/opml/FeedsOpmlTest.kt | 115 ++++++++++++++++++ 3 files changed, 223 insertions(+) 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/commonTest/kotlin/dev/sasikanth/rss/reader/opml/FeedsOpmlTest.kt 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..6e81168a5 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/FeedsOpml.kt @@ -0,0 +1,77 @@ +/* + * 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 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 { + val opml = + Opml( + version = "2.0", + head = Head("Twine RSS Feeds"), + body = Body(outlines = feeds.map(::mapFeedToOutline)) + ) + + val xmlString = xml.encodeToString(serializer(), opml) + + return StringBuilder(xmlString) + .insert(0, "\n") + .appendLine() + .toString() + } + + fun decode(content: String): List { + 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) } + + return opmlFeeds.distinctBy { it.link } + } + + 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/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) + } +} From aa458eb812717fa9b5b342e40929bdeb28ee4eae Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Fri, 20 Oct 2023 15:22:26 +0530 Subject: [PATCH 07/20] Add support for passing title when adding feed in the repository --- .../dev/sasikanth/rss/reader/repository/RssRepository.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 5b8be3be9..a7f4cc4e7 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, From e7f5eec33af4efdea9c6bf35a72f1f09173e6243 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 28 Oct 2023 14:54:25 +0530 Subject: [PATCH 08/20] Add function to add OPML feeds in `RssRepository` --- .../rss/reader/repository/RssRepository.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 a7f4cc4e7..078c12bac 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 @@ -20,6 +20,7 @@ import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToOne import app.cash.sqldelight.paging3.QueryPagingSource +import co.touchlab.stately.concurrency.AtomicInt import dev.sasikanth.rss.reader.database.BookmarkQueries import dev.sasikanth.rss.reader.database.FeedQueries import dev.sasikanth.rss.reader.database.FeedSearchFTSQueries @@ -30,9 +31,12 @@ import dev.sasikanth.rss.reader.models.local.Feed import dev.sasikanth.rss.reader.models.local.PostWithMetadata import dev.sasikanth.rss.reader.network.FeedFetchResult import dev.sasikanth.rss.reader.network.FeedFetcher +import dev.sasikanth.rss.reader.opml.OpmlFeed import dev.sasikanth.rss.reader.search.SearchSortOrder import dev.sasikanth.rss.reader.utils.DispatchersProvider +import kotlin.math.roundToInt import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -116,6 +120,22 @@ class RssRepository( } } + fun addOpmlFeeds(feedLinks: List): Flow = channelFlow { + val totalFeedCount = feedLinks.size + val processedFeedsCount = AtomicInt(0) + + feedLinks.chunked(UPDATE_CHUNKS).forEach { feedsGroup -> + feedsGroup + .map { feed -> launch { 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()) + } + } + suspend fun updateFeeds() { val results = withContext(ioDispatcher) { From 2d6bf5dac02ab5c716233295b2951366033c3841 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 28 Oct 2023 14:53:48 +0530 Subject: [PATCH 09/20] Add function to fetch all feeds in blocking manner --- .../dev/sasikanth/rss/reader/repository/RssRepository.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 078c12bac..3b0c94db3 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 @@ -197,6 +197,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( From c29882f3797e1781a331b86f97451098b0c4682d Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 28 Oct 2023 12:03:03 +0530 Subject: [PATCH 10/20] Capture exceptions when encoding and decoding feeds opml --- .../sasikanth/rss/reader/opml/FeedsOpml.kt | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) 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 index 6e81168a5..1bc2415d1 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/FeedsOpml.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/FeedsOpml.kt @@ -17,6 +17,7 @@ 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 @@ -35,36 +36,46 @@ class FeedsOpml { } fun encode(feeds: List): String { - val opml = - Opml( - version = "2.0", - head = Head("Twine RSS Feeds"), - body = Body(outlines = feeds.map(::mapFeedToOutline)) - ) + 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) + val xmlString = xml.encodeToString(serializer(), opml) - return StringBuilder(xmlString) - .insert(0, "\n") - .appendLine() - .toString() + StringBuilder(xmlString) + .insert(0, "\n") + .appendLine() + .toString() + } catch (e: Exception) { + Sentry.captureException(e) + "" + } } fun decode(content: String): List { - val opml = xml.decodeFromString(serializer(), content) - val opmlFeeds = mutableListOf() + 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)) - } + fun flatten(outline: Outline) { + if (outline.outlines.isNullOrEmpty() && !outline.xmlUrl.isNullOrBlank()) { + opmlFeeds.add(mapOutlineToOpmlFeed(outline)) + } - outline.outlines?.forEach { nestedOutline -> flatten(nestedOutline) } - } + outline.outlines?.forEach { nestedOutline -> flatten(nestedOutline) } + } - opml.body.outlines.forEach { outline -> flatten(outline) } + opml.body.outlines.forEach { outline -> flatten(outline) } - return opmlFeeds.distinctBy { it.link } + opmlFeeds.distinctBy { it.link } + } catch (e: Exception) { + Sentry.captureException(e) + emptyList() + } } private fun mapFeedToOutline(feed: Feed) = From 9dbef94cee2e48ed069180ebccaebb4b6b11814a Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 28 Oct 2023 14:32:45 +0530 Subject: [PATCH 11/20] Add OPML manager for managing OPML import and export --- .../sasikanth/rss/reader/opml/OpmlManager.kt | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/OpmlManager.kt 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..b690c0b5e --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/OpmlManager.kt @@ -0,0 +1,132 @@ +/* + * 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.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 kotlinx.coroutines.CancellationException +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +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 + + 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) + + rssRepository + .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) + } +} + +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 + } +} From 8ca11e1957afc0c9bbe4fa369b6993fe60eb879d Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 28 Oct 2023 14:05:25 +0530 Subject: [PATCH 12/20] Make border nullable in `OutlineButton` component --- .../dev/sasikanth/rss/reader/components/OutlinedButton.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index b5404431f..e70539612 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/OutlinedButton.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/OutlinedButton.kt @@ -36,7 +36,7 @@ fun OutlinedButton( containerColor = AppTheme.colorScheme.surfaceContainerLow, contentColor = AppTheme.colorScheme.tintedForeground ), - border: BorderStroke = BorderStroke(1.dp, AppTheme.colorScheme.surfaceContainerHigh), + border: BorderStroke? = BorderStroke(1.dp, AppTheme.colorScheme.surfaceContainerHigh), content: @Composable RowScope.() -> Unit ) { androidx.compose.material3.OutlinedButton( From 2d20ac2d6b97d455f08996113775b8e402785e5d Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 28 Oct 2023 14:47:29 +0530 Subject: [PATCH 13/20] Add OPML section to settings screen --- .../resources/strings/EnTwineStrings.kt | 6 + .../reader/resources/strings/TwineStrings.kt | 6 + .../rss/reader/settings/SettingsEvent.kt | 6 + .../rss/reader/settings/SettingsPresenter.kt | 30 +++- .../rss/reader/settings/SettingsState.kt | 5 +- .../rss/reader/settings/ui/SettingsScreen.kt | 135 +++++++++++++++--- 6 files changed, 167 insertions(+), 21 deletions(-) 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..5823b4056 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/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 1d3f23e85..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,6 +19,7 @@ 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 @@ -44,6 +45,7 @@ class SettingsPresenter( 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, @@ -55,7 +57,8 @@ class SettingsPresenter( dispatchersProvider = dispatchersProvider, appInfo = appInfo, settingsRepository = settingsRepository, - rssRepository = rssRepository + rssRepository = rssRepository, + opmlManager = opmlManager, ) } @@ -76,8 +79,9 @@ class SettingsPresenter( private class PresenterInstance( dispatchersProvider: DispatchersProvider, appInfo: AppInfo, - rssRepository: RssRepository, + private val rssRepository: RssRepository, private val settingsRepository: SettingsRepository, + private val opmlManager: OpmlManager, ) : InstanceKeeper.Instance { private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchersProvider.main) @@ -109,6 +113,13 @@ class SettingsPresenter( } } .launchIn(coroutineScope) + + opmlManager.result + .onEach { result -> + println(result) + _state.update { it.copy(opmlResult = result) } + } + .launchIn(coroutineScope) } fun dispatch(event: SettingsEvent) { @@ -121,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 bf9ae8865..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,6 +17,7 @@ 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 @@ -25,6 +26,7 @@ internal data class SettingsState( val enableHomePageBlur: Boolean, val hasFeeds: Boolean, val appInfo: AppInfo, + val opmlResult: OpmlResult?, ) { companion object { @@ -34,7 +36,8 @@ internal data class SettingsState( browserType = BrowserType.Default, enableHomePageBlur = true, hasFeeds = false, - appInfo = appInfo + 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 ) } From cacbd4e649a0adbf7e2e1b8fc0df3ac3315350de Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 28 Oct 2023 16:11:26 +0530 Subject: [PATCH 14/20] Fix feed fetching stuck in a loop when fetching link from the HTML --- .../kotlin/dev/sasikanth/rss/reader/network/FeedFetcher.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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..a3fccb533 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 @@ -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.isNullOrBlank() && redirectCount < MAX_REDIRECTS_ALLOWED) { + redirectCount += 1 fetch(url = feedUrl, fetchPosts = fetchPosts) } else { if (e is XmlParsingError) { From 459362a0065ca0b7fcb4a81229662d0c20c377b7 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 28 Oct 2023 16:12:52 +0530 Subject: [PATCH 15/20] Ignore redirects if the feed link didn't change from original --- .../kotlin/dev/sasikanth/rss/reader/network/FeedFetcher.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a3fccb533..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 { @@ -119,7 +119,7 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe is HtmlContentException, is XmlParsingError -> { val feedUrl = fetchFeedLinkFromHtmlIfExists(responseContent, url) - if (!feedUrl.isNullOrBlank() && redirectCount < MAX_REDIRECTS_ALLOWED) { + if (feedUrl != url && !feedUrl.isNullOrBlank() && redirectCount < MAX_REDIRECTS_ALLOWED) { redirectCount += 1 fetch(url = feedUrl, fetchPosts = fetchPosts) } else { From 73818854f34262c7e26b00db4df6ce0a15de6648 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 28 Oct 2023 16:23:06 +0530 Subject: [PATCH 16/20] Remove custom network timeouts The custom timeouts are a bit long when importing hundreds of feeds, so removed those and using default values --- .../sasikanth/rss/reader/network/NetworkComponent.kt | 10 +--------- .../sasikanth/rss/reader/network/NetworkComponent.kt | 9 +-------- 2 files changed, 2 insertions(+), 17 deletions(-) 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/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) } } } } } From 2882a6b6c7faffdd210e58ae81551d2427fb4658 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 28 Oct 2023 18:06:04 +0530 Subject: [PATCH 17/20] 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. --- .../sasikanth/rss/reader/opml/OpmlManager.kt | 29 +++++++++++++++++-- .../rss/reader/repository/RssRepository.kt | 20 ------------- 2 files changed, 27 insertions(+), 22 deletions(-) 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 index b690c0b5e..32122f466 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/OpmlManager.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/OpmlManager.kt @@ -16,19 +16,25 @@ 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 @@ -46,6 +52,10 @@ class OpmlManager( private val _result = MutableSharedFlow(replay = 1) val result: SharedFlow = _result + companion object { + private const val IMPORT_CHUNKS = 20 + } + init { _result.tryEmit(OpmlResult.Idle) } @@ -59,8 +69,7 @@ class OpmlManager( _result.emit(OpmlResult.InProgress.Importing(0)) val opmlFeeds = feedsOpml.decode(opmlXmlContent) - rssRepository - .addOpmlFeeds(opmlFeeds) + addOpmlFeeds(opmlFeeds) .onEach { progress -> _result.emit(OpmlResult.InProgress.Importing(progress)) } .onCompletion { _result.emit(OpmlResult.Idle) } .collect() @@ -113,6 +122,22 @@ class OpmlManager( job.cancelChildren() _result.tryEmit(OpmlResult.Idle) } + + private fun addOpmlFeeds(feedLinks: List): Flow = channelFlow { + val totalFeedCount = feedLinks.size + val processedFeedsCount = AtomicInt(0) + + feedLinks.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 { 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 3b0c94db3..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 @@ -20,7 +20,6 @@ import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToOne import app.cash.sqldelight.paging3.QueryPagingSource -import co.touchlab.stately.concurrency.AtomicInt import dev.sasikanth.rss.reader.database.BookmarkQueries import dev.sasikanth.rss.reader.database.FeedQueries import dev.sasikanth.rss.reader.database.FeedSearchFTSQueries @@ -31,12 +30,9 @@ import dev.sasikanth.rss.reader.models.local.Feed import dev.sasikanth.rss.reader.models.local.PostWithMetadata import dev.sasikanth.rss.reader.network.FeedFetchResult import dev.sasikanth.rss.reader.network.FeedFetcher -import dev.sasikanth.rss.reader.opml.OpmlFeed import dev.sasikanth.rss.reader.search.SearchSortOrder import dev.sasikanth.rss.reader.utils.DispatchersProvider -import kotlin.math.roundToInt import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -120,22 +116,6 @@ class RssRepository( } } - fun addOpmlFeeds(feedLinks: List): Flow = channelFlow { - val totalFeedCount = feedLinks.size - val processedFeedsCount = AtomicInt(0) - - feedLinks.chunked(UPDATE_CHUNKS).forEach { feedsGroup -> - feedsGroup - .map { feed -> launch { 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()) - } - } - suspend fun updateFeeds() { val results = withContext(ioDispatcher) { From c2ccc43e21b5410d32c36de2a17496d8189669aa Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 28 Oct 2023 18:06:21 +0530 Subject: [PATCH 18/20] Add space between progress text and progress value --- .../sasikanth/rss/reader/resources/strings/EnTwineStrings.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5823b4056..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 @@ -74,8 +74,8 @@ val EnTwineStrings = settingsAboutSubtitle = "Get to know the authors", settingsOpmlImport = "Import", settingsOpmlExport = "Export", - settingsOpmlImporting = { progress -> "Importing..$progress%" }, - settingsOpmlExporting = { progress -> "Exporting..$progress%" }, + settingsOpmlImporting = { progress -> "Importing.. $progress%" }, + settingsOpmlExporting = { progress -> "Exporting.. $progress%" }, settingsOpmlCancel = "Cancel", feeds = "Feeds", editFeeds = "Edit feeds", From e40c7e619194525d21094ce60895766797d59bdb Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 28 Oct 2023 18:27:07 +0530 Subject: [PATCH 19/20] Launch iOS document picker from main thread --- .../rss/reader/filemanager/IOSFileManager.kt | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) 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 index e94c6f4b2..9864a5e80 100644 --- a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/filemanager/IOSFileManager.kt +++ b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/filemanager/IOSFileManager.kt @@ -17,9 +17,12 @@ 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 @@ -38,7 +41,12 @@ import platform.darwin.NSObject @Inject @AppScope @OptIn(ExperimentalForeignApi::class) -class IOSFileManager(private val viewControllerProvider: () -> UIViewController) : FileManager { +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) { @@ -77,13 +85,15 @@ class IOSFileManager(private val viewControllerProvider: () -> UIViewController) } private fun presentDocumentPicker(type: UTType, delegate: UIDocumentPickerDelegateProtocol) { - val documentPickerViewController = - UIDocumentPickerViewController(forOpeningContentTypes = listOf(type)) - documentPickerViewController.delegate = delegate - documentPickerViewController.allowsMultipleSelection = false - documentPickerViewController.modalPresentationStyle = UIModalPresentationPageSheet + mainScope.launch { + val documentPickerViewController = + UIDocumentPickerViewController(forOpeningContentTypes = listOf(type)) + documentPickerViewController.delegate = delegate + documentPickerViewController.allowsMultipleSelection = false + documentPickerViewController.modalPresentationStyle = UIModalPresentationPageSheet - viewControllerProvider().presentViewController(documentPickerViewController, true, null) + viewControllerProvider().presentViewController(documentPickerViewController, true, null) + } } } From 07030bcfee94820fcdb957a492c0f8cedde87461 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sun, 29 Oct 2023 06:33:18 +0530 Subject: [PATCH 20/20] 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). --- .../kotlin/dev/sasikanth/rss/reader/opml/OpmlManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 32122f466..bab0d827d 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/OpmlManager.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/opml/OpmlManager.kt @@ -127,7 +127,7 @@ class OpmlManager( val totalFeedCount = feedLinks.size val processedFeedsCount = AtomicInt(0) - feedLinks.chunked(IMPORT_CHUNKS).forEach { feedsGroup -> + feedLinks.reversed().chunked(IMPORT_CHUNKS).forEach { feedsGroup -> feedsGroup .map { feed -> launch { rssRepository.addFeed(feedLink = feed.link, title = feed.title) } } .joinAll()