6.3.2 commit

This commit is contained in:
Xilin Jia 2024-08-03 00:02:45 +01:00
parent 81d4374a3f
commit abdbf3dabd
17 changed files with 124 additions and 55 deletions

View File

@ -138,6 +138,7 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
* Play history/progress can be separately exported/imported as Json files
* Downloaded media files can be exported/imported
* Reconsile feature (accessed from Downloads view) is added to ensure downloaded media files are in sync with specs in DB
* Podcasts can be selectively exported from Subscriptions view
* There is a setting to disable/enable auto backup of OPML files to Google
* Upon re-install of Podcini, the OPML file previously backed up to Google is not imported automatically but based on user confirmation.

View File

@ -31,8 +31,8 @@ android {
testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020225
versionName "6.3.1"
versionCode 3020226
versionName "6.3.2"
applicationId "ac.mdiq.podcini.R"
def commit = ""

View File

@ -68,6 +68,8 @@ class OpmlTransporter {
xs.startTag(null, OpmlSymbols.BODY)
for (feed in feeds!!) {
if (feed == null) continue
Logd(TAG, "writeDocument ${feed?.title}")
xs.startTag(null, OpmlSymbols.OUTLINE)
xs.attribute(null, OpmlSymbols.TEXT, feed!!.title)
xs.attribute(null, OpmlSymbols.TITLE, feed.title)

View File

@ -23,6 +23,8 @@ import ac.mdiq.podcini.storage.utils.FileNameGenerator.generateFileName
import ac.mdiq.podcini.storage.utils.FilesUtils.getDataFolder
import ac.mdiq.podcini.ui.activity.OpmlImportActivity
import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.ui.fragment.SubscriptionsFragment
import ac.mdiq.podcini.ui.fragment.SubscriptionsFragment.Companion
import ac.mdiq.podcini.util.Logd
import android.app.Activity.RESULT_OK
import android.app.ProgressDialog
@ -264,7 +266,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
}
private fun exportDatabase() {
backupDatabaseLauncher.launch(dateStampFilename(DATABASE_EXPORT_FILENAME))
backupDatabaseLauncher.launch(dateStampFilename("PodciniBackup-%s.realm"))
}
private fun importDatabase() {
@ -554,15 +556,16 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
}
}
private enum class Export(val contentType: String, val outputNameTemplate: String, @field:StringRes val labelResId: Int) {
OPML(CONTENT_TYPE_OPML, DEFAULT_OPML_OUTPUT_NAME, R.string.opml_export_label),
HTML(CONTENT_TYPE_HTML, DEFAULT_HTML_OUTPUT_NAME, R.string.html_export_label),
FAVORITES(CONTENT_TYPE_HTML, DEFAULT_FAVORITES_OUTPUT_NAME, R.string.favorites_export_label),
PROGRESS(CONTENT_TYPE_PROGRESS, DEFAULT_PROGRESS_OUTPUT_NAME, R.string.progress_export_label),
enum class Export(val contentType: String, val outputNameTemplate: String, @field:StringRes val labelResId: Int) {
OPML(CONTENT_TYPE_OPML, "podcini-feeds-%s.opml", R.string.opml_export_label),
OPML_SELECTED(CONTENT_TYPE_OPML, "podcini-feeds-selected-%s.opml", R.string.opml_export_label),
HTML(CONTENT_TYPE_HTML, "podcini-feeds-%s.html", R.string.html_export_label),
FAVORITES(CONTENT_TYPE_HTML, "podcini-favorites-%s.html", R.string.favorites_export_label),
PROGRESS(CONTENT_TYPE_PROGRESS, "podcini-progress-%s.json", R.string.progress_export_label),
}
class DocumentFileExportWorker(private val exportWriter: ExportWriter, private val context: Context, private val outputFileUri: Uri) {
suspend fun exportFile(): DocumentFile {
suspend fun exportFile(feeds: List<Feed>? = null): DocumentFile {
return withContext(Dispatchers.IO) {
val output = DocumentFile.fromSingleUri(context, outputFileUri)
var outputStream: OutputStream? = null
@ -573,7 +576,9 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
outputStream = context.contentResolver.openOutputStream(uri, "wt")
if (outputStream == null) throw IOException()
writer = OutputStreamWriter(outputStream, Charset.forName("UTF-8"))
exportWriter.writeDocument(getFeedList(), writer, context)
val feeds_ = feeds ?: getFeedList()
Logd(TAG, "feeds_: ${feeds_.size}")
exportWriter.writeDocument(feeds_, writer, context)
output
} catch (e: IOException) {
throw e
@ -591,7 +596,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
class ExportWorker private constructor(private val exportWriter: ExportWriter, private val output: File, private val context: Context) {
constructor(exportWriter: ExportWriter, context: Context) : this(exportWriter, File(getDataFolder(EXPORT_DIR),
DEFAULT_OUTPUT_NAME + "." + exportWriter.fileExtension()), context)
suspend fun exportFile(): File? {
suspend fun exportFile(feeds: List<Feed>? = null): File? {
return withContext(Dispatchers.IO) {
if (output.exists()) {
val success = output.delete()
@ -600,7 +605,9 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
var writer: OutputStreamWriter? = null
try {
writer = OutputStreamWriter(FileOutputStream(output), Charset.forName("UTF-8"))
exportWriter.writeDocument(getFeedList(), writer, context)
val feeds_ = feeds ?: getFeedList()
Logd(TAG, "feeds_: ${feeds_.size}")
exportWriter.writeDocument(feeds_, writer, context)
output // return the output file
} catch (e: IOException) {
Log.e(TAG, "Error during file export", e)
@ -1135,13 +1142,8 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
companion object {
private val TAG: String = ImportExportPreferencesFragment::class.simpleName ?: "Anonymous"
private const val DEFAULT_OPML_OUTPUT_NAME = "podcini-feeds-%s.opml"
private const val CONTENT_TYPE_OPML = "text/x-opml"
private const val DEFAULT_HTML_OUTPUT_NAME = "podcini-feeds-%s.html"
private const val CONTENT_TYPE_HTML = "text/html"
private const val DEFAULT_FAVORITES_OUTPUT_NAME = "podcini-favorites-%s.html"
private const val CONTENT_TYPE_PROGRESS = "text/x-json"
private const val DEFAULT_PROGRESS_OUTPUT_NAME = "podcini-progress-%s.json"
private const val DATABASE_EXPORT_FILENAME = "PodciniBackup-%s.realm"
}
}

View File

@ -1,6 +1,7 @@
package ac.mdiq.podcini.storage.model
import ac.mdiq.podcini.storage.database.Feeds.getFeed
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.ext.realmSetOf
import io.realm.kotlin.types.RealmList
@ -114,7 +115,7 @@ class Episode : RealmObject {
val imageLocation: String?
get() = when {
imageUrl != null -> imageUrl
media != null && media!!.hasEmbeddedPicture() -> EpisodeMedia.FILENAME_PREFIX_EMBEDDED_COVER + media!!.getLocalMediaUrl()
media != null && unmanaged(media!!).hasEmbeddedPicture() -> EpisodeMedia.FILENAME_PREFIX_EMBEDDED_COVER + media!!.getLocalMediaUrl()
feed != null -> {
feed!!.imageUrl
}

View File

@ -313,7 +313,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
override fun getImageLocation(): String? {
return when {
episode != null -> episode!!.imageLocation
hasEmbeddedPicture() -> FILENAME_PREFIX_EMBEDDED_COVER + getLocalMediaUrl()
unmanaged(this).hasEmbeddedPicture() -> FILENAME_PREFIX_EMBEDDED_COVER + getLocalMediaUrl()
else -> null
}
}

View File

@ -9,6 +9,10 @@ import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.storage.database.Episodes.persistEpisode
import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent
class CancelDownloadActionButton(item: Episode) : EpisodeActionButton(item) {
@StringRes
@ -25,8 +29,9 @@ class CancelDownloadActionButton(item: Episode) : EpisodeActionButton(item) {
val media = item.media
if (media != null) DownloadServiceInterface.get()?.cancel(context, media)
if (isEnableAutodownload) {
item.disableAutoDownload()
persistEpisode(item)
}
val item_ = upsertBlk(item) {
it.disableAutoDownload()
}
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item_)) }
}
}

View File

@ -157,9 +157,7 @@ abstract class SelectableAdapter<T : RecyclerView.ViewHolder?>(private val activ
totalCount = totalNumberOfItems
if (shouldSelectLazyLoadedItems) selectedCount += (totalNumberOfItems - itemCount)
}
actionMode!!.title = activity.resources
.getQuantityString(R.plurals.num_selected_label, selectedIds.size,
selectedCount, totalCount)
actionMode!!.title = activity.resources.getQuantityString(R.plurals.num_selected_label, selectedIds.size, selectedCount, totalCount)
}
fun setOnSelectModeListener(onSelectModeListener: OnSelectModeListener?) {

View File

@ -524,9 +524,10 @@ import kotlin.math.max
}
}
// they didn't tell us the size, but we don't want to keep querying on it
if (size <= 0) media.setCheckedOnSizeButUnknown()
else media.size = size
upsert(episode) {}
upsert(episode) {
if (size <= 0) it.media?.setCheckedOnSizeButUnknown()
else it.media?.size = size
}
size
}
}

View File

@ -238,7 +238,7 @@ import java.lang.ref.WeakReference
private fun onMenuItemClicked(fragment: Fragment, menuItemId: Int, selectedFeed: Feed, callback: Runnable): Boolean {
val context = fragment.requireContext()
when (menuItemId) {
R.id.rename_folder_item -> CustomFeedNameDialog(fragment.activity as Activity, selectedFeed).show()
// R.id.rename_folder_item -> CustomFeedNameDialog(fragment.activity as Activity, selectedFeed).show()
R.id.edit_tags -> if (selectedFeed.preferences != null) TagSettingsDialog.newInstance(listOf(selectedFeed))
.show(fragment.childFragmentManager, TagSettingsDialog.TAG)
R.id.rename_item -> CustomFeedNameDialog(fragment.activity as Activity, selectedFeed).show()
@ -492,7 +492,7 @@ import java.lang.ref.WeakReference
override fun onCreateContextMenu(contextMenu: ContextMenu, view: View, contextMenuInfo: ContextMenu.ContextMenuInfo?) {
val inflater: MenuInflater = mainActivityRef.get()!!.menuInflater
if (longPressedItem == null) return
inflater.inflate(R.menu.nav_feed_context, contextMenu)
inflater.inflate(R.menu.feed_context, contextMenu)
contextMenu.setHeaderTitle(longPressedItem!!.title)
}
fun setEndButton(@StringRes text: Int, action: Runnable?) {

View File

@ -3,8 +3,10 @@ package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.*
import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlWriter
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
import ac.mdiq.podcini.preferences.fragments.ImportExportPreferencesFragment.*
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.getTags
import ac.mdiq.podcini.storage.database.RealmDB.realm
@ -28,15 +30,21 @@ import ac.mdiq.podcini.ui.utils.LiftOnScrollListener
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent
import android.app.Activity.RESULT_OK
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.*
import android.view.inputmethod.EditorInfo
import android.widget.*
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar
@ -66,11 +74,8 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.*
@ -80,6 +85,14 @@ import java.util.*
*/
class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, SelectableAdapter.OnSelectModeListener {
private val chooseOpmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
if (result.resultCode != RESULT_OK || result.data == null) return@registerForActivityResult
val uri = result.data!!.data
multiSelectHandler?.exportOPML(uri)
}
private var multiSelectHandler: FeedMultiSelectActionHandler? = null
private var _binding: FragmentSubscriptionsBinding? = null
private val binding get() = _binding!!
@ -181,7 +194,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
val speedDialBinding = MultiSelectSpeedDialBinding.bind(binding.root)
speedDialView = speedDialBinding.fabSD
speedDialView.overlayLayout = speedDialBinding.fabSDOverlay
speedDialView.inflate(R.menu.nav_feed_action_speeddial)
speedDialView.inflate(R.menu.feed_action_speeddial)
speedDialView.setOnChangeListener(object : SpeedDialView.OnChangeListener {
override fun onMainActionSelected(): Boolean {
return false
@ -189,7 +202,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
override fun onToggleChanged(isOpen: Boolean) {}
})
speedDialView.setOnActionSelectedListener { actionItem: SpeedDialActionItem ->
FeedMultiSelectActionHandler(activity as MainActivity, adapter.selectedItems.filterIsInstance<Feed>()).handleAction(actionItem.id)
multiSelectHandler = FeedMultiSelectActionHandler(activity as MainActivity, adapter.selectedItems.filterIsInstance<Feed>())
multiSelectHandler?.handleAction(actionItem.id)
true
}
loadSubscriptions()
@ -242,7 +256,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
private fun queryStringOfTags() : String {
return when (tagFilterIndex) {
1 -> "" // All feeds
0 -> " preferences.tags.@count == 0 OR (preferences.tags.@count == 0 AND ALL preferences.tags == '#root' ) "
// TODO: #root appears not used in RealmDB, is it a SQLite specialty
0 -> " (preferences.tags.@count == 0 OR (preferences.tags.@count != 0 AND ALL preferences.tags == '#root' )) "
else -> { // feeds with the chosen tag
val tag = tags[tagFilterIndex]
" ANY preferences.tags == '$tag' "
@ -334,8 +349,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
filterAndSort()
resetTags()
filterAndSort()
}
withContext(Dispatchers.Main) {
// We have fewer items. This can result in items being selected that are no longer visible.
@ -502,7 +517,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
}
@UnstableApi
private class FeedMultiSelectActionHandler(private val activity: MainActivity, private val selectedItems: List<Feed>) {
private inner class FeedMultiSelectActionHandler(private val activity: MainActivity, private val selectedItems: List<Feed>) {
fun handleAction(id: Int) {
when (id) {
R.id.remove_feed -> RemoveFeedDialog.show(activity, selectedItems)
@ -510,11 +525,42 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
R.id.autodownload -> autoDownloadPrefHandler()
R.id.autoDeleteDownload -> autoDeleteEpisodesPrefHandler()
R.id.playback_speed -> playbackSpeedPrefHandler()
R.id.export_opml -> openExportPathPicker()
R.id.associate_queue -> associatedQueuePrefHandler()
R.id.edit_tags -> TagSettingsDialog.newInstance(selectedItems).show(activity.supportFragmentManager, TAG)
else -> Log.e(TAG, "Unrecognized speed dial action item. Do nothing. id=$id")
}
}
private fun openExportPathPicker() {
val exportType = Export.OPML_SELECTED
val title = String.format(exportType.outputNameTemplate, SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()))
val intentPickAction = Intent(Intent.ACTION_CREATE_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType(exportType.contentType)
.putExtra(Intent.EXTRA_TITLE, title)
try {
chooseOpmlExportPathLauncher.launch(intentPickAction)
return
} catch (e: ActivityNotFoundException) {
Log.e(TAG, "No activity found. Should never happen...")
}
// if on SDK lower than API 21 or the implicit intent failed, fallback to the legacy export process
exportOPML(null)
}
fun exportOPML(uri: Uri?) {
try {
runBlocking {
Logd(TAG, "selectedFeeds: ${selectedItems.size}")
if (uri == null) ExportWorker(OpmlWriter(), requireContext()).exportFile(selectedItems)
else {
val worker = DocumentFileExportWorker(OpmlWriter(), requireContext(), uri)
worker.exportFile(selectedItems)
}
}
} catch (e: Exception) {
Log.e(TAG, "exportOPML error: ${e.message}")
}
}
private fun autoDownloadPrefHandler() {
val preferenceSwitchDialog = PreferenceSwitchDialog(activity, activity.getString(R.string.auto_download_settings_label), activity.getString(R.string.auto_download_label))
preferenceSwitchDialog.setOnPreferenceChangedListener(@UnstableApi object: PreferenceSwitchDialog.OnPreferenceChangedListener {
@ -761,10 +807,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
get() {
val items = ArrayList<Feed>()
for (i in 0 until itemCount) {
if (isSelected(i)) {
val feed: Feed = feedList[i]
items.add(feed)
}
if (isSelected(i)) items.add(feedList[i])
}
return items
}

View File

@ -0,0 +1,7 @@
<vector android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="?attr/action_icon_color" android:pathData="M9,3L5,6.99h3L8,14h2L10,6.99h3L9,3zM16,17.01L16,10h-2v7.01h-3L15,21l4,-3.99h-3z"/>
</vector>

View File

@ -10,11 +10,6 @@
android:menuCategory="container"
android:title="@string/keep_updated"
android:icon="@drawable/ic_refresh"/>
<!-- <item-->
<!-- android:id="@+id/notify_new_episodes"-->
<!-- android:menuCategory="container"-->
<!-- android:title="@string/episode_notification"-->
<!-- android:icon="@drawable/ic_notifications"/>-->
<item
android:id="@+id/autodownload"
android:menuCategory="container"
@ -40,4 +35,9 @@
android:menuCategory="container"
android:title="@string/pref_feed_associated_queue"
android:icon="@drawable/ic_playlist_play"/>
<item
android:id="@+id/export_opml"
android:menuCategory="container"
android:title="@string/opml_export_label"
android:icon="@drawable/baseline_import_export_24"/>
</menu>

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/rename_folder_item"
android:menuCategory="container"
android:title="@string/rename_tag_label" />
</menu>

View File

@ -1,3 +1,11 @@
# 6.3.2
* fixed crash of opening FeedEpisode view when "Use episode cover" is set
* fixed crash of opening EpisodeInfo view on episode with unknown media size
* fixed crash when cancelling download in a auto-download enabled feed
* fixed mis-behavior of "Untagged" filter in combination with other filters in Subscriptions view
* added "export selected feeds" in multi-select menu in Subscriptions view
# 6.3.1
* fixed crash when playing episode with missing media file

View File

@ -0,0 +1,8 @@
Version 6.3.2 brings several changes:
* fixed crash of opening FeedEpisode view when "Use episode cover" is set
* fixed crash of opening EpisodeInfo view on episode with unknown media size
* fixed crash when cancelling download in a auto-download enabled feed
* fixed mis-behavior of "Untagged" filter in combination with other filters in Subscriptions view
* added "export selected feeds" in multi-select menu in Subscriptions view