From 4377b7133ebbd011b538dcb9c7aeb4b08bfe6693 Mon Sep 17 00:00:00 2001 From: Xilin Jia <6257601+XilinJia@users.noreply.github.com> Date: Sat, 21 Dec 2024 14:41:48 +0100 Subject: [PATCH] 6.16.4 commit --- README.md | 5 + app/build.gradle | 16 +- .../ac/mdiq/podcini/preferences/AutoBackup.kt | 77 +++ .../podcini/preferences/ComboTransporter.kt | 293 +++++++++ .../podcini/preferences/MiscTransporters.kt | 220 +++++++ .../podcini/preferences/UserPreferences.kt | 7 + .../podcini/preferences/screens/Downloads.kt | 1 - .../preferences/screens/ImportExport.kt | 576 +++--------------- .../mdiq/podcini/storage/database/Episodes.kt | 21 +- .../podcini/ui/actions/EpisodeActionButton.kt | 2 +- .../mdiq/podcini/ui/actions/SwipeActions.kt | 192 +++--- .../mdiq/podcini/ui/activity/MainActivity.kt | 5 +- .../podcini/ui/activity/PreferenceActivity.kt | 56 +- .../ui/fragment/AudioPlayerFragment.kt | 17 +- .../podcini/ui/fragment/EpisodesFragment.kt | 18 +- .../ui/fragment/FeedEpisodesFragment.kt | 34 +- .../ui/fragment/OnlineEpisodesFragment.kt | 17 +- .../podcini/ui/fragment/QueuesFragment.kt | 31 +- .../podcini/ui/fragment/SearchFragment.kt | 37 +- .../ui/fragment/SearchResultsFragment.kt | 6 +- .../ui/fragment/SubscriptionsFragment.kt | 5 +- .../kotlin/ac/mdiq/podcini/util/FlowEvent.kt | 2 +- .../ac/mdiq/podcini/util/MiscFormatter.kt | 4 + app/src/main/res/values/strings.xml | 7 + changelog.md | 14 + .../android/en-US/changelogs/3020326.txt | 13 + 26 files changed, 984 insertions(+), 692 deletions(-) create mode 100644 app/src/main/kotlin/ac/mdiq/podcini/preferences/AutoBackup.kt create mode 100644 app/src/main/kotlin/ac/mdiq/podcini/preferences/ComboTransporter.kt create mode 100644 app/src/main/kotlin/ac/mdiq/podcini/preferences/MiscTransporters.kt create mode 100644 fastlane/metadata/android/en-US/changelogs/3020326.txt diff --git a/README.md b/README.md index 6699148e..694d0820 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,11 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c * copy the previous directories "Podcini-Prefs" and/or "Podcini-MediaFiles" into the above directory * no need to copy all three, only the ones you need * then do the combo import +* There is an option to turn on auto backup in Settings->Import/Export + * if turned on, one needs to specify interval (in hours), a folder, and number of copies to keep + * then Preferences and DB are backed up in sub-folder named "Podcini-AudoBackups-(date)" + * backup time is on the next resume of Podcini after interval hours from last backup time + * to restore, use Combo restore * Play history/progress can be separately exported/imported as Json files * 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 diff --git a/app/build.gradle b/app/build.gradle index dcb718d3..d015c39a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,8 +26,8 @@ android { vectorDrawables.useSupportLibrary false vectorDrawables.generatedDensities = [] - versionCode 3020325 - versionName "6.16.3" + versionCode 3020326 + versionName "6.16.4" ndkVersion "27.0.12077973" @@ -196,18 +196,18 @@ dependencies { implementation "androidx.work:work-runtime:2.10.0" implementation "androidx.fragment:fragment-ktx:1.8.5" - implementation "androidx.media3:media3-exoplayer:1.5.0" - implementation "androidx.media3:media3-datasource-okhttp:1.5.0" - implementation "androidx.media3:media3-ui:1.5.0" - implementation "androidx.media3:media3-common:1.5.0" - implementation "androidx.media3:media3-session:1.5.0" + implementation "androidx.media3:media3-exoplayer:1.5.1" + implementation "androidx.media3:media3-datasource-okhttp:1.5.1" + implementation "androidx.media3:media3-ui:1.5.1" + implementation "androidx.media3:media3-common:1.5.1" + implementation "androidx.media3:media3-session:1.5.1" implementation "com.google.android.material:material:1.12.0" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" /** Desugaring for using VistaGuide **/ - coreLibraryDesugaring "com.android.tools:desugar_jdk_libs_nio:2.1.3" + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs_nio:2.1.4" implementation "com.github.XilinJia.vistaguide:VistaGuide:lv0.24.2.6" implementation "com.github.skydoves:balloon:1.6.6" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/AutoBackup.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/AutoBackup.kt new file mode 100644 index 00000000..6774a799 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/AutoBackup.kt @@ -0,0 +1,77 @@ +package ac.mdiq.podcini.preferences + +import ac.mdiq.podcini.preferences.UserPreferences.appPrefs +import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.MiscFormatter.dateStampFilename +import android.app.Activity +import android.net.Uri +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.IOException + +fun autoBackup(activity: Activity) { + val TAG = "autoBackup" + + val backupDirName = "Podcini-AudoBackups" + val prefsDirName = "Podcini-Prefs" + + val isAutoBackup = appPrefs.getBoolean(UserPreferences.Prefs.prefAutoBackup.name, false) + if (!isAutoBackup) return + val uriString = appPrefs.getString(UserPreferences.Prefs.prefAutoBackupFolder.name, "") + if (uriString.isNullOrBlank()) return + + Logd("autoBackup", "in autoBackup directory: $uriString") + + fun deleteDirectoryAndContents(directory: DocumentFile): Boolean { + if (directory.isDirectory) { + directory.listFiles().forEach { file -> + if (file.isDirectory) deleteDirectoryAndContents(file) + Logd(TAG, "deleting ${file.name}") + file.delete() + } + } + return directory.delete() + } + + CoroutineScope(Dispatchers.IO).launch { + val interval = appPrefs.getInt(UserPreferences.Prefs.prefAutoBackupIntervall.name, 24) + var lastBackupTime = appPrefs.getLong(UserPreferences.Prefs.prefAutoBackupTimeStamp.name, 0L) + val curTime = System.currentTimeMillis() + if ((curTime - lastBackupTime) / 1000 / 3600 > interval) { + val uri = Uri.parse(uriString) + val permissions = activity.contentResolver.persistedUriPermissions.find { it.uri == uri } + if (permissions != null && permissions.isReadPermission && permissions.isWritePermission) { + val chosenDir = DocumentFile.fromTreeUri(activity, uri) + if (chosenDir != null) { + val backupDirs = mutableListOf() + try { + if (chosenDir.isDirectory) { + chosenDir.listFiles().forEach { file -> + Logd(TAG, "file: $file") + if (file.isDirectory && file.name?.startsWith(backupDirName, ignoreCase = true) == true) backupDirs.add(file) + } + } + Logd(TAG, "backupDirs: ${backupDirs.size}") + val limit = appPrefs.getInt(UserPreferences.Prefs.prefAutoBackupLimit.name, 2) + if (backupDirs.size >= limit) { + backupDirs.sortBy { it.name } + for (i in 0..(backupDirs.size - limit)) deleteDirectoryAndContents(backupDirs[i]) + } + + val dirName = dateStampFilename("$backupDirName-%s") + val exportSubDir = chosenDir.createDirectory(dirName) ?: throw IOException("Error creating subdirectory $dirName") + val subUri: Uri = exportSubDir.uri + PreferencesTransporter(prefsDirName).exportToDocument(subUri, activity) + val realmFile = exportSubDir.createFile("application/octet-stream", "backup.realm") + if (realmFile != null) DatabaseTransporter().exportToDocument(realmFile.uri, activity) + + appPrefs.edit().putLong(UserPreferences.Prefs.prefAutoBackupTimeStamp.name, curTime).apply() + } catch (e: Exception) { Log.e("autoBackup", "Error backing up ${e.message}") } + } + } else Log.e("autoBackup", "Uri permissions are no longer valid") + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/ComboTransporter.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/ComboTransporter.kt new file mode 100644 index 00000000..08c48b84 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/ComboTransporter.kt @@ -0,0 +1,293 @@ +package ac.mdiq.podcini.preferences + +import ac.mdiq.podcini.BuildConfig +import ac.mdiq.podcini.storage.database.Feeds.getFeedList +import ac.mdiq.podcini.storage.database.RealmDB.realm +import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk +import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.Feed +import ac.mdiq.podcini.storage.utils.FileNameGenerator.generateFileName +import ac.mdiq.podcini.util.Logd +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.text.format.Formatter +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import org.apache.commons.io.FileUtils +import org.apache.commons.io.IOUtils +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.nio.channels.FileChannel + +class PreferencesTransporter(val prefsDirName: String) { + val TAG = "PreferencesTransporter" + + @Throws(IOException::class) + fun exportToDocument(uri: Uri, context: Context) { + try { + val chosenDir = DocumentFile.fromTreeUri(context, uri) ?: throw IOException("Destination directory is not valid") + val exportSubDir = chosenDir.createDirectory(prefsDirName) ?: throw IOException("Error creating subdirectory $prefsDirName") + val sharedPreferencesDir = context.applicationContext.filesDir.parentFile?.listFiles { file -> file.name.startsWith("shared_prefs") }?.firstOrNull() + if (sharedPreferencesDir != null) { + sharedPreferencesDir.listFiles()!!.forEach { file -> + val destFile = exportSubDir.createFile("text/xml", file.name) + if (destFile != null) copyFile(file, destFile, context) + } + } else Log.e("Error", "shared_prefs directory not found") + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { } + } + private fun copyFile(sourceFile: File, destFile: DocumentFile, context: Context) { + try { + val inputStream = FileInputStream(sourceFile) + val outputStream = context.contentResolver.openOutputStream(destFile.uri) + if (outputStream != null) copyStream(inputStream, outputStream) + inputStream.close() + outputStream?.close() + } catch (e: IOException) { + Log.e("Error", "Error copying file: $e") + throw e + } + } + private fun copyFile(sourceFile: DocumentFile, destFile: File, context: Context) { + try { + val inputStream = context.contentResolver.openInputStream(sourceFile.uri) + val outputStream = FileOutputStream(destFile) + if (inputStream != null) 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") + val sharedPreferencesDir = context.applicationContext.filesDir.parentFile?.listFiles { file -> file.name.startsWith("shared_prefs") }?.firstOrNull() + if (sharedPreferencesDir != null) sharedPreferencesDir.listFiles()?.forEach { file -> file.delete() } + else Log.e("Error", "shared_prefs directory not found") + val files = exportedDir.listFiles() + var hasPodciniRPrefs = false + for (file in files) { + if (file?.isFile == true && file.name?.endsWith(".xml") == true && file.name!!.contains("podcini.R")) { + hasPodciniRPrefs = true + break + } + } + for (file in files) { + if (file?.isFile == true && file.name?.endsWith(".xml") == true) { + var destName = file.name!! + if (destName.contains("PlayerWidgetPrefs")) continue +// for importing from Podcini version 5 and below + if (!hasPodciniRPrefs) { + when { + destName.contains("podcini") -> destName = destName.replace("podcini", "podcini.R") + destName.contains("EpisodeItemListRecyclerView") -> destName = destName.replace("EpisodeItemListRecyclerView", "EpisodesRecyclerView") + } + } + when { + BuildConfig.DEBUG && !destName.contains(".debug") -> destName = destName.replace("podcini.R", "podcini.R.debug") + !BuildConfig.DEBUG && destName.contains(".debug") -> destName = destName.replace(".debug", "") + } + val destFile = File(sharedPreferencesDir, destName) + copyFile(file, destFile, context) + } + } + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { } + } +} + +class MediaFilesTransporter(val mediaFilesDirName: String) { + val TAG = "MediaFilesTransporter" + + var feed: Feed? = null + private val nameFeedMap: MutableMap = mutableMapOf() + private val nameEpisodeMap: MutableMap = mutableMapOf() + @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(mediaFilesDirName) ?: throw IOException("Error creating subdirectory $mediaFilesDirName") + 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+1) ?: return + if (srcFile.isDirectory) { + Logd(TAG, "copyRecursive folder title: $relativePath") + feed = nameFeedMap[relativePath] ?: return + Logd(TAG, "copyRecursive found feed: ${feed?.title}") + nameEpisodeMap.clear() + feed!!.episodes.forEach { e -> if (!e.title.isNullOrEmpty()) nameEpisodeMap[generateFileName(e.title!!)] = e } + val destFile = File(destRootDir, relativePath) + if (!destFile.exists()) destFile.mkdirs() + srcFile.listFiles().forEach { file -> copyRecursive(context, file, srcFile, destFile) } + } else { + val nameParts = relativePath.split(".") + if (nameParts.size < 3) return + val ext = nameParts[nameParts.size-1] + val title = nameParts.dropLast(2).joinToString(".") + Logd(TAG, "copyRecursive file title: $title") + val episode = nameEpisodeMap[title] ?: return + Logd(TAG, "copyRecursive found episode: ${episode.title}") + val destName = "$title.${episode.id}.$ext" + val destFile = File(destRootDir, destName) + if (!destFile.exists()) { + Logd(TAG, "copyRecursive copying file to: ${destFile.absolutePath}") + copyFile(srcFile, destFile, context) + upsertBlk(episode) { + it.media?.fileUrl = destFile.absolutePath + it.media?.setIsDownloaded() + } + } + } + } + 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(mediaFilesDirName) != true) return + val mediaDir = context.getExternalFilesDir("media") ?: return + val fileList = exportedDir.listFiles() + if (fileList.isNotEmpty()) { + val feeds = getFeedList() + feeds.forEach { f -> if (!f.title.isNullOrEmpty()) nameFeedMap[generateFileName(f.title!!)] = f } + fileList.forEach { file -> copyRecursive(context, file, exportedDir, mediaDir) } + } + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { + nameFeedMap.clear() + nameEpisodeMap.clear() + feed = null + } + } +} + +class DatabaseTransporter { + val TAG = "DatabaseTransporter" + + @Throws(IOException::class) + fun exportToDocument(uri: Uri?, context: Context) { + var pfd: ParcelFileDescriptor? = null + var fileOutputStream: FileOutputStream? = null + try { + pfd = context.contentResolver.openFileDescriptor(uri!!, "wt") + fileOutputStream = FileOutputStream(pfd!!.fileDescriptor) + exportToStream(fileOutputStream, context) + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { + IOUtils.closeQuietly(fileOutputStream) + if (pfd != null) try { pfd.close() } catch (e: IOException) { Logd(TAG, "Unable to close ParcelFileDescriptor") } + } + } + @Throws(IOException::class) + fun exportToStream(outFileStream: FileOutputStream, context: Context) { + var src: FileChannel? = null + var dst: FileChannel? = null + try { + val realmPath = realm.configuration.path + Logd(TAG, "exportToStream realmPath: $realmPath") + val currentDB = File(realmPath) + if (currentDB.exists()) { + src = FileInputStream(currentDB).channel + dst = outFileStream.channel + val srcSize = src.size() + dst.transferFrom(src, 0, srcSize) + val newDstSize = dst.size() + if (newDstSize != srcSize) + throw IOException(String.format("Unable to write entire database. Expected to write %s, but wrote %s.", Formatter.formatShortFileSize(context, srcSize), Formatter.formatShortFileSize(context, newDstSize))) + } else throw IOException("Can not access current database") + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { + IOUtils.closeQuietly(src) + IOUtils.closeQuietly(dst) + } + } + @Throws(IOException::class) + fun importBackup(inputUri: Uri?, context: Context) { + val TEMP_DB_NAME = "temp.realm" + var inputStream: InputStream? = null + try { + val tempDB = context.getDatabasePath(TEMP_DB_NAME) + inputStream = context.contentResolver.openInputStream(inputUri!!) + FileUtils.copyInputStreamToFile(inputStream, tempDB) + val realmPath = realm.configuration.path + val currentDB = File(realmPath) + val success = currentDB.delete() + if (!success) throw IOException("Unable to delete old database") + FileUtils.moveFile(tempDB, currentDB) + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { IOUtils.closeQuietly(inputStream) } + } +} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/MiscTransporters.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/MiscTransporters.kt new file mode 100644 index 00000000..2e8882a2 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/MiscTransporters.kt @@ -0,0 +1,220 @@ +package ac.mdiq.podcini.preferences + +import ac.mdiq.podcini.net.sync.SyncService.Companion.isValidGuid +import ac.mdiq.podcini.net.sync.model.EpisodeAction +import ac.mdiq.podcini.net.sync.model.EpisodeAction.Companion.readFromJsonObject +import ac.mdiq.podcini.net.sync.model.SyncServiceException +import ac.mdiq.podcini.storage.database.Episodes.getEpisodeByGuidOrUrl +import ac.mdiq.podcini.storage.database.Episodes.getEpisodes +import ac.mdiq.podcini.storage.database.Episodes.hasAlmostEnded +import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk +import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.EpisodeFilter +import ac.mdiq.podcini.storage.model.EpisodeSortOrder +import ac.mdiq.podcini.storage.model.Feed +import ac.mdiq.podcini.storage.model.Rating +import ac.mdiq.podcini.util.Logd +import android.content.Context +import org.apache.commons.io.IOUtils +import org.apache.commons.lang3.StringUtils +import org.json.JSONArray +import java.io.IOException +import java.io.Reader +import java.io.Writer +import java.util.Date +import java.util.TreeMap +import kotlin.collections.get + +class EpisodeProgressReader { + val TAG = "EpisodeProgressReader" + + fun readDocument(reader: Reader) { + val jsonString = reader.readText() + val jsonArray = JSONArray(jsonString) + for (i in 0 until jsonArray.length()) { + val jsonAction = jsonArray.getJSONObject(i) + Logd(TAG, "Loaded EpisodeActions message: $i $jsonAction") + val action = readFromJsonObject(jsonAction) ?: continue + Logd(TAG, "processing action: $action") + val result = processEpisodeAction(action) ?: continue +// upsertBlk(result.second) {} + } + } + private fun processEpisodeAction(action: EpisodeAction): Pair? { + val guid = if (isValidGuid(action.guid)) action.guid else null + var feedItem = getEpisodeByGuidOrUrl(guid, action.episode?:"", false) ?: return null + if (feedItem.media == null) { + Logd(TAG, "Feed item has no media: $action") + return null + } + var idRemove = 0L + feedItem = upsertBlk(feedItem) { + it.media!!.startPosition = action.started * 1000 + it.media!!.setPosition(action.position * 1000) + it.media!!.playedDuration = action.playedDuration * 1000 + it.media!!.lastPlayedTime = (action.timestamp!!.time) + it.rating = if (action.isFavorite) Rating.SUPER.code else Rating.UNRATED.code + it.playState = action.playState + if (hasAlmostEnded(it.media!!)) { + Logd(TAG, "Marking as played: $action") + it.setPlayed(true) + it.media!!.setPosition(0) + idRemove = it.id + } else Logd(TAG, "Setting position: $action") + } + return Pair(idRemove, feedItem) + } +} + +class EpisodesProgressWriter : ExportWriter { + val TAG = "EpisodesProgressWriter" + + @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) + override fun writeDocument(feeds: List, writer: Writer, context: Context) { + Logd(TAG, "Starting to write document") + val queuedEpisodeActions: MutableList = mutableListOf() + val pausedItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.paused.name), EpisodeSortOrder.DATE_NEW_OLD) + val readItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.played.name), EpisodeSortOrder.DATE_NEW_OLD) + val favoriteItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.superb.name), EpisodeSortOrder.DATE_NEW_OLD) + val comItems = mutableSetOf() + comItems.addAll(pausedItems) + comItems.addAll(readItems) + comItems.addAll(favoriteItems) + Logd(TAG, "Save state for all " + comItems.size + " played episodes") + for (item in comItems) { + val media = item.media ?: continue + val played = EpisodeAction.Builder(item, EpisodeAction.PLAY) + .timestamp(Date(media.lastPlayedTime)) + .started(media.startPosition / 1000) + .position(media.position / 1000) + .playedDuration(media.playedDuration / 1000) + .total(media.duration / 1000) + .isFavorite(item.isSUPER) + .playState(item.playState) + .build() + queuedEpisodeActions.add(played) + } + if (queuedEpisodeActions.isNotEmpty()) { + try { + Logd(TAG, "Saving ${queuedEpisodeActions.size} actions: ${StringUtils.join(queuedEpisodeActions, ", ")}") + val list = JSONArray() + for (episodeAction in queuedEpisodeActions) { + val obj = episodeAction.writeToJsonObject() + if (obj != null) { + Logd(TAG, "saving EpisodeAction: $obj") + list.put(obj) + } + } + writer.write(list.toString()) + } catch (e: Exception) { + e.printStackTrace() + throw SyncServiceException(e) + } + } + Logd(TAG, "Finished writing document") + } + override fun fileExtension(): String { + return "json" + } +} +class FavoritesWriter : ExportWriter { + val TAG = "FavoritesWriter" + + private val FAVORITE_TEMPLATE = "html-export-favorites-item-template.html" + private val FEED_TEMPLATE = "html-export-feed-template.html" + private val UTF_8 = "UTF-8" + @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) + override fun writeDocument(feeds: List, writer: Writer, context: Context) { + Logd(TAG, "Starting to write document") + val templateStream = context.assets.open("html-export-template.html") + var template = IOUtils.toString(templateStream, UTF_8) + template = template.replace("\\{TITLE\\}".toRegex(), "Favorites") + val templateParts = template.split("\\{FEEDS\\}".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val favTemplateStream = context.assets.open(FAVORITE_TEMPLATE) + val favTemplate = IOUtils.toString(favTemplateStream, UTF_8) + val feedTemplateStream = context.assets.open(FEED_TEMPLATE) + val feedTemplate = IOUtils.toString(feedTemplateStream, UTF_8) + val allFavorites = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.superb.name), EpisodeSortOrder.DATE_NEW_OLD) + val favoritesByFeed = buildFeedMap(allFavorites) + writer.append(templateParts[0]) + for (feedId in favoritesByFeed.keys) { + val favorites: List = favoritesByFeed[feedId]!! + if (favorites[0].feed == null) continue + writer.append("
  • \n") + writeFeed(writer, favorites[0].feed!!, feedTemplate) + writer.append("
      \n") + for (item in favorites) writeFavoriteItem(writer, item, favTemplate) + writer.append("
  • \n") + } + writer.append(templateParts[1]) + Logd(TAG, "Finished writing document") + } + /** + * Group favorite episodes by feed, sorting them by publishing date in descending order. + * @param favoritesList `List` of all favorite episodes. + * @return A `Map` favorite episodes, keyed by feed ID. + */ + private fun buildFeedMap(favoritesList: List): Map> { + val feedMap: MutableMap> = TreeMap() + for (item in favoritesList) { + var feedEpisodes = feedMap[item.feedId] + if (feedEpisodes == null) { + feedEpisodes = ArrayList() + if (item.feedId != null) feedMap[item.feedId!!] = feedEpisodes + } + feedEpisodes.add(item) + } + return feedMap + } + @Throws(IOException::class) + private fun writeFeed(writer: Writer, feed: Feed, feedTemplate: String) { + val feedInfo = feedTemplate + .replace("{FEED_IMG}", feed.imageUrl?:"") + .replace("{FEED_TITLE}", feed.title?:" No title") + .replace("{FEED_LINK}", feed.link?: "") + .replace("{FEED_WEBSITE}", feed.downloadUrl?:"") + writer.append(feedInfo) + } + @Throws(IOException::class) + private fun writeFavoriteItem(writer: Writer, item: Episode, favoriteTemplate: String) { + var favItem = favoriteTemplate.replace("{FAV_TITLE}", item.title!!.trim { it <= ' ' }) + favItem = if (item.link != null) favItem.replace("{FAV_WEBSITE}", item.link!!) + else favItem.replace("{FAV_WEBSITE}", "") + favItem = + if (item.media != null && item.media!!.downloadUrl != null) favItem.replace("{FAV_MEDIA}", item.media!!.downloadUrl!!) + else favItem.replace("{FAV_MEDIA}", "") + writer.append(favItem) + } + override fun fileExtension(): String { + return "html" + } +} +class HtmlWriter : ExportWriter { + val TAG = "HtmlWriter" + + @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) + override fun writeDocument(feeds: List, writer: Writer, context: Context) { + Logd(TAG, "Starting to write document") + val templateStream = context.assets.open("html-export-template.html") + var template = IOUtils.toString(templateStream, "UTF-8") + template = template.replace("\\{TITLE\\}".toRegex(), "Subscriptions") + val templateParts = template.split("\\{FEEDS\\}".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + writer.append(templateParts[0]) + for (feed in feeds) { + writer.append("
  • ") + writer.append(feed.title) + writer.append(" WebsiteFeed

  • \n") + } + writer.append(templateParts[1]) + Logd(TAG, "Finished writing document") + } + override fun fileExtension(): String { + return "html" + } +} 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 6da91342..c0f75be0 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt @@ -281,6 +281,13 @@ object UserPreferences { prefSkipKeepsEpisode, prefRemoveFromQueueMarkedPlayed, prefFavoriteKeepsEpisode, + + prefAutoBackup, + prefAutoBackupIntervall, + prefAutoBackupFolder, + prefAutoBackupLimit, + prefAutoBackupTimeStamp, + prefAutoDelete, prefAutoDeleteLocal, prefPlaybackSpeedArray, diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/screens/Downloads.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/screens/Downloads.kt index c65b5696..24aa5073 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/screens/Downloads.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/screens/Downloads.kt @@ -349,7 +349,6 @@ enum class EpisodeCleanupOptions(val res: Int, val num: Int) { fun AutoDownloadPreferencesScreen() { val textColor = MaterialTheme.colorScheme.onSurface val scrollState = rememberScrollState() -// supportActionBar!!.setTitle(R.string.pref_automatic_download_title) Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) { var isEnabled by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefEnableAutoDl.name, false)) } Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/screens/ImportExport.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/screens/ImportExport.kt index c6d2d08d..92e8f9d4 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/screens/ImportExport.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/screens/ImportExport.kt @@ -1,50 +1,43 @@ package ac.mdiq.podcini.preferences.screens -import ac.mdiq.podcini.BuildConfig import ac.mdiq.podcini.PodciniApp.Companion.forceRestart import ac.mdiq.podcini.R -import ac.mdiq.podcini.net.sync.SyncService.Companion.isValidGuid -import ac.mdiq.podcini.net.sync.model.EpisodeAction -import ac.mdiq.podcini.net.sync.model.EpisodeAction.Companion.readFromJsonObject -import ac.mdiq.podcini.net.sync.model.SyncServiceException import ac.mdiq.podcini.preferences.* import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlElement import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlWriter -import ac.mdiq.podcini.storage.database.Episodes.getEpisodeByGuidOrUrl -import ac.mdiq.podcini.storage.database.Episodes.getEpisodes -import ac.mdiq.podcini.storage.database.Episodes.hasAlmostEnded -import ac.mdiq.podcini.storage.database.Feeds.getFeedList -import ac.mdiq.podcini.storage.database.RealmDB.realm -import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk -import ac.mdiq.podcini.storage.model.* -import ac.mdiq.podcini.storage.utils.FileNameGenerator.generateFileName +import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.ui.activity.PreferenceActivity import ac.mdiq.podcini.ui.compose.ComfirmDialog import ac.mdiq.podcini.ui.compose.CustomTextStyles import ac.mdiq.podcini.ui.compose.OpmlImportSelectionDialog import ac.mdiq.podcini.ui.compose.TitleSummaryActionColumn import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.MiscFormatter.dateStampFilename import android.app.Activity.RESULT_OK import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri -import android.os.ParcelFileDescriptor -import android.text.format.Formatter import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.core.app.ShareCompat.IntentBuilder @@ -55,469 +48,18 @@ import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.apache.commons.io.FileUtils -import org.apache.commons.io.IOUtils -import org.apache.commons.lang3.StringUtils -import org.json.JSONArray -import java.io.* -import java.nio.channels.FileChannel -import java.text.SimpleDateFormat -import java.util.* +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader @Composable fun ImportExportPreferencesScreen(activity: PreferenceActivity) { val TAG = "ImportExportPreferencesScreen" val backupDirName = "Podcini-Backups" - var prefsDirName = "Podcini-Prefs" + val prefsDirName = "Podcini-Prefs" val mediaFilesDirName = "Podcini-MediaFiles" - class PreferencesTransporter { - @Throws(IOException::class) - fun exportToDocument(uri: Uri, context: Context) { - try { - val chosenDir = DocumentFile.fromTreeUri(context, uri) ?: throw IOException("Destination directory is not valid") - val exportSubDir = chosenDir.createDirectory(prefsDirName) ?: throw IOException("Error creating subdirectory $prefsDirName") - val sharedPreferencesDir = context.applicationContext.filesDir.parentFile?.listFiles { file -> file.name.startsWith("shared_prefs") }?.firstOrNull() - if (sharedPreferencesDir != null) { - sharedPreferencesDir.listFiles()!!.forEach { file -> - val destFile = exportSubDir.createFile("text/xml", file.name) - if (destFile != null) copyFile(file, destFile, context) - } - } else Log.e("Error", "shared_prefs directory not found") - } catch (e: IOException) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { } - } - private fun copyFile(sourceFile: File, destFile: DocumentFile, context: Context) { - try { - val inputStream = FileInputStream(sourceFile) - val outputStream = context.contentResolver.openOutputStream(destFile.uri) - if (outputStream != null) copyStream(inputStream, outputStream) - inputStream.close() - outputStream?.close() - } catch (e: IOException) { - Log.e("Error", "Error copying file: $e") - throw e - } - } - private fun copyFile(sourceFile: DocumentFile, destFile: File, context: Context) { - try { - val inputStream = context.contentResolver.openInputStream(sourceFile.uri) - val outputStream = FileOutputStream(destFile) - if (inputStream != null) 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") - val sharedPreferencesDir = context.applicationContext.filesDir.parentFile?.listFiles { file -> file.name.startsWith("shared_prefs") }?.firstOrNull() - if (sharedPreferencesDir != null) sharedPreferencesDir.listFiles()?.forEach { file -> file.delete() } - else Log.e("Error", "shared_prefs directory not found") - val files = exportedDir.listFiles() - var hasPodciniRPrefs = false - for (file in files) { - if (file?.isFile == true && file.name?.endsWith(".xml") == true && file.name!!.contains("podcini.R")) { - hasPodciniRPrefs = true - break - } - } - for (file in files) { - if (file?.isFile == true && file.name?.endsWith(".xml") == true) { - var destName = file.name!! - if (destName.contains("PlayerWidgetPrefs")) continue -// for importing from Podcini version 5 and below - if (!hasPodciniRPrefs) { - when { - destName.contains("podcini") -> destName = destName.replace("podcini", "podcini.R") - destName.contains("EpisodeItemListRecyclerView") -> destName = destName.replace("EpisodeItemListRecyclerView", "EpisodesRecyclerView") - } - } - when { - BuildConfig.DEBUG && !destName.contains(".debug") -> destName = destName.replace("podcini.R", "podcini.R.debug") - !BuildConfig.DEBUG && destName.contains(".debug") -> destName = destName.replace(".debug", "") - } - val destFile = File(sharedPreferencesDir, destName) - copyFile(file, destFile, context) - } - } - } catch (e: IOException) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { } - } - } - class MediaFilesTransporter { - var feed: Feed? = null - private val nameFeedMap: MutableMap = mutableMapOf() - private val nameEpisodeMap: MutableMap = mutableMapOf() - @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(mediaFilesDirName) ?: throw IOException("Error creating subdirectory $mediaFilesDirName") - 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+1) ?: return - if (srcFile.isDirectory) { - Logd(TAG, "copyRecursive folder title: $relativePath") - feed = nameFeedMap[relativePath] ?: return - Logd(TAG, "copyRecursive found feed: ${feed?.title}") - nameEpisodeMap.clear() - feed!!.episodes.forEach { e -> if (!e.title.isNullOrEmpty()) nameEpisodeMap[generateFileName(e.title!!)] = e } - val destFile = File(destRootDir, relativePath) - if (!destFile.exists()) destFile.mkdirs() - srcFile.listFiles().forEach { file -> copyRecursive(context, file, srcFile, destFile) } - } else { - val nameParts = relativePath.split(".") - if (nameParts.size < 3) return - val ext = nameParts[nameParts.size-1] - val title = nameParts.dropLast(2).joinToString(".") - Logd(TAG, "copyRecursive file title: $title") - val episode = nameEpisodeMap[title] ?: return - Logd(TAG, "copyRecursive found episode: ${episode.title}") - val destName = "$title.${episode.id}.$ext" - val destFile = File(destRootDir, destName) - if (!destFile.exists()) { - Logd(TAG, "copyRecursive copying file to: ${destFile.absolutePath}") - copyFile(srcFile, destFile, context) - upsertBlk(episode) { - it.media?.fileUrl = destFile.absolutePath - it.media?.setIsDownloaded() - } - } - } - } - 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(mediaFilesDirName) != true) return - val mediaDir = context.getExternalFilesDir("media") ?: return - val fileList = exportedDir.listFiles() - if (fileList.isNotEmpty()) { - val feeds = getFeedList() - feeds.forEach { f -> if (!f.title.isNullOrEmpty()) nameFeedMap[generateFileName(f.title!!)] = f } - fileList.forEach { file -> copyRecursive(context, file, exportedDir, mediaDir) } - } - } catch (e: IOException) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { - nameFeedMap.clear() - nameEpisodeMap.clear() - feed = null - } - } - } - class DatabaseTransporter { - @Throws(IOException::class) - fun exportToDocument(uri: Uri?, context: Context) { - var pfd: ParcelFileDescriptor? = null - var fileOutputStream: FileOutputStream? = null - try { - pfd = context.contentResolver.openFileDescriptor(uri!!, "wt") - fileOutputStream = FileOutputStream(pfd!!.fileDescriptor) - exportToStream(fileOutputStream, context) - } catch (e: IOException) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { - IOUtils.closeQuietly(fileOutputStream) - if (pfd != null) try { pfd.close() } catch (e: IOException) { Logd(TAG, "Unable to close ParcelFileDescriptor") } - } - } - @Throws(IOException::class) - fun exportToStream(outFileStream: FileOutputStream, context: Context) { - var src: FileChannel? = null - var dst: FileChannel? = null - try { - val realmPath = realm.configuration.path - Logd(TAG, "exportToStream realmPath: $realmPath") - val currentDB = File(realmPath) - if (currentDB.exists()) { - src = FileInputStream(currentDB).channel - dst = outFileStream.channel - val srcSize = src.size() - dst.transferFrom(src, 0, srcSize) - val newDstSize = dst.size() - if (newDstSize != srcSize) - throw IOException(String.format("Unable to write entire database. Expected to write %s, but wrote %s.", Formatter.formatShortFileSize(context, srcSize), Formatter.formatShortFileSize(context, newDstSize))) - } else throw IOException("Can not access current database") - } catch (e: IOException) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { - IOUtils.closeQuietly(src) - IOUtils.closeQuietly(dst) - } - } - @Throws(IOException::class) - fun importBackup(inputUri: Uri?, context: Context) { - val TEMP_DB_NAME = "temp.realm" - var inputStream: InputStream? = null - try { - val tempDB = context.getDatabasePath(TEMP_DB_NAME) - inputStream = context.contentResolver.openInputStream(inputUri!!) - FileUtils.copyInputStreamToFile(inputStream, tempDB) - val realmPath = realm.configuration.path - val currentDB = File(realmPath) - val success = currentDB.delete() - if (!success) throw IOException("Unable to delete old database") - FileUtils.moveFile(tempDB, currentDB) - } catch (e: IOException) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { IOUtils.closeQuietly(inputStream) } - } - } - class EpisodeProgressReader { - fun readDocument(reader: Reader) { - val jsonString = reader.readText() - val jsonArray = JSONArray(jsonString) - for (i in 0 until jsonArray.length()) { - val jsonAction = jsonArray.getJSONObject(i) - Logd(TAG, "Loaded EpisodeActions message: $i $jsonAction") - val action = readFromJsonObject(jsonAction) ?: continue - Logd(TAG, "processing action: $action") - val result = processEpisodeAction(action) ?: continue -// upsertBlk(result.second) {} - } - } - private fun processEpisodeAction(action: EpisodeAction): Pair? { - val guid = if (isValidGuid(action.guid)) action.guid else null - var feedItem = getEpisodeByGuidOrUrl(guid, action.episode?:"", false) ?: return null - if (feedItem.media == null) { - Logd(TAG, "Feed item has no media: $action") - return null - } - var idRemove = 0L - feedItem = upsertBlk(feedItem) { - it.media!!.startPosition = action.started * 1000 - it.media!!.setPosition(action.position * 1000) - it.media!!.playedDuration = action.playedDuration * 1000 - it.media!!.lastPlayedTime = (action.timestamp!!.time) - it.rating = if (action.isFavorite) Rating.SUPER.code else Rating.UNRATED.code - it.playState = action.playState - if (hasAlmostEnded(it.media!!)) { - Logd(TAG, "Marking as played: $action") - it.setPlayed(true) - it.media!!.setPosition(0) - idRemove = it.id - } else Logd(TAG, "Setting position: $action") - } - return Pair(idRemove, feedItem) - } - } - class EpisodesProgressWriter : ExportWriter { - @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) - override fun writeDocument(feeds: List, writer: Writer, context: Context) { - Logd(TAG, "Starting to write document") - val queuedEpisodeActions: MutableList = mutableListOf() - val pausedItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.paused.name), EpisodeSortOrder.DATE_NEW_OLD) - val readItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.played.name), EpisodeSortOrder.DATE_NEW_OLD) - val favoriteItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.superb.name), EpisodeSortOrder.DATE_NEW_OLD) - val comItems = mutableSetOf() - comItems.addAll(pausedItems) - comItems.addAll(readItems) - comItems.addAll(favoriteItems) - Logd(TAG, "Save state for all " + comItems.size + " played episodes") - for (item in comItems) { - val media = item.media ?: continue - val played = EpisodeAction.Builder(item, EpisodeAction.PLAY) - .timestamp(Date(media.lastPlayedTime)) - .started(media.startPosition / 1000) - .position(media.position / 1000) - .playedDuration(media.playedDuration / 1000) - .total(media.duration / 1000) - .isFavorite(item.isSUPER) - .playState(item.playState) - .build() - queuedEpisodeActions.add(played) - } - if (queuedEpisodeActions.isNotEmpty()) { - try { - Logd(TAG, "Saving ${queuedEpisodeActions.size} actions: ${StringUtils.join(queuedEpisodeActions, ", ")}") - val list = JSONArray() - for (episodeAction in queuedEpisodeActions) { - val obj = episodeAction.writeToJsonObject() - if (obj != null) { - Logd(TAG, "saving EpisodeAction: $obj") - list.put(obj) - } - } - writer.write(list.toString()) - } catch (e: Exception) { - e.printStackTrace() - throw SyncServiceException(e) - } - } - Logd(TAG, "Finished writing document") - } - override fun fileExtension(): String { - return "json" - } - } - class FavoritesWriter : ExportWriter { - private val FAVORITE_TEMPLATE = "html-export-favorites-item-template.html" - private val FEED_TEMPLATE = "html-export-feed-template.html" - private val UTF_8 = "UTF-8" - @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) - override fun writeDocument(feeds: List, writer: Writer, context: Context) { - Logd(TAG, "Starting to write document") - val templateStream = context.assets.open("html-export-template.html") - var template = IOUtils.toString(templateStream, UTF_8) - template = template.replace("\\{TITLE\\}".toRegex(), "Favorites") - val templateParts = template.split("\\{FEEDS\\}".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - val favTemplateStream = context.assets.open(FAVORITE_TEMPLATE) - val favTemplate = IOUtils.toString(favTemplateStream, UTF_8) - val feedTemplateStream = context.assets.open(FEED_TEMPLATE) - val feedTemplate = IOUtils.toString(feedTemplateStream, UTF_8) - val allFavorites = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.superb.name), EpisodeSortOrder.DATE_NEW_OLD) - val favoritesByFeed = buildFeedMap(allFavorites) - writer.append(templateParts[0]) - for (feedId in favoritesByFeed.keys) { - val favorites: List = favoritesByFeed[feedId]!! - if (favorites[0].feed == null) continue - writer.append("
  • \n") - writeFeed(writer, favorites[0].feed!!, feedTemplate) - writer.append("
      \n") - for (item in favorites) writeFavoriteItem(writer, item, favTemplate) - writer.append("
  • \n") - } - writer.append(templateParts[1]) - Logd(TAG, "Finished writing document") - } - /** - * Group favorite episodes by feed, sorting them by publishing date in descending order. - * @param favoritesList `List` of all favorite episodes. - * @return A `Map` favorite episodes, keyed by feed ID. - */ - private fun buildFeedMap(favoritesList: List): Map> { - val feedMap: MutableMap> = TreeMap() - for (item in favoritesList) { - var feedEpisodes = feedMap[item.feedId] - if (feedEpisodes == null) { - feedEpisodes = ArrayList() - if (item.feedId != null) feedMap[item.feedId!!] = feedEpisodes - } - feedEpisodes.add(item) - } - return feedMap - } - @Throws(IOException::class) - private fun writeFeed(writer: Writer, feed: Feed, feedTemplate: String) { - val feedInfo = feedTemplate - .replace("{FEED_IMG}", feed.imageUrl?:"") - .replace("{FEED_TITLE}", feed.title?:" No title") - .replace("{FEED_LINK}", feed.link?: "") - .replace("{FEED_WEBSITE}", feed.downloadUrl?:"") - writer.append(feedInfo) - } - @Throws(IOException::class) - private fun writeFavoriteItem(writer: Writer, item: Episode, favoriteTemplate: String) { - var favItem = favoriteTemplate.replace("{FAV_TITLE}", item.title!!.trim { it <= ' ' }) - favItem = if (item.link != null) favItem.replace("{FAV_WEBSITE}", item.link!!) - else favItem.replace("{FAV_WEBSITE}", "") - favItem = - if (item.media != null && item.media!!.downloadUrl != null) favItem.replace("{FAV_MEDIA}", item.media!!.downloadUrl!!) - else favItem.replace("{FAV_MEDIA}", "") - writer.append(favItem) - } - override fun fileExtension(): String { - return "html" - } - } - class HtmlWriter : ExportWriter { - /** - * Takes a list of feeds and a writer and writes those into an HTML document. - */ - @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) - override fun writeDocument(feeds: List, writer: Writer, context: Context) { - Logd(TAG, "Starting to write document") - val templateStream = context.assets.open("html-export-template.html") - var template = IOUtils.toString(templateStream, "UTF-8") - template = template.replace("\\{TITLE\\}".toRegex(), "Subscriptions") - val templateParts = template.split("\\{FEEDS\\}".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - writer.append(templateParts[0]) - for (feed in feeds) { - writer.append("
  • ") - writer.append(feed.title) - writer.append(" WebsiteFeed

  • \n") - } - writer.append(templateParts[1]) - Logd(TAG, "Finished writing document") - } - override fun fileExtension(): String { - return "html" - } - } - var showProgress by remember { mutableStateOf(false) } fun isJsonFile(uri: Uri): Boolean { val fileName = uri.lastPathSegment ?: return false @@ -536,9 +78,6 @@ fun ImportExportPreferencesScreen(activity: PreferenceActivity) { .setAction(R.string.share_label) { IntentBuilder(activity).setType(mimeType).addStream(uri!!).setChooserTitle(R.string.share_label).startChooser() } .show() } - fun dateStampFilename(fname: String): String { - return String.format(fname, SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())) - } val showImporSuccessDialog = remember { mutableStateOf(false) } ComfirmDialog(titleRes = R.string.successful_import_label, message = stringResource(R.string.import_ok), showDialog = showImporSuccessDialog, cancellable = false) { forceRestart() } @@ -679,9 +218,9 @@ fun ImportExportPreferencesScreen(activity: PreferenceActivity) { for (child in rootFile.listFiles()) { if (child.isDirectory) { if (child.name == prefsDirName) { - if (comboDic["Preferences"] == true) PreferencesTransporter().importBackup(child.uri, activity) + if (comboDic["Preferences"] == true) PreferencesTransporter(prefsDirName).importBackup(child.uri, activity) } else if (child.name == mediaFilesDirName) { - if (comboDic["Media files"] == true) MediaFilesTransporter().importBackup(child.uri, activity) + if (comboDic["Media files"] == true) MediaFilesTransporter(mediaFilesDirName).importBackup(child.uri, activity) } } else if (isRealmFile(child.uri) && comboDic["Database"] == true) DatabaseTransporter().importBackup(child.uri, activity) } @@ -726,8 +265,8 @@ fun ImportExportPreferencesScreen(activity: PreferenceActivity) { val chosenDir = DocumentFile.fromTreeUri(activity, uri) ?: throw IOException("Destination directory is not valid") val exportSubDir = chosenDir.createDirectory(dateStampFilename("$backupDirName-%s")) ?: throw IOException("Error creating subdirectory $backupDirName") val subUri: Uri = exportSubDir.uri - if (comboDic["Preferences"] == true) PreferencesTransporter().exportToDocument(subUri, activity) - if (comboDic["Media files"] == true) MediaFilesTransporter().exportToDocument(subUri, activity) + if (comboDic["Preferences"] == true) PreferencesTransporter(prefsDirName).exportToDocument(subUri, activity) + if (comboDic["Media files"] == true) MediaFilesTransporter(mediaFilesDirName).exportToDocument(subUri, activity) if (comboDic["Database"] == true) { val realmFile = exportSubDir.createFile("application/octet-stream", "backup.realm") if (realmFile != null) DatabaseTransporter().exportToDocument(realmFile.uri, activity) @@ -742,6 +281,19 @@ fun ImportExportPreferencesScreen(activity: PreferenceActivity) { ) } + var backupFolder by remember { mutableStateOf(appPrefs.getString(UserPreferences.Prefs.prefAutoBackupFolder.name, activity.getString(R.string.pref_auto_backup_folder_sum))!! ) } + val autoBackupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + val uri: Uri? = it.data?.data + if (uri != null) { + activity.contentResolver.takePersistableUriPermission(uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + backupFolder = uri.toString() + appPrefs.edit().putString(UserPreferences.Prefs.prefAutoBackupFolder.name, uri.toString()).apply() + } + } + } + val restoreComboLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> if (result.resultCode != RESULT_OK || result.data?.data == null) return@rememberLauncherForActivityResult val uri = result.data!!.data!! @@ -813,8 +365,74 @@ fun ImportExportPreferencesScreen(activity: PreferenceActivity) { } } val scrollState = rememberScrollState() -// supportActionBar?.setTitle(R.string.import_export_pref) Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) { + var isAutoBackup by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefAutoBackup.name, false)) } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_auto_backup_title), color = textColor, style = CustomTextStyles.titleCustom, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_auto_backup_sum), color = textColor) + } + Switch(checked = isAutoBackup, onCheckedChange = { + isAutoBackup = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefAutoBackup.name, it).apply() + }) + } + if (isAutoBackup) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Text(stringResource(R.string.pref_auto_backup_interval), color = textColor, style = CustomTextStyles.titleCustom, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + var interval by remember { mutableStateOf(appPrefs.getInt(UserPreferences.Prefs.prefAutoBackupIntervall.name, 24).toString()) } + var showIcon by remember { mutableStateOf(false) } + TextField(value = interval, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), label = { Text("(hours)") }, + singleLine = true, modifier = Modifier.weight(0.5f), + onValueChange = { + val intVal = it.toIntOrNull() + if (it.isEmpty() || (intVal != null && intVal>0)) { + interval = it + showIcon = true + } + }, + trailingIcon = { + if (showIcon) Icon(imageVector = Icons.Filled.Settings, contentDescription = "Settings icon", + modifier = Modifier.size(30.dp).padding(start = 5.dp).clickable(onClick = { + if (interval.isEmpty()) interval = "0" + appPrefs.edit().putInt(UserPreferences.Prefs.prefAutoBackupIntervall.name, interval.toIntOrNull()?:0).apply() + showIcon = false + })) + }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Text(stringResource(R.string.pref_auto_backup_limit), color = textColor, style = CustomTextStyles.titleCustom, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + var count by remember { mutableStateOf(appPrefs.getInt(UserPreferences.Prefs.prefAutoBackupLimit.name, 2).toString()) } + var showIcon by remember { mutableStateOf(false) } + TextField(value = count, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, modifier = Modifier.weight(0.4f), label = { Text("1 - 9") }, + onValueChange = { + val intVal = it.toIntOrNull() + if (it.isEmpty() || (intVal != null && intVal>0 && intVal<10)) { + count = it + showIcon = true + } + }, + trailingIcon = { + if (showIcon) Icon(imageVector = Icons.Filled.Settings, contentDescription = "Settings icon", + modifier = Modifier.size(30.dp).padding(start = 5.dp).clickable(onClick = { + if (count.isEmpty()) count = "0" + appPrefs.edit().putInt(UserPreferences.Prefs.prefAutoBackupLimit.name, count.toIntOrNull()?:0).apply() + showIcon = false + })) + }) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + intent.addCategory(Intent.CATEGORY_DEFAULT) + autoBackupLauncher.launch(intent) + })) { + Text(stringResource(R.string.pref_auto_backup_folder), color = textColor, style = CustomTextStyles.titleCustom, fontWeight = FontWeight.Bold) + Text(backupFolder, color = textColor, style = MaterialTheme.typography.bodySmall) + } + } + HorizontalDivider(modifier = Modifier.fillMaxWidth().height(2.dp).padding(top = 20.dp, bottom = 20.dp)) TitleSummaryActionColumn(R.string.combo_export_label, R.string.combo_export_summary) { launchExportCombos() } val showComboImportDialog = remember { mutableStateOf(false) } ComfirmDialog(titleRes = R.string.combo_import_label, message = stringResource(R.string.combo_import_warning), showDialog = showComboImportDialog) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt index a7e7080f..87ec7021 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt @@ -28,10 +28,12 @@ import ac.mdiq.vista.extractor.stream.StreamInfo import ac.mdiq.vista.extractor.stream.StreamInfoItem import android.app.backup.BackupManager import android.content.Context +import android.content.DialogInterface import android.net.Uri import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.documentfile.provider.DocumentFile +import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.realm.kotlin.ext.isManaged import kotlinx.coroutines.Job import java.io.File @@ -90,7 +92,24 @@ object Episodes { return if (media != null) realm.copyFromRealm(media) else null } -// @JvmStatic is needed because some Runnable blocks call this + fun deleteEpisodesWarnLocal(context: Context, items: Iterable) { + val localItems: MutableList = mutableListOf() + for (item in items) { + if (item.feed?.isLocalFeed == true) localItems.add(item) + else deleteEpisodeMedia(context, item) + } + + if (localItems.isNotEmpty()) { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.delete_episode_label) + .setMessage(R.string.delete_local_feed_warning_body) + .setPositiveButton(R.string.delete_label) { dialog: DialogInterface?, which: Int -> for (item in localItems) deleteEpisodeMedia(context, item) } + .setNegativeButton(R.string.cancel_label, null) + .show() + } + } + + // @JvmStatic is needed because some Runnable blocks call this @JvmStatic fun deleteEpisodeMedia(context: Context, episode: Episode) : Job { Logd(TAG, "deleteMediaOfEpisode called ${episode.title}") diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt index 6083c9fb..6ad95bc1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt @@ -14,6 +14,7 @@ import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.isStreamOverDownload import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode import ac.mdiq.podcini.receiver.MediaButtonReceiver +import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodesWarnLocal import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync import ac.mdiq.podcini.storage.database.RealmDB import ac.mdiq.podcini.storage.database.RealmDB.realm @@ -21,7 +22,6 @@ import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.storage.utils.AudioMediaTools import ac.mdiq.podcini.storage.utils.FilesUtils -import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.deleteEpisodesWarnLocal import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment import ac.mdiq.podcini.util.EventFlow diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt index 1afc6566..5cf979ec 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt @@ -2,7 +2,7 @@ package ac.mdiq.podcini.ui.actions import ac.mdiq.podcini.R import ac.mdiq.podcini.playback.base.InTheatre.curQueue -import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodeMedia +import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodesWarnLocal import ac.mdiq.podcini.storage.database.Episodes.hasAlmostEnded import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync import ac.mdiq.podcini.storage.database.Queues.addToQueue @@ -20,7 +20,6 @@ import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.MiscFormatter.fullDateTimeString import android.content.Context -import android.content.DialogInterface import android.content.SharedPreferences import android.util.TypedValue import androidx.annotation.AttrRes @@ -47,30 +46,35 @@ import androidx.compose.ui.window.Dialog import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.runBlocking import java.util.* interface SwipeAction { - fun getId(): String? + fun getId(): String fun getTitle(context: Context): String - @DrawableRes fun getActionIcon(): Int - @AttrRes @DrawableRes fun getActionColor(): Int - @Composable fun ActionOptions() {} - - fun performAction(item: Episode, fragment: Fragment) + fun performAction(item: Episode) } class SwipeActions(private val fragment: Fragment, private val tag: String) : DefaultLifecycleObserver { - var actions by mutableStateOf(getPrefs(tag, "")) +// val prefs: SharedPreferences by lazy { fragment.requireContext().getSharedPreferences(SWIPE_ACTIONS_PREF_NAME, Context.MODE_PRIVATE)} + + val actionsList: List = listOf( + NoActionSwipeAction(), ComboSwipeAction(), + AddToQueueSwipeAction(), PutToQueueSwipeAction(), + StartDownloadSwipeAction(), SetRatingSwipeAction(), AddCommentSwipeAction(), + SetPlaybackStateSwipeAction(), RemoveFromQueueSwipeAction(), + DeleteSwipeAction(), RemoveFromHistorySwipeAction(), + ShelveSwipeAction(), EraseSwipeAction()) + + var actions by mutableStateOf(getPrefs(tag, "")) override fun onStart(owner: LifecycleOwner) { actions = getPrefs(tag, "") @@ -82,25 +86,24 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De actions.right[0].ActionOptions() } - fun dialogCallback() { - actions = getPrefs(this@SwipeActions.tag, "") - // TODO: remove the need of event - EventFlow.postEvent(FlowEvent.SwipeActionsChangedEvent()) + private fun getPrefs(tag: String, defaultActions: String): RightLeftActions { + val prefsString = prefs?.getString(KEY_PREFIX_SWIPEACTIONS + tag, defaultActions) ?: defaultActions + return RightLeftActions(prefsString) } - class Actions(prefs: String?) { + inner class RightLeftActions(actions_: String) { @JvmField - var right: MutableList = mutableListOf(swipeActions[0], swipeActions[0]) + var right: MutableList = mutableListOf(NoActionSwipeAction(), NoActionSwipeAction()) @JvmField - var left: MutableList = mutableListOf(swipeActions[0], swipeActions[0]) + var left: MutableList = mutableListOf(NoActionSwipeAction(), NoActionSwipeAction()) init { - val actions = prefs!!.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val actions = actions_.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() if (actions.size == 2) { - val rActs = swipeActions.filter { a: SwipeAction -> a.getId().equals(actions[0]) } - this.right[0] = if (rActs.isEmpty()) swipeActions[0] else rActs[0] - val lActs = swipeActions.filter { a: SwipeAction -> a.getId().equals(actions[1]) } - this.left[0] = if (lActs.isEmpty()) swipeActions[0] else lActs[0] + val rActs = actionsList.filter { a: SwipeAction -> a.getId() == actions[0] } + this.right[0] = if (rActs.isEmpty()) actionsList[0] else rActs[0] + val lActs = actionsList.filter { a: SwipeAction -> a.getId() == actions[1] } + this.left[0] = if (lActs.isEmpty()) actionsList[0] else lActs[0] } } } @@ -121,7 +124,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De ERASE } - class AddToQueueSwipeAction : SwipeAction { + inner class AddToQueueSwipeAction : SwipeAction { override fun getId(): String { return ActionTypes.ADD_TO_QUEUE.name } @@ -134,15 +137,14 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.add_to_queue_label) } - override fun performAction(item: Episode, fragment: Fragment) { + override fun performAction(item: Episode) { addToQueue(item) } } - class ComboSwipeAction : SwipeAction { + inner class ComboSwipeAction : SwipeAction { var showDialog by mutableStateOf(false) var onEpisode by mutableStateOf(null) - var onFragment by mutableStateOf(null) var useAction by mutableStateOf(null) override fun getId(): String { return ActionTypes.COMBO.name @@ -156,23 +158,22 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.combo_action) } - override fun performAction(item: Episode, fragment: Fragment) { + override fun performAction(item: Episode) { onEpisode = item - onFragment = fragment showDialog = true } @Composable override fun ActionOptions() { useAction?.ActionOptions() - if (showDialog && onEpisode!= null && onFragment != null) Dialog(onDismissRequest = { showDialog = false }) { + if (showDialog && onEpisode!= null) Dialog(onDismissRequest = { showDialog = false }) { val context = LocalContext.current Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - for (action in swipeActions) { + for (action in actionsList) { if (action.getId() == ActionTypes.NO_ACTION.name || action.getId() == ActionTypes.COMBO.name) continue Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp).clickable { useAction = action - action.performAction(onEpisode!!, onFragment!!) + action.performAction(onEpisode!!) showDialog = false }) { val colorAccent = remember { @@ -190,7 +191,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De } } - class DeleteSwipeAction : SwipeAction { + inner class DeleteSwipeAction : SwipeAction { override fun getId(): String { return ActionTypes.DELETE.name } @@ -203,7 +204,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.delete_episode_label) } - override fun performAction(item_: Episode, fragment: Fragment) { + override fun performAction(item_: Episode) { var item = item_ if (!item.isDownloaded && item.feed?.isLocalFeed != true) return val media = item.media @@ -217,7 +218,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De } } - class SetRatingSwipeAction : SwipeAction { + inner class SetRatingSwipeAction : SwipeAction { var showChooseRatingDialog by mutableStateOf(false) var onEpisode by mutableStateOf(null) override fun getId(): String { @@ -232,7 +233,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.set_rating_label) } - override fun performAction(item: Episode, fragment: Fragment) { + override fun performAction(item: Episode) { onEpisode = item showChooseRatingDialog = true } @@ -242,7 +243,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De } } - class AddCommentSwipeAction : SwipeAction { + inner class AddCommentSwipeAction : SwipeAction { var showEditComment by mutableStateOf(false) var onEpisode by mutableStateOf(null) var localTime by mutableLongStateOf(System.currentTimeMillis()) @@ -259,7 +260,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.add_opinion_label) } - override fun performAction(item: Episode, fragment: Fragment) { + override fun performAction(item: Episode) { // onEpisode = realm.query(Episode::class).query("id == ${item.id}").first().find() onEpisode = item localTime = System.currentTimeMillis() @@ -271,10 +272,11 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De if (showEditComment && onEpisode != null) { LargeTextEditingDialog(textState = editCommentText, onTextChange = { editCommentText = it }, onDismissRequest = { showEditComment = false }, onSave = { text -> - runOnIOScope { upsert(onEpisode!!) { - it.comment = text - it.commentTime = localTime - } + runOnIOScope { + upsert(onEpisode!!) { + it.comment = text + it.commentTime = localTime + } onEpisode = null } }) @@ -295,10 +297,10 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.no_action_label) } - override fun performAction(item: Episode, fragment: Fragment) {} + override fun performAction(item: Episode) {} } - class RemoveFromHistorySwipeAction : SwipeAction { + inner class RemoveFromHistorySwipeAction : SwipeAction { val TAG = this::class.simpleName ?: "Anonymous" override fun getId(): String { @@ -313,15 +315,13 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.remove_history_label) } - override fun performAction(item: Episode, fragment: Fragment) { + override fun performAction(item: Episode) { val playbackCompletionDate: Date? = item.media?.playbackCompletionDate val lastPlayedDate = item.media?.lastPlayedTime setHistoryDates(item) - (fragment.requireActivity() as MainActivity) - .showSnackbarAbovePlayer(R.string.removed_history_label, Snackbar.LENGTH_LONG) - .setAction(fragment.getString(R.string.undo)) { - if (playbackCompletionDate != null) setHistoryDates(item, lastPlayedDate?:0, playbackCompletionDate) } + (fragment.requireActivity() as MainActivity).showSnackbarAbovePlayer(R.string.removed_history_label, Snackbar.LENGTH_LONG) + .setAction(fragment.getString(R.string.undo)) { if (playbackCompletionDate != null) setHistoryDates(item, lastPlayedDate?:0, playbackCompletionDate) } } private fun setHistoryDates(episode: Episode, lastPlayed: Long = 0, completed: Date = Date(0)) { runOnIOScope { @@ -337,7 +337,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De } } - class RemoveFromQueueSwipeAction : SwipeAction { + inner class RemoveFromQueueSwipeAction : SwipeAction { override fun getId(): String { return ActionTypes.REMOVE_FROM_QUEUE.name } @@ -350,7 +350,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.remove_from_queue_label) } - override fun performAction(item_: Episode, fragment: Fragment) { + override fun performAction(item_: Episode) { // val position: Int = curQueue.episodes.indexOf(item_) var item = item_ val media = item.media @@ -364,7 +364,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De } } - class PutToQueueSwipeAction : SwipeAction { + inner class PutToQueueSwipeAction : SwipeAction { var showPutToQueueDialog by mutableStateOf(false) var onEpisode by mutableStateOf(null) override fun getId(): String { @@ -379,7 +379,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.put_in_queue_label) } - override fun performAction(item: Episode, fragment: Fragment) { + override fun performAction(item: Episode) { onEpisode = item showPutToQueueDialog = true } @@ -389,7 +389,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De } } - class StartDownloadSwipeAction : SwipeAction { + inner class StartDownloadSwipeAction : SwipeAction { override fun getId(): String { return ActionTypes.START_DOWNLOAD.name } @@ -402,14 +402,12 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.download_label) } - override fun performAction(item: Episode, fragment: Fragment) { - if (!item.isDownloaded && item.feed != null && !item.feed!!.isLocalFeed) { - DownloadActionButton(item).onClick(fragment.requireContext()) - } + override fun performAction(item: Episode) { + if (!item.isDownloaded && item.feed != null && !item.feed!!.isLocalFeed) DownloadActionButton(item).onClick(fragment.requireContext()) } } - class SetPlaybackStateSwipeAction : SwipeAction { + inner class SetPlaybackStateSwipeAction : SwipeAction { var showPlayStateDialog by mutableStateOf(false) var onEpisode by mutableStateOf(null) override fun getId(): String { @@ -424,7 +422,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.set_play_state_label) } - override fun performAction(item: Episode, fragment: Fragment) { + override fun performAction(item: Episode) { onEpisode = item showPlayStateDialog = true } @@ -443,7 +441,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De // } } - class ShelveSwipeAction : SwipeAction { + inner class ShelveSwipeAction : SwipeAction { var showShelveDialog by mutableStateOf(false) var onEpisode by mutableStateOf(null) override fun getId(): String { @@ -458,7 +456,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.shelve_label) } - override fun performAction(item: Episode, fragment: Fragment) { + override fun performAction(item: Episode) { onEpisode = item showShelveDialog = true } @@ -468,7 +466,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De } } - class EraseSwipeAction : SwipeAction { + inner class EraseSwipeAction : SwipeAction { var showEraseDialog by mutableStateOf(false) var onEpisode by mutableStateOf(null) override fun getId(): String { @@ -483,7 +481,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De override fun getTitle(context: Context): String { return context.getString(R.string.erase_episodes_label) } - override fun performAction(item: Episode, fragment: Fragment) { + override fun performAction(item: Episode) { onEpisode = item showEraseDialog = true } @@ -498,59 +496,28 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De private const val KEY_PREFIX_SWIPEACTIONS: String = "PrefSwipeActions" private const val KEY_PREFIX_NO_ACTION: String = "PrefNoSwipeAction" - private var prefs: SharedPreferences? = null + var prefs: SharedPreferences? = null fun getSharedPrefs(context: Context) { if (prefs == null) prefs = context.getSharedPreferences(SWIPE_ACTIONS_PREF_NAME, Context.MODE_PRIVATE) } - @JvmField - val swipeActions: List = listOf( - NoActionSwipeAction(), ComboSwipeAction(), - AddToQueueSwipeAction(), PutToQueueSwipeAction(), - StartDownloadSwipeAction(), SetRatingSwipeAction(), AddCommentSwipeAction(), - SetPlaybackStateSwipeAction(), RemoveFromQueueSwipeAction(), - DeleteSwipeAction(), RemoveFromHistorySwipeAction(), - ShelveSwipeAction(), EraseSwipeAction()) - - private fun getPrefs(tag: String, defaultActions: String): Actions { - val prefsString = prefs!!.getString(KEY_PREFIX_SWIPEACTIONS + tag, defaultActions) - return Actions(prefsString) - } - - fun deleteEpisodesWarnLocal(context: Context, items: Iterable) { - val localItems: MutableList = mutableListOf() - for (item in items) { - if (item.feed?.isLocalFeed == true) localItems.add(item) - else deleteEpisodeMedia(context, item) - } - - if (localItems.isNotEmpty()) { - MaterialAlertDialogBuilder(context) - .setTitle(R.string.delete_episode_label) - .setMessage(R.string.delete_local_feed_warning_body) - .setPositiveButton(R.string.delete_label) { dialog: DialogInterface?, which: Int -> for (item in localItems) deleteEpisodeMedia(context, item) } - .setNegativeButton(R.string.cancel_label, null) - .show() - } - } - @Composable - fun SwipeActionsDialog(tag: String, onDismissRequest: () -> Unit, callback: ()->Unit) { + fun SwipeActionsSettingDialog(sa: SwipeActions, onDismissRequest: () -> Unit, callback: (RightLeftActions)->Unit) { val context = LocalContext.current val textColor = MaterialTheme.colorScheme.onSurface - val actions = remember { getPrefs(tag, "${ActionTypes.NO_ACTION.name},${ActionTypes.NO_ACTION.name}") } + var actions = remember { sa.actions } val leftAction = remember { mutableStateOf(actions.left) } val rightAction = remember { mutableStateOf(actions.right) } - var keys by remember { mutableStateOf(swipeActions) } + var keys by remember { mutableStateOf(sa.actionsList) } fun savePrefs(tag: String, right: String?, left: String?) { - getSharedPrefs(context) - prefs!!.edit().putString(KEY_PREFIX_SWIPEACTIONS + tag, "$right,$left").apply() +// getSharedPrefs(context) + prefs?.edit()?.putString(KEY_PREFIX_SWIPEACTIONS + tag, "$right,$left")?.apply() } fun saveActionsEnabledPrefs(enabled: Boolean) { - getSharedPrefs(context) - prefs!!.edit().putBoolean(KEY_PREFIX_NO_ACTION + tag, enabled).apply() +// getSharedPrefs(context) + prefs?.edit()?.putBoolean(KEY_PREFIX_NO_ACTION + sa.tag, enabled)?.apply() } var direction by remember { mutableIntStateOf(0) } @@ -578,21 +545,22 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De } if (!showPickerDialog) Dialog(onDismissRequest = { onDismissRequest() }) { - val forFragment = remember(tag) { when (tag) { + val forFragment = remember(sa.tag) { + if (sa.tag != QueuesFragment.TAG) keys = keys.filter { a: SwipeAction -> a.getId() != ActionTypes.REMOVE_FROM_QUEUE.name } + when (sa.tag) { EpisodesFragment.TAG -> context.getString(R.string.episodes_label) OnlineEpisodesFragment.TAG -> context.getString(R.string.online_episodes_label) SearchFragment.TAG -> context.getString(R.string.search_label) FeedEpisodesFragment.TAG -> { - keys = keys.filter { a: SwipeAction -> !a.getId().equals(ActionTypes.REMOVE_FROM_HISTORY.name) } + keys = keys.filter { a: SwipeAction -> a.getId() != ActionTypes.REMOVE_FROM_HISTORY.name } context.getString(R.string.subscription) } QueuesFragment.TAG -> { - keys = keys.filter { a: SwipeAction -> (!a.getId().equals(ActionTypes.ADD_TO_QUEUE.name) && !a.getId().equals(ActionTypes.REMOVE_FROM_HISTORY.name)) }.toList() + keys = keys.filter { a: SwipeAction -> (a.getId() != ActionTypes.ADD_TO_QUEUE.name && a.getId() != ActionTypes.REMOVE_FROM_HISTORY.name) } context.getString(R.string.queue_label) } else -> { "" } } } - if (tag != QueuesFragment.TAG) keys = keys.filter { a: SwipeAction -> !a.getId().equals(ActionTypes.REMOVE_FROM_QUEUE.name) } Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) { Text(stringResource(R.string.swipeactions_label) + " - " + forFragment) @@ -603,8 +571,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De .clickable(onClick = { direction = -1 showPickerDialog = true - }) - ) + })) Spacer(Modifier.weight(0.1f)) Icon(imageVector = ImageVector.vectorResource(R.drawable.baseline_arrow_left_alt_24), tint = textColor, contentDescription = "right_arrow", modifier = Modifier.width(50.dp).height(35.dp)) Spacer(Modifier.weight(0.5f)) @@ -618,15 +585,14 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De .clickable(onClick = { direction = 1 showPickerDialog = true - }) - ) + })) Spacer(Modifier.weight(0.1f)) } Button(onClick = { - savePrefs(tag, rightAction.value[0].getId(), leftAction.value[0].getId()) + actions = sa.RightLeftActions("${rightAction.value[0].getId()},${leftAction.value[0].getId()}") + savePrefs(sa.tag, rightAction.value[0].getId(), leftAction.value[0].getId()) saveActionsEnabledPrefs(true) - EventFlow.postEvent(FlowEvent.SwipeActionsChangedEvent()) - callback() + callback(actions) onDismissRequest() }) { Text("Confirm") } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt index 4765d516..86de3e08 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -15,6 +15,7 @@ import ac.mdiq.podcini.preferences.ThemeSwitcher.getNoTitleTheme import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.backButtonOpensDrawer import ac.mdiq.podcini.preferences.UserPreferences.defaultPage +import ac.mdiq.podcini.preferences.autoBackup import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createIntent import ac.mdiq.podcini.storage.database.Feeds.buildTags import ac.mdiq.podcini.storage.database.Feeds.monitorFeeds @@ -153,7 +154,7 @@ class MainActivity : CastEnabledActivity() { return displayMetrics.widthPixels } - public override fun onCreate(savedInstanceState: Bundle?) { + public override fun onCreate(savedInstanceState: Bundle?) { lastTheme = getNoTitleTheme(this) setTheme(lastTheme) @@ -546,6 +547,8 @@ class MainActivity : CastEnabledActivity() { override fun onResume() { super.onResume() + autoBackup(this) + handleNavIntent() RatingDialog.check() if (lastTheme != getNoTitleTheme(this)) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/PreferenceActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/PreferenceActivity.kt index 7a196a78..60108f9e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/PreferenceActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/PreferenceActivity.kt @@ -8,11 +8,7 @@ import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.DefaultPages import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.preferences.screens.* -import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.SwipeActionsDialog import ac.mdiq.podcini.ui.compose.* -import ac.mdiq.podcini.ui.fragment.EpisodesFragment -import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment -import ac.mdiq.podcini.ui.fragment.QueuesFragment import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.IntentUtils.openInBrowser @@ -126,9 +122,9 @@ class PreferenceActivity : AppCompatActivity() { composable(Screens.notifications.tag) { topAppBarTitle = stringResource(Screens.notifications.titleRes) NotificationPreferencesScreen() } - composable(Screens.swipe.tag) { - topAppBarTitle = stringResource(Screens.swipe.titleRes) - SwipePreferencesScreen() } +// composable(Screens.swipe.tag) { +// topAppBarTitle = stringResource(Screens.swipe.titleRes) +// SwipePreferencesScreen() } composable(Screens.about.tag) { topAppBarTitle = stringResource(Screens.about.titleRes) AboutScreen(navController) } @@ -476,33 +472,33 @@ class PreferenceActivity : AppCompatActivity() { ) } TitleSummarySwitchPrefRow(R.string.pref_back_button_opens_drawer, R.string.pref_back_button_opens_drawer_summary, UserPreferences.Prefs.prefBackButtonOpensDrawer.name) - TitleSummaryActionColumn(R.string.swipeactions_label, R.string.swipeactions_summary) { - navController.navigate(Screens.swipe.tag) -// openScreen(Screens.swipe) - } +// TitleSummaryActionColumn(R.string.swipeactions_label, R.string.swipeactions_summary) { +// navController.navigate(Screens.swipe.tag) +//// openScreen(Screens.swipe) +// } } } - @Suppress("EnumEntryName") - private enum class SwipePrefs(val res: Int, val tag: String) { - prefSwipeQueue(R.string.queue_label, QueuesFragment.TAG), - prefSwipeEpisodes(R.string.episodes_label, EpisodesFragment.TAG), - prefSwipeFeed(R.string.individual_subscription, FeedEpisodesFragment.TAG), - } +// @Suppress("EnumEntryName") +// private enum class SwipePrefs(val res: Int, val tag: String) { +// prefSwipeQueue(R.string.queue_label, QueuesFragment.TAG), +// prefSwipeEpisodes(R.string.episodes_label, EpisodesFragment.TAG), +// prefSwipeFeed(R.string.individual_subscription, FeedEpisodesFragment.TAG), +// } - @Composable - fun SwipePreferencesScreen() { -// supportActionBar?.setTitle(R.string.swipeactions_label) - val textColor = MaterialTheme.colorScheme.onSurface - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - for (e in SwipePrefs.entries) { - val showDialog = remember { mutableStateOf(false) } - if (showDialog.value) SwipeActionsDialog(e.tag, onDismissRequest = { showDialog.value = false }) {} - Text(stringResource(e.res), color = textColor, style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 10.dp).clickable(onClick = { showDialog.value = true })) - } - } - } +// @Composable +// fun SwipePreferencesScreen() { +//// supportActionBar?.setTitle(R.string.swipeactions_label) +// val textColor = MaterialTheme.colorScheme.onSurface +// Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { +// for (e in SwipePrefs.entries) { +// val showDialog = remember { mutableStateOf(false) } +// if (showDialog.value) SwipeActionsDialog(e.tag, onDismissRequest = { showDialog.value = false }) {} +// Text(stringResource(e.res), color = textColor, style = MaterialTheme.typography.headlineSmall, +// modifier = Modifier.padding(bottom = 10.dp).clickable(onClick = { showDialog.value = true })) +// } +// } +// } @Composable fun NotificationPreferencesScreen() { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index 5e2ccc2b..38f6d1f5 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -120,7 +120,7 @@ class AudioPlayerFragment : Fragment() { private var isCollapsed by mutableStateOf(true) - private lateinit var controllerFuture: ListenableFuture + private var controllerFuture: ListenableFuture? = null private var controller: ServiceStatusHandler? = null private var prevMedia: EpisodeMedia? = null @@ -226,7 +226,8 @@ class AudioPlayerFragment : Fragment() { Logd(TAG, "Fragment destroyed") controller?.release() controller = null - MediaController.releaseFuture(controllerFuture) + if (controllerFuture != null) MediaController.releaseFuture(controllerFuture!!) + controllerFuture = null super.onDestroyView() } @@ -804,13 +805,13 @@ class AudioPlayerFragment : Fragment() { procFlowEvents() val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), PlaybackService::class.java)) - controllerFuture = MediaController.Builder(requireContext(), sessionToken).buildAsync() - controllerFuture.addListener({ - media3Controller = controllerFuture.get() + if (controllerFuture == null) { + controllerFuture = MediaController.Builder(requireContext(), sessionToken).buildAsync() + controllerFuture?.addListener({ + media3Controller = controllerFuture!!.get() // Logd(TAG, "controllerFuture.addListener: $mediaController") - }, MoreExecutors.directExecutor()) - -// loadMediaInfo() + }, MoreExecutors.directExecutor()) + } } override fun onStop() { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodesFragment.kt index 931e8846..d3a7c025 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodesFragment.kt @@ -19,7 +19,7 @@ import ac.mdiq.podcini.ui.actions.DeleteActionButton import ac.mdiq.podcini.ui.actions.EpisodeActionButton import ac.mdiq.podcini.ui.actions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeActions -import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.SwipeActionsDialog +import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.SwipeActionsSettingDialog import ac.mdiq.podcini.ui.actions.SwipeActions.NoActionSwipeAction import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.compose.* @@ -64,7 +64,7 @@ class EpisodesFragment : Fragment() { private var displayUpArrow = false - protected var infoBarText = mutableStateOf("") + private var infoBarText = mutableStateOf("") private var leftActionState = mutableStateOf(NoActionSwipeAction()) private var rightActionState = mutableStateOf(NoActionSwipeAction()) private var showSwipeActionsDialog by mutableStateOf(false) @@ -112,7 +112,10 @@ class EpisodesFragment : Fragment() { if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW) swipeActions = SwipeActions(this, TAG) + leftActionState.value = swipeActions.actions.left[0] + rightActionState.value = swipeActions.actions.right[0] lifecycle.addObserver(swipeActions) + val composeView = ComposeView(requireContext()).apply { setContent { CustomTheme(requireContext()) { @@ -124,11 +127,11 @@ class EpisodesFragment : Fragment() { activity as MainActivity, vms = vms, leftSwipeCB = { if (leftActionState.value is NoActionSwipeAction) showSwipeActionsDialog = true - else leftActionState.value.performAction(it, this@EpisodesFragment) + else leftActionState.value.performAction(it) }, rightSwipeCB = { if (rightActionState.value is NoActionSwipeAction) showSwipeActionsDialog = true - else rightActionState.value.performAction(it, this@EpisodesFragment) + else rightActionState.value.performAction(it) }, actionButton_ = actionButtonToPass ) @@ -197,7 +200,7 @@ class EpisodesFragment : Fragment() { EventFlow.events.collectLatest { event -> Logd(TAG, "Received event: ${event.TAG}") when (event) { - is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() +// is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() is FlowEvent.EpisodeEvent -> onEpisodeEvent(event) is FlowEvent.EpisodeMediaEvent -> onEpisodeMediaEvent(event) is FlowEvent.HistoryEvent -> onHistoryEvent(event) @@ -255,7 +258,10 @@ class EpisodesFragment : Fragment() { @Composable fun OpenDialog() { - if (showSwipeActionsDialog) SwipeActionsDialog(TAG, onDismissRequest = { showSwipeActionsDialog = false }) { swipeActions.dialogCallback() } + if (showSwipeActionsDialog) SwipeActionsSettingDialog(swipeActions, onDismissRequest = { showSwipeActionsDialog = false }) { actions -> + swipeActions.actions = actions + refreshSwipeTelltale() + } if (showFilterDialog) EpisodesFilterDialog(filter = getFilter(), filtersDisabled = filtersDisabled(), onDismissRequest = { showFilterDialog = false }) { onFilterChanged(it) } if (showSortDialog) EpisodeSortDialog(initOrder = sortOrder, onDismissRequest = { showSortDialog = false }) { order, _ -> onSort(order) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt index ba53e7c7..d9bf98cb 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt @@ -14,7 +14,7 @@ import ac.mdiq.podcini.storage.model.EpisodeSortOrder.Companion.fromCode import ac.mdiq.podcini.storage.model.EpisodeSortOrder.Companion.getPermutor import ac.mdiq.podcini.ui.actions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeActions -import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.SwipeActionsDialog +import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.SwipeActionsSettingDialog import ac.mdiq.podcini.ui.actions.SwipeActions.NoActionSwipeAction import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.compose.* @@ -67,12 +67,11 @@ import java.util.concurrent.Semaphore class FeedEpisodesFragment : Fragment() { private lateinit var swipeActions: SwipeActions - - private var infoBarText = mutableStateOf("") private var leftActionState = mutableStateOf(NoActionSwipeAction()) private var rightActionState = mutableStateOf(NoActionSwipeAction()) private var showSwipeActionsDialog by mutableStateOf(false) + private var infoBarText = mutableStateOf("") private var infoTextFiltered = "" private var infoTextUpdate = "" private var displayUpArrow by mutableStateOf(false) @@ -95,7 +94,7 @@ class FeedEpisodesFragment : Fragment() { private var showRenameDialog by mutableStateOf(false) var showSortDialog by mutableStateOf(false) var sortOrder by mutableStateOf(EpisodeSortOrder.DATE_NEW_OLD) - var layoutMode by mutableIntStateOf(0) + var layoutModeIndex by mutableIntStateOf(0) private val ioScope = CoroutineScope(Dispatchers.IO) private var onInit: Boolean = true @@ -114,12 +113,19 @@ class FeedEpisodesFragment : Fragment() { if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW) NavDrawerFragment.saveLastNavFragment(TAG, feedID.toString()) swipeActions = SwipeActions(this, TAG) + leftActionState.value = swipeActions.actions.left[0] + rightActionState.value = swipeActions.actions.right[0] + lifecycle.addObserver(swipeActions) var filterJob: Job? = null fun filterLongClick() { if (feed == null) return enableFilter = !enableFilter - filterJob?.cancel() + if (filterJob != null) { + filterJob?.cancel() + stopMonitor(vms) + vms.clear() + } filterJob = lifecycleScope.launch { val eListTmp = mutableListOf() withContext(Dispatchers.IO) { @@ -144,7 +150,8 @@ class FeedEpisodesFragment : Fragment() { } }.apply { invokeOnCompletion { filterJob = null } } } - layoutMode = if (feed?.preferences?.useWideLayout == true) 1 else 0 + + layoutModeIndex = if (feed?.preferences?.useWideLayout == true) 1 else 0 val composeView = ComposeView(requireContext()).apply { setContent { @@ -179,7 +186,10 @@ class FeedEpisodesFragment : Fragment() { } } } - if (showSwipeActionsDialog) SwipeActionsDialog(TAG, onDismissRequest = { showSwipeActionsDialog = false }) { swipeActions.dialogCallback() } + if (showSwipeActionsDialog) SwipeActionsSettingDialog(swipeActions, onDismissRequest = { showSwipeActionsDialog = false }) { actions -> + swipeActions.actions = actions + refreshSwipeTelltale() + } swipeActions.ActionOptionsDialog() Scaffold(topBar = { MyTopAppBar() }) { innerPadding -> Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { @@ -187,16 +197,16 @@ class FeedEpisodesFragment : Fragment() { if (enableFilter && feed != null) showFilterDialog = true }, filterLongClickCB = { filterLongClick() }) InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = { showSwipeActionsDialog = true }) - EpisodeLazyColumn(activity as MainActivity, vms = vms, feed = feed, layoutMode = layoutMode, + EpisodeLazyColumn(activity as MainActivity, vms = vms, feed = feed, layoutMode = layoutModeIndex, refreshCB = { FeedUpdateManager.runOnceOrAsk(requireContext(), feed) }, leftSwipeCB = { if (leftActionState.value is NoActionSwipeAction) showSwipeActionsDialog = true - else leftActionState.value.performAction(it, this@FeedEpisodesFragment) + else leftActionState.value.performAction(it) }, rightSwipeCB = { Logd(TAG, "rightActionState: ${rightActionState.value.getId()}") if (rightActionState.value is NoActionSwipeAction) showSwipeActionsDialog = true - else rightActionState.value.performAction(it, this@FeedEpisodesFragment) + else rightActionState.value.performAction(it) }, ) } @@ -445,7 +455,7 @@ class FeedEpisodesFragment : Fragment() { is FlowEvent.FeedPrefsChangeEvent -> if (feed?.id == event.feed.id) loadFeed() // is FlowEvent.PlayerSettingsEvent -> loadFeed() is FlowEvent.FeedListEvent -> if (feed != null && event.contains(feed!!)) loadFeed() - is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() +// is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() else -> {} } } @@ -520,7 +530,6 @@ class FeedEpisodesFragment : Fragment() { feed = withContext(Dispatchers.IO) { val feed_ = getFeed(feedID) if (feed_ != null) { - layoutMode = if (feed_.preferences?.useWideLayout == true) 1 else 0 Logd(TAG, "loadItems feed_.episodes.size: ${feed_.episodes.size}") val eListTmp = mutableListOf() if (enableFilter && !feed_.preferences?.filterString.isNullOrEmpty()) { @@ -535,6 +544,7 @@ class FeedEpisodesFragment : Fragment() { ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index } ueMap = episodes.mapIndexedNotNull { index, episode -> episode.media?.downloadUrl?.let { it to index } }.toMap() withContext(Dispatchers.Main) { + layoutModeIndex = if (feed_.preferences?.useWideLayout == true) 1 else 0 stopMonitor(vms) vms.clear() for (e in eListTmp) { vms.add(EpisodeVM(e, TAG)) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineEpisodesFragment.kt index 3dbdc34a..a06bc423 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineEpisodesFragment.kt @@ -4,7 +4,7 @@ import ac.mdiq.podcini.R import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.ui.actions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeActions -import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.SwipeActionsDialog +import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.SwipeActionsSettingDialog import ac.mdiq.podcini.ui.actions.SwipeActions.NoActionSwipeAction import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.compose.CustomTheme @@ -30,6 +30,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -43,10 +44,11 @@ class OnlineEpisodesFragment: Fragment() { private var displayUpArrow = false private var infoBarText = mutableStateOf("") + private lateinit var swipeActions: SwipeActions private var leftActionState = mutableStateOf(NoActionSwipeAction()) private var rightActionState = mutableStateOf(NoActionSwipeAction()) + private var showSwipeActionsDialog by mutableStateOf(false) - lateinit var swipeActions: SwipeActions val episodes = mutableListOf() val vms = mutableStateListOf() @@ -58,12 +60,17 @@ class OnlineEpisodesFragment: Fragment() { if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW) swipeActions = SwipeActions(this, TAG) + leftActionState.value = swipeActions.actions.left[0] + rightActionState.value = swipeActions.actions.right[0] lifecycle.addObserver(swipeActions) val composeView = ComposeView(requireContext()).apply { setContent { CustomTheme(requireContext()) { - if (showSwipeActionsDialog) SwipeActionsDialog(TAG, onDismissRequest = { showSwipeActionsDialog = false }) { swipeActions.dialogCallback() } + if (showSwipeActionsDialog) SwipeActionsSettingDialog(swipeActions, onDismissRequest = { showSwipeActionsDialog = false }) { actions -> + swipeActions.actions = actions + refreshSwipeTelltale() + } swipeActions.ActionOptionsDialog() Scaffold(topBar = { MyTopAppBar() }) { innerPadding -> Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { @@ -71,11 +78,11 @@ class OnlineEpisodesFragment: Fragment() { EpisodeLazyColumn(activity as MainActivity, vms = vms, leftSwipeCB = { if (leftActionState.value is NoActionSwipeAction) showSwipeActionsDialog = true - else leftActionState.value.performAction(it, this@OnlineEpisodesFragment) + else leftActionState.value.performAction(it) }, rightSwipeCB = { if (rightActionState.value is NoActionSwipeAction) showSwipeActionsDialog = true - else rightActionState.value.performAction(it, this@OnlineEpisodesFragment) + else rightActionState.value.performAction(it) }, ) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt index f479c3f8..60450a88 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt @@ -24,7 +24,7 @@ import ac.mdiq.podcini.storage.model.EpisodeSortOrder.Companion.getPermutor import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.ui.actions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeActions -import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.SwipeActionsDialog +import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.SwipeActionsSettingDialog import ac.mdiq.podcini.ui.actions.SwipeActions.NoActionSwipeAction import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.compose.* @@ -88,15 +88,15 @@ class QueuesFragment : Fragment() { private lateinit var swipeActions: SwipeActions private lateinit var swipeActionsBin: SwipeActions + private var leftActionState = mutableStateOf(NoActionSwipeAction()) + private var rightActionState = mutableStateOf(NoActionSwipeAction()) + private var leftActionStateBin = mutableStateOf(NoActionSwipeAction()) + private var rightActionStateBin = mutableStateOf(NoActionSwipeAction()) private var infoTextUpdate = "" private var infoText = "" private var infoBarText = mutableStateOf("") - private var leftActionState = mutableStateOf(NoActionSwipeAction()) - private var rightActionState = mutableStateOf(NoActionSwipeAction()) - private var leftActionStateBin = mutableStateOf(NoActionSwipeAction()) - private var rightActionStateBin = mutableStateOf(NoActionSwipeAction()) private var showSwipeActionsDialog by mutableStateOf(false) private var isQueueLocked by mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefQueueLocked.name, true)) @@ -144,12 +144,21 @@ class QueuesFragment : Fragment() { // curIndex = queues.indexOf(curQueue) swipeActions = SwipeActions(this, TAG) + leftActionState.value = swipeActions.actions.left[0] + rightActionState.value = swipeActions.actions.right[0] swipeActionsBin = SwipeActions(this, "$TAG.Bin") + leftActionStateBin.value = swipeActions.actions.left[0] + rightActionStateBin.value = swipeActions.actions.right[0] + lifecycle.addObserver(swipeActions) + lifecycle.addObserver(swipeActionsBin) val composeView = ComposeView(requireContext()).apply { setContent { CustomTheme(requireContext()) { - if (showSwipeActionsDialog) SwipeActionsDialog(if (showBin) "$TAG.Bin" else TAG, onDismissRequest = { showSwipeActionsDialog = false }) { swipeActions.dialogCallback() } + if (showSwipeActionsDialog) SwipeActionsSettingDialog(if (showBin) swipeActionsBin else swipeActions, onDismissRequest = { showSwipeActionsDialog = false }) { actions -> + swipeActions.actions = actions + refreshSwipeTelltale() + } ComfirmDialog(titleRes = R.string.clear_queue_label, message = stringResource(R.string.clear_queue_confirmation_msg), showDialog = showClearQueueDialog) { clearQueue() } if (shouldShowLockWarningDiwload) ShowLockWarning { shouldShowLockWarningDiwload = false } RenameQueueDialog(showDialog = showRenameQueueDialog.value, onDismiss = { showRenameQueueDialog.value = false }) @@ -163,11 +172,11 @@ class QueuesFragment : Fragment() { InforBar(infoBarText, leftAction = leftActionStateBin, rightAction = rightActionStateBin, actionConfig = { showSwipeActionsDialog = true }) val leftCB = { episode: Episode -> if (leftActionStateBin.value is NoActionSwipeAction) showSwipeActionsDialog = true - else leftActionStateBin.value.performAction(episode, this@QueuesFragment) + else leftActionStateBin.value.performAction(episode) } val rightCB = { episode: Episode -> if (rightActionStateBin.value is NoActionSwipeAction) showSwipeActionsDialog = true - else rightActionStateBin.value.performAction(episode, this@QueuesFragment) + else rightActionStateBin.value.performAction(episode) } EpisodeLazyColumn(activity as MainActivity, vms = vms, leftSwipeCB = { leftCB(it) }, rightSwipeCB = { rightCB(it) }) } @@ -184,11 +193,11 @@ class QueuesFragment : Fragment() { InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = { showSwipeActionsDialog = true }) val leftCB = { episode: Episode -> if (leftActionState.value is NoActionSwipeAction) showSwipeActionsDialog = true - else leftActionState.value.performAction(episode, this@QueuesFragment) + else leftActionState.value.performAction(episode) } val rightCB = { episode: Episode -> if (rightActionState.value is NoActionSwipeAction) showSwipeActionsDialog = true - else rightActionState.value.performAction(episode, this@QueuesFragment) + else rightActionState.value.performAction(episode) } EpisodeLazyColumn(activity as MainActivity, vms = vms, isDraggable = dragDropEnabled, dragCB = { iFrom, iTo -> runOnIOScope { moveInQueueSync(iFrom, iTo, true) } }, @@ -297,7 +306,7 @@ class QueuesFragment : Fragment() { // is FlowEvent.PlayerSettingsEvent -> onPlayerSettingsEvent(event) is FlowEvent.FeedPrefsChangeEvent -> onFeedPrefsChanged(event) is FlowEvent.EpisodePlayedEvent -> onEpisodePlayedEvent(event) - is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() +// is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() else -> {} } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt index 06cd5683..f8677351 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt @@ -10,7 +10,7 @@ import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Rating import ac.mdiq.podcini.ui.actions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeActions -import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.SwipeActionsDialog +import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.SwipeActionsSettingDialog import ac.mdiq.podcini.ui.actions.SwipeActions.NoActionSwipeAction import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.compose.* @@ -71,10 +71,10 @@ class SearchFragment : Fragment() { private var feedName by mutableStateOf("") private var queryText by mutableStateOf("") - private var leftActionState = mutableStateOf(NoActionSwipeAction()) - private var rightActionState = mutableStateOf(NoActionSwipeAction()) private var showSwipeActionsDialog by mutableStateOf(false) private lateinit var swipeActions: SwipeActions + private var leftActionState = mutableStateOf(NoActionSwipeAction()) + private var rightActionState = mutableStateOf(NoActionSwipeAction()) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -85,6 +85,8 @@ class SearchFragment : Fragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { Logd(TAG, "fragment onCreateView") swipeActions = SwipeActions(this, TAG) + leftActionState.value = swipeActions.actions.left[0] + rightActionState.value = swipeActions.actions.right[0] lifecycle.addObserver(swipeActions) if (requireArguments().getLong(ARG_FEED, 0) > 0L) { @@ -94,7 +96,10 @@ class SearchFragment : Fragment() { val composeView = ComposeView(requireContext()).apply { setContent { CustomTheme(requireContext()) { - if (showSwipeActionsDialog) SwipeActionsDialog(TAG, onDismissRequest = { showSwipeActionsDialog = false }) { swipeActions.dialogCallback() } + if (showSwipeActionsDialog) SwipeActionsSettingDialog(swipeActions, onDismissRequest = { showSwipeActionsDialog = false }) { actions -> + swipeActions.actions = actions + refreshSwipeTelltale() + } swipeActions.ActionOptionsDialog() Scaffold(topBar = { MyTopAppBar() }) { innerPadding -> Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { @@ -112,11 +117,11 @@ class SearchFragment : Fragment() { EpisodeLazyColumn(activity as MainActivity, vms = vms, leftSwipeCB = { if (leftActionState.value is NoActionSwipeAction) showSwipeActionsDialog = true - else leftActionState.value.performAction(it, this@SearchFragment) + else leftActionState.value.performAction(it) }, rightSwipeCB = { if (rightActionState.value is NoActionSwipeAction) showSwipeActionsDialog = true - else rightActionState.value.performAction(it, this@SearchFragment) + else rightActionState.value.performAction(it) }, ) } @@ -179,7 +184,7 @@ class SearchFragment : Fragment() { Logd(TAG, "Received event: ${event.TAG}") when (event) { is FlowEvent.FeedListEvent, is FlowEvent.EpisodePlayedEvent -> search(queryText) - is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() +// is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() else -> {} } } @@ -204,9 +209,13 @@ class SearchFragment : Fragment() { private var searchJob: Job? = null @SuppressLint("StringFormatMatches") - private fun search(query: String) { - if (query.isBlank()) return - searchJob?.cancel() + private fun search(query: String) { + if (query.isBlank()) return + if (searchJob != null) { + searchJob?.cancel() + stopMonitor(vms) + vms.clear() + } searchJob = lifecycleScope.launch { try { val results_ = withContext(Dispatchers.IO) { @@ -223,16 +232,18 @@ class SearchFragment : Fragment() { if (results_.first != null) { val first_ = results_.first!!.toMutableList() results.clear() - results.addAll(first_) infoBarText.value = "${results.size} episodes" stopMonitor(vms) vms.clear() - for (e in first_) { vms.add(EpisodeVM(e, TAG)) } + if (first_.isNotEmpty()) { + results.addAll(first_) + for (e in first_) { vms.add(EpisodeVM(e, TAG)) } + } } if (requireArguments().getLong(ARG_FEED, 0) == 0L) { if (results_.second != null) { resultFeeds.clear() - resultFeeds.addAll(results_.second!!) + if (results_.second.isNotEmpty()) resultFeeds.addAll(results_.second!!) } } else resultFeeds.clear() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt index 5d2ba698..e57ac143 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt @@ -11,6 +11,7 @@ import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.OnlineFeedItem import ac.mdiq.podcini.ui.compose.SearchBarRow +import ac.mdiq.podcini.ui.compose.stopMonitor import ac.mdiq.podcini.util.Logd import android.annotation.SuppressLint import android.os.Bundle @@ -127,7 +128,10 @@ class SearchResultsFragment : Fragment() { @SuppressLint("StringFormatMatches") private fun search(query: String) { if (query.isBlank()) return - searchJob?.cancel() + if (searchJob != null) { + searchJob?.cancel() + searchResults.clear() + } showOnlyProgressBar() searchJob = lifecycleScope.launch(Dispatchers.IO) { val feeds = getFeedList() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt index 57cb4efd..120915b5 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt @@ -344,7 +344,10 @@ class SubscriptionsFragment : Fragment() { private var loadingJob: Job? = null private fun loadSubscriptions() { - loadingJob?.cancel() + if (loadingJob != null) { + loadingJob?.cancel() + feedListFiltered.clear() + } loadingJob = lifecycleScope.launch { val feedList: List try { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt index 5c48d7e9..2cfd7a92 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt @@ -191,7 +191,7 @@ sealed class FlowEvent { data class MessageEvent(val message: String, val action: Consumer? = null, val actionText: String? = null) : FlowEvent() - data class SwipeActionsChangedEvent(val dummy: Unit = Unit) : FlowEvent() +// data class SwipeActionsChangedEvent(val dummy: Unit = Unit) : FlowEvent() data class SyncServiceEvent(val messageResId: Int, val message: String = "") : FlowEvent() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/MiscFormatter.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/MiscFormatter.kt index e8b09bd7..c4bf78a8 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/MiscFormatter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/util/MiscFormatter.kt @@ -82,4 +82,8 @@ object MiscFormatter { else -> String.format(Locale.getDefault(), "%.2fB", n / 1_000_000_000.0) } } + + fun dateStampFilename(fname: String): String { + return String.format(fname, SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7479d351..d480334b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -407,6 +407,13 @@ Details Import/Export backup, restore + Auto backup + Automatically back up DB and preferences at a specified interval into a selected folder. + Backup interval + Spicify a backup folder + Not specified. + Number of backups to keep + Appearance External elements Create YT syndicates diff --git a/changelog.md b/changelog.md index 42a3d404..61f6716b 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,17 @@ +# 6.16.4 + +* ensure cleanup when cancelling coroutine tasks +* enhanced and cleaned SwipeActions settings + * removed swipe actions settings in Preferences, better be set in associated fragment screen + * change of swipe actions is handled directly in the fragment, no long need for posting an event or calling redundant callback +* fixed issue of setting wide layout in FeedEpsiodes +* added auto backup settings in Settings->Import/Export + * if turned on, one needs to specify interval (in hours), a folder, and number of copies to keep + * then Preferences and DB are backed up in sub-folder named "Podcini-AudoBackups-(date)" + * backup time is on the next resume of Podcini after interval hours from last backup time + * to restore, use Combo restore +* media3 updated to 1.5.1 + # 6.16.3 * to calm Google tested ANR stability issue, ensure only one swipe actions setting dialog is open diff --git a/fastlane/metadata/android/en-US/changelogs/3020326.txt b/fastlane/metadata/android/en-US/changelogs/3020326.txt new file mode 100644 index 00000000..f7718131 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020326.txt @@ -0,0 +1,13 @@ + Version 6.16.4 + +* ensure cleanup when cancelling coroutine tasks +* enhanced and cleaned SwipeActions settings + * removed swipe actions settings in Preferences, better be set in associated fragment screen + * change of swipe actions is handled directly in the fragment, no long need for posting an event or calling redundant callback +* fixed issue of setting wide layout in FeedEpsiodes +* added auto backup settings in Settings->Import/Export + * if turned on, one needs to specify interval (in hours), a folder, and number of copies to keep + * then Preferences and DB are backed up in sub-folder named "Podcini-AudoBackups-(date)" + * backup time is on the next resume of Podcini after interval hours from last backup time + * to restore, use Combo restore +* media3 updated to 1.5.1