Skip to content

Commit

Permalink
Add support for importing and exporting OPML (#121)
Browse files Browse the repository at this point in the history
* Add platform specific file managers

* Add outlined button component

* Add query to get number of feeds

* Check if app has feeds when settings screen is initialised

* Add xmlutil library for xml serialization

* Implement `FeedsOpml`

* Add support for passing title when adding feed in the repository

* Add function to add OPML feeds in `RssRepository`

* Add function to fetch all feeds in blocking manner

* Capture exceptions when encoding and decoding feeds opml

* Add OPML manager for managing OPML import and export

* Make border nullable in `OutlineButton` component

* Add OPML section to settings screen

* Fix feed fetching stuck in a loop when fetching link from the HTML

* Ignore redirects if the feed link didn't change from original

* Remove custom network timeouts

The custom timeouts are a bit long when importing hundreds of feeds, so removed those and using default values

* Move `addOpmlFeeds` function from repository to OPML manager

There is no need for our manager to know about OPML feeds. Since it's just logic of looping over OPML feeds, moved it to the manager.

* Add space between progress text and progress value

* Launch iOS document picker from main thread

* Reverse OPML feeds list when importing

Since the app orders based on when the feed is created, we are reversing the OPML list in order to try and preserve the order as much as we can. It might still get affected, because we are doing parallel processing (since we are doing joinAll it should be fine, but not entirely sure).
  • Loading branch information
msasikanth authored Oct 29, 2023
1 parent 86ac5d3 commit 2ae8f8d
Show file tree
Hide file tree
Showing 29 changed files with 1,029 additions and 47 deletions.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down Expand Up @@ -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" }
Expand All @@ -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" }
Expand All @@ -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" ]
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.buildKonfig)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlinx.serialization)
}

buildkonfig {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String?>()

private lateinit var createDocumentLauncher: ActivityResultLauncher<String>
private lateinit var openDocumentLauncher: ActivityResultLauncher<Array<String>>

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
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) } } }
}
}
Original file line number Diff line number Diff line change
@@ -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
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
Loading

0 comments on commit 2ae8f8d

Please sign in to comment.