diff --git a/README.md b/README.md index 8b0104ef..39a54479 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c * Disabled `usesCleartextTraffic`, so that all content transmission is more private and secure * Settings/Preferences can now be exported and imported * Play history/progress can be separately exported/imported as Json files +* downloaded media files can be exported/imported * There is a setting to disable/enable auto backup OPML files to Google For more details of the changes, see the [Changelog](changelog.md) diff --git a/app/build.gradle b/app/build.gradle index 6d6860aa..335b740d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -126,8 +126,8 @@ android { buildConfig true } defaultConfig { - versionCode 3020209 - versionName "6.0.9" + versionCode 3020210 + versionName "6.0.10" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt index 0c59d8e7..24764e0c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt @@ -54,7 +54,6 @@ object UserPreferences { private const val PREF_QUEUE_KEEP_SORTED_ORDER: String = "prefQueueKeepSortedOrder" private const val PREF_DOWNLOADS_SORTED_ORDER = "prefDownloadSortedOrder" private const val PREF_HISTORY_SORTED_ORDER = "prefHistorySortedOrder" - private const val PREF_INBOX_SORTED_ORDER = "prefInboxSortedOrder" // Episodes private const val PREF_SORT_ALL_EPISODES: String = "prefEpisodesSort" @@ -585,14 +584,17 @@ object UserPreferences { return !feed.isLocalFeed || isAutoDeleteLocal } +// only used in test fun showSkipOnFullNotification(): Boolean { return showButtonOnFullNotification(NOTIFICATION_BUTTON_SKIP) } + // only used in test fun showNextChapterOnFullNotification(): Boolean { return showButtonOnFullNotification(NOTIFICATION_BUTTON_NEXT_CHAPTER) } + // only used in test fun showPlaybackSpeedOnFullNotification(): Boolean { return showButtonOnFullNotification(NOTIFICATION_BUTTON_PLAYBACK_SPEED) } @@ -646,6 +648,7 @@ object UserPreferences { return if (mediaType == MediaType.VIDEO) videoPlaybackSpeed else audioPlaybackSpeed } +// only used in test fun shouldPauseForFocusLoss(): Boolean { return appPrefs.getBoolean(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS, true) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt index e621c625..d18ca84a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt @@ -97,6 +97,16 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { } } + private val restoreMediaFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> this.restoreMediaFilesResult(result) } + + private val backupMediaFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + val data: Uri? = it.data?.data + if (data != null) MediaFilesTransporter.exportToDocument(data, requireContext()) + } + } + private var progressDialog: ProgressDialog? = null override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -157,6 +167,14 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { exportPreferences() true } + findPreference(PREF_MEDIAFILES_IMPORT)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { + importMediaFiles() + true + } + findPreference(PREF_MEDIAFILES_EXPORT)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { + exportMediaFiles() + true + } findPreference(PREF_FAVORITE_EXPORT)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { openExportPathPicker(Export.FAVORITES, chooseFavoritesExportPathLauncher, FavoritesWriter()) true @@ -222,6 +240,31 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { builder.show() } + private fun exportMediaFiles() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.addCategory(Intent.CATEGORY_DEFAULT) + backupMediaFilesLauncher.launch(intent) + } + + private fun importMediaFiles() { + val builder = MaterialAlertDialogBuilder(requireActivity()) + builder.setTitle(R.string.media_files_import_label) + builder.setMessage(R.string.media_files_import_notice) + + // add a button + builder.setNegativeButton(R.string.no, null) + builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.addCategory(Intent.CATEGORY_DEFAULT) + restoreMediaFilesLauncher.launch(intent) + } + + // create and show the alert dialog + builder.show() + } + private fun exportDatabase() { backupDatabaseLauncher.launch(dateStampFilename(DATABASE_EXPORT_FILENAME)) } @@ -314,7 +357,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { private fun restoreProgressResult(result: ActivityResult) { if (result.resultCode != RESULT_OK || result.data?.data == null) return - val uri = result.data!!.data!! + val uri = result.data!!.data uri?.let { if (isJsonFile(uri)) { progressDialog!!.show() @@ -379,25 +422,67 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { return fileName.endsWith(".realm", ignoreCase = true) } + private fun isPrefDir(uri: Uri): Boolean { + val fileName = uri.lastPathSegment ?: return false + return fileName.contains("Podcini-Prefs", ignoreCase = true) + } + + private fun isMediaFilesDir(uri: Uri): Boolean { + val fileName = uri.lastPathSegment ?: return false + return fileName.contains("Podcini-MediaFiles", ignoreCase = true) + } + private fun restorePreferencesResult(result: ActivityResult) { if (result.resultCode != RESULT_OK || result.data?.data == null) return val uri = result.data!!.data!! - progressDialog!!.show() - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - PreferencesTransporter.importBackup(uri, requireContext()) + if (isPrefDir(uri)) { + progressDialog!!.show() + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + PreferencesTransporter.importBackup(uri, requireContext()) + } + withContext(Dispatchers.Main) { + showDatabaseImportSuccessDialog() + progressDialog!!.dismiss() + } + } catch (e: Throwable) { + showExportErrorDialog(e) } - withContext(Dispatchers.Main) { - showDatabaseImportSuccessDialog() - progressDialog!!.dismiss() - } - } catch (e: Throwable) { - showExportErrorDialog(e) } + } else { + val context = requireContext() + val message = context.getString(R.string.import_directory_toast) + "Podcini-Prefs" + showExportErrorDialog(Throwable(message)) } } + private fun restoreMediaFilesResult(result: ActivityResult) { + if (result.resultCode != RESULT_OK || result.data?.data == null) return + val uri = result.data!!.data!! + if (isMediaFilesDir(uri)) { + progressDialog!!.show() + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + MediaFilesTransporter.importBackup(uri, requireContext()) + } + withContext(Dispatchers.Main) { + showDatabaseImportSuccessDialog() + progressDialog!!.dismiss() + } + } catch (e: Throwable) { + showExportErrorDialog(e) + } + } + } else { + val context = requireContext() + val message = context.getString(R.string.import_directory_toast) + "Podcini-MediaFiles" + showExportErrorDialog(Throwable(message)) + } + } + + private fun backupDatabaseResult(uri: Uri?) { if (uri == null) return progressDialog!!.show() @@ -621,6 +706,96 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { } } + object MediaFilesTransporter { + private val TAG: String = MediaFilesTransporter::class.simpleName ?: "Anonymous" + @Throws(IOException::class) + fun exportToDocument(uri: Uri, context: Context) { + try { + val mediaDir = context.getExternalFilesDir("media") ?: return + val chosenDir = DocumentFile.fromTreeUri(context, uri) ?: throw IOException("Destination directory is not valid") + val exportSubDir = chosenDir.createDirectory("Podcini-MediaFiles") ?: throw IOException("Error creating subdirectory Podcini-Prefs") + mediaDir.listFiles()?.forEach { file -> + copyRecursive(context, file, mediaDir, exportSubDir) + } + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { } + } + private fun copyRecursive(context: Context, srcFile: File, srcRootDir: File, destRootDir: DocumentFile) { + val relativePath = srcFile.absolutePath.substring(srcRootDir.absolutePath.length+1) + if (srcFile.isDirectory) { + val dirFiles = srcFile.listFiles() + if (!dirFiles.isNullOrEmpty()) { + val destDir = destRootDir.findFile(relativePath) ?: destRootDir.createDirectory(relativePath) ?: return + dirFiles.forEach { file -> + copyRecursive(context, file, srcFile, destDir) + } + } + } else { + val destFile = destRootDir.createFile("application/octet-stream", relativePath) ?: return + copyFile(srcFile, destFile, context) + } + } + private fun copyFile(sourceFile: File, destFile: DocumentFile, context: Context) { + try { + val outputStream = context.contentResolver.openOutputStream(destFile.uri) ?: return + val inputStream = FileInputStream(sourceFile) + copyStream(inputStream, outputStream) + inputStream.close() + outputStream.close() + } catch (e: IOException) { + Log.e("Error", "Error copying file: $e") + throw e + } + } + private fun copyRecursive(context: Context, srcFile: DocumentFile, srcRootDir: DocumentFile, destRootDir: File) { + val relativePath = srcFile.uri.path?.substring(srcRootDir.uri.path!!.length) ?: return + val destFile = File(destRootDir, relativePath) + if (srcFile.isDirectory) { + if (!destFile.exists()) destFile.mkdirs() + srcFile.listFiles().forEach { file -> + copyRecursive(context, file, srcFile, destFile) + } + } else { + if (!destFile.exists()) copyFile(srcFile, destFile, context) + } + } + private fun copyFile(sourceFile: DocumentFile, destFile: File, context: Context) { + try { + val inputStream = context.contentResolver.openInputStream(sourceFile.uri) ?: return + val outputStream = FileOutputStream(destFile) + copyStream(inputStream, outputStream) + inputStream.close() + outputStream.close() + } catch (e: IOException) { + Log.e("Error", "Error copying file: $e") + throw e + } + } + private fun copyStream(inputStream: InputStream, outputStream: OutputStream) { + val buffer = ByteArray(1024) + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + @Throws(IOException::class) + fun importBackup(uri: Uri, context: Context) { + try { + val exportedDir = DocumentFile.fromTreeUri(context, uri) ?: throw IOException("Backup directory is not valid") + if (exportedDir.name?.contains("Podcini-MediaFiles") != true) return + val mediaDir = context.getExternalFilesDir("media") ?: return + exportedDir.listFiles().forEach { file -> + copyRecursive(context, file, exportedDir, mediaDir) + } + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { } + } + } + object DatabaseTransporter { private val TAG: String = DatabaseTransporter::class.simpleName ?: "Anonymous" @Throws(IOException::class) @@ -898,6 +1073,8 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { private const val PREF_HTML_EXPORT = "prefHtmlExport" private const val PREF_PREFERENCES_IMPORT = "prefPrefImport" private const val PREF_PREFERENCES_EXPORT = "prefPrefExport" + private const val PREF_MEDIAFILES_IMPORT = "prefMediaFilesImport" + private const val PREF_MEDIAFILES_EXPORT = "prefMediaFilesExport" private const val PREF_DATABASE_IMPORT = "prefDatabaseImport" private const val PREF_DATABASE_EXPORT = "prefDatabaseExport" private const val PREF_FAVORITE_EXPORT = "prefFavoritesExport" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1d8904e9..b78ea266 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -599,6 +599,7 @@ Move subscriptions and queue to another device Preferences + Media files Database OPML HTML @@ -606,8 +607,10 @@ Show your subscriptions to a friend Transfer your subscriptions to another podcast app Import your subscriptions from another podcast app - Transfer Podcini preferences to Podcini on another device - Import Podcini preferences from another device + Export Podcini preferences + Import Podcini preferences + Export Podcini media files + Import Podcini media files Transfer subscriptions, listened episodes and queue to Podcini on another device Import Podcini database from another device OPML import @@ -623,13 +626,17 @@ Importing episodes progress will replace all of your current playing history. You should export your current progress as a backup. Do you want to replace\? If yes, in the next screen, choose the desired .json file. The process can take a couple minutes depending on size. Once completed, a popup of either success or failure will be shown. OPML export HTML export + Media files export + Media files import Preferences export Preferences import Importing preferences will replace all of your current preferences. If confirmed, choose a previously exported directory with name containing \"Podcini-Prefs\" + Choose a previously exported directory with name containing \"Podcini-MediaFiles\" Database export Database import Realm database import Importing a database will replace all of your current subscriptions and playing history. You should export your current database as a backup. Do you want to replace\? If yes, in the next screen, choose a file with extension .realm + Only accepting directory name including: Only accepting file extension: Please wait… Export error diff --git a/app/src/main/res/xml/preferences_import_export.xml b/app/src/main/res/xml/preferences_import_export.xml index ad6c8920..3822b50a 100644 --- a/app/src/main/res/xml/preferences_import_export.xml +++ b/app/src/main/res/xml/preferences_import_export.xml @@ -16,6 +16,19 @@ android:summary="@string/database_import_summary"/> + + + + + Import/Export: * preferences files * OPML file * json file of episodes progress +* downloaded media files (5.5.4 only) An OPML file should be imported before importing episodes progress, but you can always re-do any of the above -These files can be best exported in version 5.5.3 in Settings -> Import/Export +These files can be best exported in version 5.5.4 (or 5.5.3, or 5.5.2) in Settings -> Import/Export Then your subscriptions, listening progress, favorites, and app preferences will be updated.