Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for importing and exporting OPML #121

Merged
merged 20 commits into from
Oct 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0978eaa
Add platform specific file managers
msasikanth Oct 20, 2023
93d8788
Add outlined button component
msasikanth Oct 9, 2023
dc16981
Add query to get number of feeds
msasikanth Oct 20, 2023
ec9d8af
Check if app has feeds when settings screen is initialised
msasikanth Oct 20, 2023
aefab23
Add xmlutil library for xml serialization
msasikanth Oct 20, 2023
443ce60
Implement `FeedsOpml`
msasikanth Oct 20, 2023
aa458eb
Add support for passing title when adding feed in the repository
msasikanth Oct 20, 2023
e7f5eec
Add function to add OPML feeds in `RssRepository`
msasikanth Oct 28, 2023
2d6bf5d
Add function to fetch all feeds in blocking manner
msasikanth Oct 28, 2023
c29882f
Capture exceptions when encoding and decoding feeds opml
msasikanth Oct 28, 2023
9dbef94
Add OPML manager for managing OPML import and export
msasikanth Oct 28, 2023
8ca11e1
Make border nullable in `OutlineButton` component
msasikanth Oct 28, 2023
2d20ac2
Add OPML section to settings screen
msasikanth Oct 28, 2023
cacbd4e
Fix feed fetching stuck in a loop when fetching link from the HTML
msasikanth Oct 28, 2023
459362a
Ignore redirects if the feed link didn't change from original
msasikanth Oct 28, 2023
7381885
Remove custom network timeouts
msasikanth Oct 28, 2023
2882a6b
Move `addOpmlFeeds` function from repository to OPML manager
msasikanth Oct 28, 2023
c2ccc43
Add space between progress text and progress value
msasikanth Oct 28, 2023
e40c7e6
Launch iOS document picker from main thread
msasikanth Oct 28, 2023
07030bc
Reverse OPML feeds list when importing
msasikanth Oct 29, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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