diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d1bf5e8..c92ee3f8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -142,6 +142,7 @@ dependencies { implementation(projects.core.network) implementation(projects.core.data) implementation(projects.core.sync) + implementation(projects.core.opml) implementation(platform(libs.compose.bom)) implementation(libs.compose.htmlconverter) implementation(libs.kmpalette.core) diff --git a/app/src/main/kotlin/com/mr3y/podcaster/PodcasterApplication.kt b/app/src/main/kotlin/com/mr3y/podcaster/PodcasterApplication.kt index 6168372f..fa31a060 100644 --- a/app/src/main/kotlin/com/mr3y/podcaster/PodcasterApplication.kt +++ b/app/src/main/kotlin/com/mr3y/podcaster/PodcasterApplication.kt @@ -3,6 +3,7 @@ package com.mr3y.podcaster import android.app.Application import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration +import com.mr3y.podcaster.core.opml.FileManager import com.mr3y.podcaster.core.sync.initializeWorkManagerInstance import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @@ -13,9 +14,13 @@ class PodcasterApplication : Application(), Configuration.Provider { @Inject lateinit var workerFactory: HiltWorkerFactory + @Inject + lateinit var fileManager: FileManager + override fun onCreate() { super.onCreate() initializeWorkManagerInstance(this) + fileManager.registerActivityWatcher() } override val workManagerConfiguration: Configuration diff --git a/app/src/main/kotlin/com/mr3y/podcaster/ui/navigation/Destinations.kt b/app/src/main/kotlin/com/mr3y/podcaster/ui/navigation/Destinations.kt index ec683602..4009c0d9 100644 --- a/app/src/main/kotlin/com/mr3y/podcaster/ui/navigation/Destinations.kt +++ b/app/src/main/kotlin/com/mr3y/podcaster/ui/navigation/Destinations.kt @@ -25,4 +25,7 @@ sealed interface Destinations : Destination { @Serializable data object Licenses : Destinations + + @Serializable + data object ImportExport : Destinations } diff --git a/app/src/main/kotlin/com/mr3y/podcaster/ui/navigation/NavGraph.kt b/app/src/main/kotlin/com/mr3y/podcaster/ui/navigation/NavGraph.kt index 1f253c55..3242cbc8 100644 --- a/app/src/main/kotlin/com/mr3y/podcaster/ui/navigation/NavGraph.kt +++ b/app/src/main/kotlin/com/mr3y/podcaster/ui/navigation/NavGraph.kt @@ -15,6 +15,7 @@ import com.mr3y.podcaster.ui.presenter.UserPreferences import com.mr3y.podcaster.ui.screens.DownloadsScreen import com.mr3y.podcaster.ui.screens.EpisodeDetailsScreen import com.mr3y.podcaster.ui.screens.ExploreScreen +import com.mr3y.podcaster.ui.screens.ImportExportScreen import com.mr3y.podcaster.ui.screens.LicensesScreen import com.mr3y.podcaster.ui.screens.PodcastDetailsScreen import com.mr3y.podcaster.ui.screens.SettingsScreen @@ -97,6 +98,13 @@ fun PodcasterNavGraph( excludedWindowInsets = excludedWindowInsets, ) } + composable { + ImportExportScreen( + onNavDrawerClick = onNavDrawerClick, + contentPadding = contentPadding, + excludedWindowInsets = excludedWindowInsets, + ) + } } } diff --git a/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/opml/OpmlViewModel.kt b/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/opml/OpmlViewModel.kt new file mode 100644 index 00000000..d6507840 --- /dev/null +++ b/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/opml/OpmlViewModel.kt @@ -0,0 +1,34 @@ +package com.mr3y.podcaster.ui.presenter.opml + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mr3y.podcaster.core.opml.OpmlManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OpmlViewModel @Inject constructor( + private val opmlManager: OpmlManager, +) : ViewModel() { + + val result = opmlManager.result + + fun import() { + viewModelScope.launch { + opmlManager.cancelCurrentRunningTask() + opmlManager.import() + } + } + + fun export() { + viewModelScope.launch { + opmlManager.cancelCurrentRunningTask() + opmlManager.export() + } + } + + fun consumeResult() { + viewModelScope.launch { opmlManager.resetResultState() } + } +} diff --git a/app/src/main/kotlin/com/mr3y/podcaster/ui/resources/PodcasterEnStrings.kt b/app/src/main/kotlin/com/mr3y/podcaster/ui/resources/PodcasterEnStrings.kt index 6e6d3840..6af2bcf1 100644 --- a/app/src/main/kotlin/com/mr3y/podcaster/ui/resources/PodcasterEnStrings.kt +++ b/app/src/main/kotlin/com/mr3y/podcaster/ui/resources/PodcasterEnStrings.kt @@ -19,7 +19,7 @@ val EnStrings = PodcasterStrings( icon_theme_content_description = "Toggle App Theme", subscriptions_label = "Subscriptions", subscriptions_empty_list = "You aren't subscribed to any podcast.\nYour subscriptions will show up here.", - subscriptions_episodes_empty_list = "Start subscribing podcasts by clicking on ☰ icon -> then Explore.", + subscriptions_episodes_empty_list = "Start subscribing podcasts by\n • Clicking on ☰ icon -> then Explore\n • Or import your existing subscriptions collection from ☰ icon -> then Import/Export.", generic_error_message = "Sorry, Something went wrong", retry_label = "Retry", currently_playing = "Currently Playing", @@ -62,4 +62,13 @@ val EnStrings = PodcasterStrings( append("PodcastIndex.org") } }, + import_export_label = "Import/Export Opml", + import_label = "Import", + export_label = "Export", + import_notice = "If you're importing subscriptions collection make sure you're connected to the internet or the importing will fail", + import_succeeded = "Importing subscriptions collection completed successfully!", + import_network_error = "Importing collection failed! make sure you're connected to the internet.", + import_empty_file_error = "Empty file! nothing to import", + import_corrupted_file_error = "File may be corrupted! process failed.", + import_unknown_error = "Sorry, Something went wrong", ) diff --git a/app/src/main/kotlin/com/mr3y/podcaster/ui/resources/PodcasterStrings.kt b/app/src/main/kotlin/com/mr3y/podcaster/ui/resources/PodcasterStrings.kt index 18f72269..989b7d5f 100644 --- a/app/src/main/kotlin/com/mr3y/podcaster/ui/resources/PodcasterStrings.kt +++ b/app/src/main/kotlin/com/mr3y/podcaster/ui/resources/PodcasterStrings.kt @@ -52,4 +52,13 @@ data class PodcasterStrings( val feedback_and_issues_label: String, val privacy_policy_label: String, val powered_by_label: AnnotatedString, + val import_export_label: String, + val import_label: String, + val export_label: String, + val import_notice: String, + val import_succeeded: String, + val import_network_error: String, + val import_empty_file_error: String, + val import_corrupted_file_error: String, + val import_unknown_error: String, ) diff --git a/app/src/main/kotlin/com/mr3y/podcaster/ui/screens/HomeScreen.kt b/app/src/main/kotlin/com/mr3y/podcaster/ui/screens/HomeScreen.kt index 61d9c9ef..09333508 100644 --- a/app/src/main/kotlin/com/mr3y/podcaster/ui/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/mr3y/podcaster/ui/screens/HomeScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.FileDownload +import androidx.compose.material.icons.outlined.ImportExport import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.DrawerValue import androidx.compose.material3.Icon @@ -104,6 +105,12 @@ fun HomeScreen( createRoutePattern(), Destinations.Explore, ), + DrawerTab( + strings.import_export_label, + Icons.Outlined.ImportExport, + createRoutePattern(), + Destinations.ImportExport, + ), DrawerTab( strings.tab_downloads_label, Icons.Outlined.FileDownload, diff --git a/app/src/main/kotlin/com/mr3y/podcaster/ui/screens/ImportExportScreen.kt b/app/src/main/kotlin/com/mr3y/podcaster/ui/screens/ImportExportScreen.kt new file mode 100644 index 00000000..d449503b --- /dev/null +++ b/app/src/main/kotlin/com/mr3y/podcaster/ui/screens/ImportExportScreen.kt @@ -0,0 +1,218 @@ +package com.mr3y.podcaster.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.mr3y.podcaster.LocalStrings +import com.mr3y.podcaster.core.opml.model.OpmlResult +import com.mr3y.podcaster.ui.components.LoadingIndicator +import com.mr3y.podcaster.ui.components.plus +import com.mr3y.podcaster.ui.presenter.opml.OpmlViewModel +import com.mr3y.podcaster.ui.preview.DynamicColorsParameterProvider +import com.mr3y.podcaster.ui.preview.PodcasterPreview +import com.mr3y.podcaster.ui.theme.PodcasterTheme +import com.mr3y.podcaster.ui.theme.isAppThemeDark +import com.mr3y.podcaster.ui.theme.onPrimaryTertiary +import com.mr3y.podcaster.ui.theme.primaryTertiary +import com.mr3y.podcaster.ui.theme.setStatusBarAppearanceLight + +@Composable +fun ImportExportScreen( + onNavDrawerClick: () -> Unit, + contentPadding: PaddingValues, + excludedWindowInsets: WindowInsets?, + modifier: Modifier = Modifier, + viewModel: OpmlViewModel = hiltViewModel(), +) { + val result by viewModel.result.collectAsStateWithLifecycle() + ImportExportScreen( + result = result, + onNavDrawerClick = onNavDrawerClick, + onImporting = viewModel::import, + onExporting = viewModel::export, + onConsumeResult = viewModel::consumeResult, + externalContentPadding = contentPadding, + excludedWindowInsets = excludedWindowInsets, + modifier = modifier, + ) +} + +@Composable +fun ImportExportScreen( + result: OpmlResult, + onNavDrawerClick: () -> Unit, + onImporting: () -> Unit, + onExporting: () -> Unit, + onConsumeResult: () -> Unit, + externalContentPadding: PaddingValues, + excludedWindowInsets: WindowInsets?, + modifier: Modifier = Modifier, +) { + val snackBarHostState = remember { SnackbarHostState() } + val isDarkTheme = isAppThemeDark() + val context = LocalContext.current + LaunchedEffect(key1 = isDarkTheme) { + context.setStatusBarAppearanceLight(isAppearanceLight = !isDarkTheme) + } + Scaffold( + topBar = { + ImportExportTopAppBar( + onNavDrawerClick = onNavDrawerClick, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .padding(end = 16.dp), + ) + }, + snackbarHost = { SnackbarHost(snackBarHostState, Modifier.padding(externalContentPadding)) }, + contentWindowInsets = if (excludedWindowInsets != null) ScaffoldDefaults.contentWindowInsets.exclude(excludedWindowInsets) else ScaffoldDefaults.contentWindowInsets, + containerColor = MaterialTheme.colorScheme.surface, + modifier = modifier, + ) { contentPadding -> + val strings = LocalStrings.current + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(contentPadding + externalContentPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 56.dp), + ) { + ImportOrExportButton(label = strings.import_label, onClick = onImporting, modifier = Modifier.fillMaxWidth()) + ImportOrExportButton(label = strings.export_label, onClick = onExporting, modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = strings.import_notice, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + if (result is OpmlResult.Loading) { + LoadingIndicator(modifier = Modifier.fillMaxWidth()) + } + LaunchedEffect(result) { + if (result is OpmlResult.Error || result is OpmlResult.Success) { + val message = when (result) { + is OpmlResult.Success -> strings.import_succeeded + is OpmlResult.Error.NoContentInOpmlFile -> strings.import_empty_file_error + is OpmlResult.Error.NetworkError -> strings.import_network_error + is OpmlResult.Error.EncodingError, is OpmlResult.Error.DecodingError -> strings.import_corrupted_file_error + is OpmlResult.Error.UnknownFailure -> strings.import_unknown_error + else -> "" // Not reachable, but when can't infer that yet (see https://youtrack.jetbrains.com/issue/KT-8781/Consider-making-smart-casts-smart-enough-to-handle-exhaustive-value-sets) + } + snackBarHostState.showSnackbar(message) + onConsumeResult() + } + } + } + } +} + +@Composable +private fun ImportExportTopAppBar( + onNavDrawerClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val strings = LocalStrings.current + TopAppBar( + navigationIcon = { + IconButton( + onClick = onNavDrawerClick, + ) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = strings.icon_menu_content_description, + ) + } + }, + title = {}, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), + modifier = modifier, + ) +} + +@Composable +private fun ImportOrExportButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ElevatedButton( + onClick = onClick, + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.elevatedButtonColors( + containerColor = MaterialTheme.colorScheme.primaryTertiary, + contentColor = MaterialTheme.colorScheme.onPrimaryTertiary, + ), + modifier = modifier, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@PodcasterPreview +@Composable +fun ImportExportScreenPreview( + @PreviewParameter(DynamicColorsParameterProvider::class) isDynamicColorsOn: Boolean, +) { + PodcasterTheme(dynamicColor = isDynamicColorsOn) { + ImportExportScreen( + result = OpmlResult.Idle, + onNavDrawerClick = {}, + onImporting = { }, + onExporting = { }, + onConsumeResult = {}, + externalContentPadding = PaddingValues(0.dp), + excludedWindowInsets = null, + modifier = Modifier.fillMaxSize(), + ) + } +} diff --git a/core/data/src/main/kotlin/com/mr3y/podcaster/core/data/PodcastsRepository.kt b/core/data/src/main/kotlin/com/mr3y/podcaster/core/data/PodcastsRepository.kt index 6a86ba2d..d931315f 100644 --- a/core/data/src/main/kotlin/com/mr3y/podcaster/core/data/PodcastsRepository.kt +++ b/core/data/src/main/kotlin/com/mr3y/podcaster/core/data/PodcastsRepository.kt @@ -28,6 +28,8 @@ interface PodcastsRepository { fun isPodcastFromSubscriptions(podcastId: Long): Flow + fun isPodcastFromSubscriptionsNonObservable(podcastId: Long): Boolean + fun hasSubscriptions(): Flow fun hasDownloads(): Flow diff --git a/core/data/src/main/kotlin/com/mr3y/podcaster/core/data/internal/DefaultPodcastsRepository.kt b/core/data/src/main/kotlin/com/mr3y/podcaster/core/data/internal/DefaultPodcastsRepository.kt index 4b7efeba..ec389205 100644 --- a/core/data/src/main/kotlin/com/mr3y/podcaster/core/data/internal/DefaultPodcastsRepository.kt +++ b/core/data/src/main/kotlin/com/mr3y/podcaster/core/data/internal/DefaultPodcastsRepository.kt @@ -69,6 +69,10 @@ class DefaultPodcastsRepository @Inject constructor( return podcastsDao.isPodcastAvailable(podcastId) } + override fun isPodcastFromSubscriptionsNonObservable(podcastId: Long): Boolean { + return podcastsDao.isPodcastAvailableNonObservable(podcastId) + } + override fun hasSubscriptions(): Flow = podcastsDao.hasPodcasts() override fun hasDownloads(): Flow = podcastsDao.hasDownloads() diff --git a/core/opml/build.gradle.kts b/core/opml/build.gradle.kts new file mode 100644 index 00000000..5f62022a --- /dev/null +++ b/core/opml/build.gradle.kts @@ -0,0 +1,73 @@ +plugins { + alias(libs.plugins.com.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.ktlint) +} + +android { + namespace = "com.mr3y.podcaster.core.opml" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + freeCompilerArgs += listOf( + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + ) + } + buildFeatures { + compose = false + buildConfig = false + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +ktlint { + filter { + exclude("**/generated/**") + exclude("**/build/**") + } +} + +dependencies { + + implementation(projects.core.model) + implementation(projects.core.data) + implementation(projects.core.logger) + ksp(libs.hilt.compiler) + implementation(libs.hilt.runtime) + implementation(libs.xmlutil.core) + implementation(libs.xmlutil.serialization) + implementation(libs.kotlinx.serialization) + implementation(libs.result) + + testImplementation(projects.core.loggerTestFixtures) + testImplementation(libs.junit) + testImplementation(libs.assertk) + testImplementation(libs.coroutines.test) +} diff --git a/core/opml/consumer-rules.pro b/core/opml/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/opml/proguard-rules.pro b/core/opml/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/opml/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/opml/src/main/AndroidManifest.xml b/core/opml/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/core/opml/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/FileManager.kt b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/FileManager.kt new file mode 100644 index 00000000..f7c03978 --- /dev/null +++ b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/FileManager.kt @@ -0,0 +1,100 @@ +package com.mr3y.podcaster.core.opml + +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 dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FileManager @Inject constructor( + @ApplicationContext context: Context, +) { + + 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 + + fun save(name: String, content: String) { + this.content = content + + if (!this.content.isNullOrBlank()) { + createDocumentLauncher.launch(name) + } + } + + suspend fun read(): String? { + openDocumentLauncher.launch( + arrayOf("application/xml", "application/octet-stream", "text/xml", "text/x-opml"), + ) + return result.receiveAsFlow().first() + } + + fun registerActivityWatcher() { + val callback = object : Application.ActivityLifecycleCallbacks { + 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) + } + } + + 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, outState: Bundle) = Unit + + override fun onActivityDestroyed(activity: Activity) = Unit + } + 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) + } + } + } +} diff --git a/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/OpmlAdapter.kt b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/OpmlAdapter.kt new file mode 100644 index 00000000..056fc338 --- /dev/null +++ b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/OpmlAdapter.kt @@ -0,0 +1,79 @@ +package com.mr3y.podcaster.core.opml + +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.mr3y.podcaster.core.logger.Logger +import com.mr3y.podcaster.core.model.Podcast +import com.mr3y.podcaster.core.opml.model.Body +import com.mr3y.podcaster.core.opml.model.Head +import com.mr3y.podcaster.core.opml.model.Opml +import com.mr3y.podcaster.core.opml.model.OpmlPodcast +import com.mr3y.podcaster.core.opml.model.Outline +import kotlinx.serialization.serializer +import nl.adaptivity.xmlutil.serialization.XML +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class OpmlAdapter @Inject constructor( + private val xmlInstance: XML, + private val logger: Logger, +) { + + fun decode(content: String): Result, Any> { + return try { + val opml = xmlInstance.decodeFromString(serializer(), content) + val opmlFeeds = mutableListOf() + + fun flatten(outline: Outline) { + if (outline.outlines.isNullOrEmpty() && !outline.xmlUrl.isNullOrBlank()) { + opmlFeeds.add(mapOutlineToOpmlPodcast(outline)) + } + + outline.outlines?.forEach { nestedOutline -> flatten(nestedOutline) } + } + + opml.body.outlines.forEach { outline -> flatten(outline) } + + Ok(opmlFeeds.distinctBy { it.link }) + } catch (ex: Exception) { + logger.e(ex, tag = "OpmlAdapter") { + "Exception occurred on decoding Opml podcasts from content $content" + } + Err(ex) + } + } + + fun encode(podcasts: List): Result { + return try { + val opml = Opml( + version = "2.0", + head = Head("Podcaster Subscriptions", dateCreated = null), + body = Body(outlines = podcasts.map(::mapPodcastToOutline)), + ) + + val xmlString = xmlInstance.encodeToString(serializer(), opml) + + StringBuilder(xmlString) + .insert(0, "\n") + .appendLine() + .toString() + .let { + Ok(it) + } + } catch (ex: Exception) { + logger.e(ex, tag = "OpmlAdapter") { + "Exception occurred on encoding Opml podcasts $podcasts" + } + Err(ex) + } + } + + private fun mapPodcastToOutline(podcast: Podcast) = + Outline(text = podcast.title, title = podcast.title, type = "rss", xmlUrl = podcast.podcastUrl, htmlUrl = podcast.website, outlines = null) + + private fun mapOutlineToOpmlPodcast(outline: Outline): OpmlPodcast { + return OpmlPodcast(title = outline.title ?: outline.text, link = outline.xmlUrl!!) + } +} diff --git a/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/OpmlManager.kt b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/OpmlManager.kt new file mode 100644 index 00000000..525e20c0 --- /dev/null +++ b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/OpmlManager.kt @@ -0,0 +1,153 @@ +package com.mr3y.podcaster.core.opml + +import com.github.michaelbull.result.mapBoth +import com.mr3y.podcaster.core.data.PodcastsRepository +import com.mr3y.podcaster.core.logger.Logger +import com.mr3y.podcaster.core.model.Podcast +import com.mr3y.podcaster.core.opml.di.IODispatcher +import com.mr3y.podcaster.core.opml.model.OpmlPodcast +import com.mr3y.podcaster.core.opml.model.OpmlResult +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class OpmlManager @Inject constructor( + private val opmlAdapter: OpmlAdapter, + private val fileManager: FileManager, + private val repository: PodcastsRepository, + @IODispatcher private val coroutineDispatcher: CoroutineDispatcher, + private val logger: Logger, +) { + + private val job = SupervisorJob() + coroutineDispatcher + + private val _result = MutableStateFlow(OpmlResult.Idle) + val result: StateFlow = _result + + suspend fun import() { + try { + withContext(job) { + val opmlXmlContent = fileManager.read() + + if (!opmlXmlContent.isNullOrBlank()) { + _result.emit(OpmlResult.Loading) + opmlAdapter.decode(opmlXmlContent) + .mapBoth( + success = { + addOpmlPodcasts(it) + if (_result.value !is OpmlResult.Error) { + _result.emit(OpmlResult.Success) + } + }, + failure = { + _result.emit(OpmlResult.Error.DecodingError) + }, + ) + } else { + _result.emit(OpmlResult.Error.NoContentInOpmlFile) + } + } + } catch (ex: Exception) { + if (ex !is CancellationException) { + logger.e(ex, tag = "OpmlManager") { + "Exception occurred on importing subscriptions collection." + } + _result.emit(OpmlResult.Error.UnknownFailure(ex)) + } + } + } + + suspend fun export() { + try { + withContext(job) { + _result.emit(OpmlResult.Loading) + + repository.getSubscriptionsNonObservable() + .let { opmlAdapter.encode(it) } + .mapBoth( + success = { + fileManager.save(OpmlFileName, it) + _result.emit(OpmlResult.Idle) + }, + failure = { + _result.emit(OpmlResult.Error.EncodingError) + }, + ) + } + } catch (ex: Exception) { + if (ex !is CancellationException) { + logger.e(ex, tag = "OpmlManager") { + "Exception occurred on exporting subscriptions collection." + } + _result.emit(OpmlResult.Error.UnknownFailure(ex)) + } + } + } + + fun cancelCurrentRunningTask() { + job.cancelChildren() + _result.tryEmit(OpmlResult.Idle) + } + + suspend fun resetResultState() { + _result.emit(OpmlResult.Idle) + } + + private suspend fun addOpmlPodcasts(opmlPodcasts: List) = coroutineScope { + if (opmlPodcasts.size > PageSize) { + opmlPodcasts.reversed().chunked(PageSize).forEach { podcastsGroup -> + podcastsGroup + .map { opmlPodcast -> + launch { + repository.getPodcast(podcastFeedUrl = opmlPodcast.link).mapBoth( + success = { podcast -> + addPodcastToSubscriptionsIfNotExist(podcast) + }, + failure = { + _result.emit(OpmlResult.Error.NetworkError) + }, + ) + } + } + .joinAll() + } + } else { + opmlPodcasts.reversed().forEach { opmlPodcast -> + repository.getPodcast(podcastFeedUrl = opmlPodcast.link).mapBoth( + success = { podcast -> + addPodcastToSubscriptionsIfNotExist(podcast) + }, + failure = { + _result.emit(OpmlResult.Error.NetworkError) + }, + ) + } + } + } + + private suspend fun addPodcastToSubscriptionsIfNotExist(podcast: Podcast) { + if (!repository.isPodcastFromSubscriptionsNonObservable(podcast.id)) { + val episodes = repository.getEpisodesForPodcast(podcast.id, podcast.title, podcast.artworkUrl, true) + if (episodes != null) { + repository.subscribeToPodcast(podcast, episodes) + } else { + _result.emit(OpmlResult.Error.NetworkError) + } + } + } + + companion object { + private const val PageSize = 10 + private const val OpmlFileName = "podcaster_subscriptions.xml" + } +} diff --git a/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/di/XMLSerializerModule.kt b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/di/XMLSerializerModule.kt new file mode 100644 index 00000000..d7e81027 --- /dev/null +++ b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/di/XMLSerializerModule.kt @@ -0,0 +1,39 @@ +package com.mr3y.podcaster.core.opml.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import nl.adaptivity.xmlutil.serialization.XML +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IODispatcher + +@Module +@InstallIn(SingletonComponent::class) +object XMLSerializerModule { + + @Singleton + @Provides + fun provideXMLInstance(): XML { + return XML { + autoPolymorphic = true + indentString = " " + defaultPolicy { + pedantic = false + ignoreUnknownChildren() + } + } + } + + @Provides + @IODispatcher + fun provideIOCoroutineDispatcher(): CoroutineDispatcher { + return Dispatchers.IO + } +} diff --git a/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/model/Opml.kt b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/model/Opml.kt new file mode 100644 index 00000000..bff89d42 --- /dev/null +++ b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/model/Opml.kt @@ -0,0 +1,32 @@ +package com.mr3y.podcaster.core.opml.model + +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?, + @XmlElement(value = false) val head: Head?, + val body: Body, +) + +@Serializable +@XmlSerialName("head") +internal data class Head(@XmlElement val title: String, @XmlElement val dateCreated: 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?, + @XmlElement(value = false) val htmlUrl: String?, + @XmlSerialName("outline") val outlines: List?, +) diff --git a/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/model/OpmlPodcast.kt b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/model/OpmlPodcast.kt new file mode 100644 index 00000000..4c04ae53 --- /dev/null +++ b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/model/OpmlPodcast.kt @@ -0,0 +1,3 @@ +package com.mr3y.podcaster.core.opml.model + +data class OpmlPodcast(val title: String?, val link: String) diff --git a/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/model/OpmlResult.kt b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/model/OpmlResult.kt new file mode 100644 index 00000000..9fc188d1 --- /dev/null +++ b/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/model/OpmlResult.kt @@ -0,0 +1,21 @@ +package com.mr3y.podcaster.core.opml.model + +sealed interface OpmlResult { + data object Idle : OpmlResult + + data object Loading : OpmlResult + + data object Success : OpmlResult + + sealed interface Error : OpmlResult { + data object NoContentInOpmlFile : Error + + data object EncodingError : Error + + data object DecodingError : Error + + data object NetworkError : Error + + data class UnknownFailure(val error: Exception) : Error + } +} diff --git a/core/opml/src/test/kotlin/com/mr3y/podcaster/core/opml/OpmlAdapterTest.kt b/core/opml/src/test/kotlin/com/mr3y/podcaster/core/opml/OpmlAdapterTest.kt new file mode 100644 index 00000000..55db5a30 --- /dev/null +++ b/core/opml/src/test/kotlin/com/mr3y/podcaster/core/opml/OpmlAdapterTest.kt @@ -0,0 +1,103 @@ +package com.mr3y.podcaster.core.opml + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.github.michaelbull.result.Ok +import com.mr3y.podcaster.core.logger.TestLogger +import com.mr3y.podcaster.core.opml.model.OpmlPodcast +import com.mr3y.podcaster.core.sampledata.Podcasts +import nl.adaptivity.xmlutil.serialization.XML +import org.junit.Before +import org.junit.Test + +class OpmlAdapterTest { + + private lateinit var sut: OpmlAdapter + + @Before + fun setUp() { + val xmlInstance = XML { + autoPolymorphic = true + indentString = " " + defaultPolicy { + pedantic = false + ignoreUnknownChildren() + } + } + sut = OpmlAdapter(xmlInstance, TestLogger()) + } + + @Test + fun `test decoding is working as expected`() { + val pocketCastsExportedOpml = """ + + + + Pocket Casts Feeds + + + + + + + + + + """.trimIndent() + val expectedPocketCastsFeeds = setOf( + OpmlPodcast(title = "Android Developers Backstage", link = "https://adbackstage.libsyn.com/rss"), + OpmlPodcast(title = "Waveform: The MKBHD Podcast", link = "https://feeds.megaphone.fm/STU4418364045"), + OpmlPodcast(title = "Now in Android", link = "https://nowinandroid.libsyn.com/rss"), + ) + + var result = sut.decode(pocketCastsExportedOpml) + assertThat(result).isInstanceOf>>() + result as Ok> + assertThat(result.value.toSet()).isEqualTo(expectedPocketCastsFeeds) + + val antennaPodExportedOpml = """ + + + + AntennaPod Subscriptions + 08 Mar 24 15:45:10 +0200 + + + + + + """.trimIndent() + val expectedAntennaPodFeeds = setOf( + OpmlPodcast(title = "Android Developers Backstage", link = "https://adbackstage.libsyn.com/rss"), + ) + + result = sut.decode(antennaPodExportedOpml) + assertThat(result).isInstanceOf>>() + result as Ok> + assertThat(result.value.toSet()).isEqualTo(expectedAntennaPodFeeds) + } + + @Test + fun `test encoding is working as expected`() { + val subscriptions = Podcasts.take(2) + val expectedExportedResult = """ + + + + Podcaster Subscriptions + + + + + + + + """.trimIndent() + + val result = sut.encode(subscriptions) + assertThat(result).isInstanceOf>() + result as Ok + assertThat(result.value).isEqualTo(expectedExportedResult) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed99313c..617115eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ workmanager = "2.9.0" coil = "2.6.0" ktor = "2.3.8" result = "1.1.18" +xmlutil = "0.86.3" kermit = "2.0.3" sqldelight = "2.0.1" sqlite-jdbc = "3.18.0" # sqlite version used in Android API level 26 (our minSdk) @@ -107,6 +108,8 @@ firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashly leakcanary = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "leakcanary" } datastore-pref = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } aboutlibraries-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutlibraries" } +xmlutil-core = { group = "io.github.pdvrieze.xmlutil", name = "core", version.ref = "xmlutil" } +xmlutil-serialization = { group = "io.github.pdvrieze.xmlutil", name = "serialization", version.ref = "xmlutil" } [plugins] com-android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index ce90bac1..736938d9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,3 +26,4 @@ include(":core:network") include(":core:network-test-fixtures") include(":core:data") include(":core:sync") +include(":core:opml")