diff --git a/app/build.gradle b/app/build.gradle index fbd2d28f..03dfff21 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,8 +26,8 @@ android { vectorDrawables.useSupportLibrary false vectorDrawables.generatedDensities = [] - versionCode 3020306 - versionName "6.14.7" + versionCode 3020307 + versionName "6.14.8" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/PodciniApp.kt b/app/src/main/kotlin/ac/mdiq/podcini/PodciniApp.kt index 8ddbec48..9c61c65b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/PodciniApp.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/PodciniApp.kt @@ -44,9 +44,7 @@ class PodciniApp : Application() { super.onCreate() ClientConfig.USER_AGENT = "Podcini/" + BuildConfig.VERSION_NAME ClientConfig.applicationCallbacks = ApplicationCallbacksImpl() - Thread.setDefaultUncaughtExceptionHandler(CrashReportWriter()) - if (BuildConfig.DEBUG) { val builder: StrictMode.VmPolicy.Builder = StrictMode.VmPolicy.Builder() .detectAll() @@ -56,7 +54,6 @@ class PodciniApp : Application() { } singleton = this - runBlocking { withContext(Dispatchers.IO) { ClientConfigurator.initialize(this@PodciniApp) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/utils/NetworkUtils.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/utils/NetworkUtils.kt index 9a1813a2..48eaf341 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/utils/NetworkUtils.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/utils/NetworkUtils.kt @@ -33,8 +33,9 @@ object NetworkUtils { setAllowMobileFor("auto_download", allow) } + // not using this val isEnableAutodownloadWifiFilter: Boolean - get() = Build.VERSION.SDK_INT < 29 && appPrefs.getBoolean(UserPreferences.Prefs.prefEnableAutoDownloadWifiFilter.name, false) + get() = false && Build.VERSION.SDK_INT < 29 && appPrefs.getBoolean(UserPreferences.Prefs.prefEnableAutoDownloadWifiFilter.name, false) @JvmStatic val isAutoDownloadAllowed: Boolean diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/ExportWorker.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/ExportWorker.kt new file mode 100644 index 00000000..ebfe06ce --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/ExportWorker.kt @@ -0,0 +1,85 @@ +package ac.mdiq.podcini.preferences + +import ac.mdiq.podcini.R +import ac.mdiq.podcini.storage.database.Feeds.getFeedList +import ac.mdiq.podcini.storage.model.Feed +import ac.mdiq.podcini.storage.utils.FilesUtils.getDataFolder +import ac.mdiq.podcini.util.Logd +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.annotation.StringRes +import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.nio.charset.Charset + +enum class ExportTypes(val contentType: String, val outputNameTemplate: String, @field:StringRes val labelResId: Int) { + OPML("text/x-opml", "podcini-feeds-%s.opml", R.string.opml_export_label), + OPML_SELECTED("text/x-opml", "podcini-feeds-selected-%s.opml", R.string.opml_export_label), + HTML("text/html", "podcini-feeds-%s.html", R.string.html_export_label), + FAVORITES("text/html", "podcini-favorites-%s.html", R.string.favorites_export_label), + PROGRESS("text/x-json", "podcini-progress-%s.json", R.string.progress_export_label), +} + +/** + * Writes an OPML file into the export directory in the background. + */ +class ExportWorker private constructor(private val exportWriter: ExportWriter, private val output: File, private val context: Context) { + constructor(exportWriter: ExportWriter, context: Context) : this(exportWriter, File(getDataFolder(EXPORT_DIR), + DEFAULT_OUTPUT_NAME + "." + exportWriter.fileExtension()), context) + suspend fun exportFile(feeds: List? = null): File? { + return withContext(Dispatchers.IO) { + if (output.exists()) { + val success = output.delete() + Logd(TAG, "Overwriting previously exported file: $success") + } + var writer: OutputStreamWriter? = null + try { + writer = OutputStreamWriter(FileOutputStream(output), Charset.forName("UTF-8")) + val feeds_ = feeds ?: getFeedList() + Logd(TAG, "feeds_: ${feeds_.size}") + exportWriter.writeDocument(feeds_, writer, context) + output // return the output file + } catch (e: IOException) { + Log.e(TAG, "Error during file export", e) + null // return null in case of error + } finally { writer?.close() } + } + } + companion object { + private const val EXPORT_DIR = "export/" + private val TAG: String = ExportWorker::class.simpleName ?: "Anonymous" + private const val DEFAULT_OUTPUT_NAME = "podcini-feeds" + } +} + +class DocumentFileExportWorker(private val exportWriter: ExportWriter, private val context: Context, private val outputFileUri: Uri) { + suspend fun exportFile(feeds: List? = null): DocumentFile { + return withContext(Dispatchers.IO) { + val output = DocumentFile.fromSingleUri(context, outputFileUri) + var outputStream: OutputStream? = null + var writer: OutputStreamWriter? = null + try { + if (output == null) throw IOException() + val uri = output.uri + outputStream = context.contentResolver.openOutputStream(uri, "wt") + if (outputStream == null) throw IOException() + writer = OutputStreamWriter(outputStream, Charset.forName("UTF-8")) + val feeds_ = feeds ?: getFeedList() + Logd("DocumentFileExportWorker", "feeds_: ${feeds_.size}") + exportWriter.writeDocument(feeds_, writer, context) + output + } catch (e: IOException) { throw e + } finally { + if (writer != null) try { writer.close() } catch (e: IOException) { throw e } + if (outputStream != null) try { outputStream.close() } catch (e: IOException) { throw e } + } + } + } +} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/MasterSwitchPreference.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/MasterSwitchPreference.kt deleted file mode 100644 index 7f3ad895..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/MasterSwitchPreference.kt +++ /dev/null @@ -1,30 +0,0 @@ -package ac.mdiq.podcini.preferences - -import android.content.Context -import android.graphics.Typeface -import android.util.AttributeSet -import android.widget.TextView -import androidx.preference.PreferenceViewHolder -import androidx.preference.SwitchPreferenceCompat -import ac.mdiq.podcini.R -import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr - -class MasterSwitchPreference : SwitchPreferenceCompat { - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super( - context, attrs, defStyleAttr, defStyleRes) - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - - constructor(context: Context) : super(context) - - - override fun onBindViewHolder(holder: PreferenceViewHolder) { - super.onBindViewHolder(holder) - - holder.itemView.setBackgroundColor(getColorFromAttr(context, com.google.android.material.R.attr.colorSurfaceVariant)) - val title = holder.findViewById(android.R.id.title) as? TextView - title?.setTypeface(title.typeface, Typeface.BOLD) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/MaterialListPreference.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/MaterialListPreference.kt deleted file mode 100644 index 75265468..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/MaterialListPreference.kt +++ /dev/null @@ -1,37 +0,0 @@ -package ac.mdiq.podcini.preferences - -import android.content.Context -import android.content.DialogInterface -import android.util.AttributeSet -import androidx.preference.ListPreference -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class MaterialListPreference : ListPreference { - constructor(context: Context) : super(context) - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - - override fun onClick() { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(title) - builder.setIcon(dialogIcon) - builder.setNegativeButton(negativeButtonText, null) - - val values = entryValues - var selected = -1 - for (i in values.indices) { - if (values[i].toString() == value) { - selected = i - break - } - } - builder.setSingleChoiceItems(entries, selected) { dialog: DialogInterface, which: Int -> - dialog.dismiss() - if (which >= 0 && entryValues != null) { - val value = entryValues[which].toString() - if (callChangeListener(value)) setValue(value) - } - } - builder.show() - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/MaterialMultiSelectListPreference.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/MaterialMultiSelectListPreference.kt deleted file mode 100644 index 92f28a8f..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/MaterialMultiSelectListPreference.kt +++ /dev/null @@ -1,36 +0,0 @@ -package ac.mdiq.podcini.preferences - -import android.content.Context -import android.content.DialogInterface -import android.util.AttributeSet -import androidx.preference.MultiSelectListPreference -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class MaterialMultiSelectListPreference : MultiSelectListPreference { - - constructor(context: Context) : super(context) - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - - override fun onClick() { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(title) - builder.setIcon(dialogIcon) - builder.setNegativeButton(negativeButtonText, null) - - val selected = BooleanArray(entries.size) - val values = entryValues - for (i in values.indices) { - selected[i] = getValues().contains(values[i].toString()) - } - builder.setMultiChoiceItems(entries, selected) { _: DialogInterface?, which: Int, isChecked: Boolean -> selected[which] = isChecked } - builder.setPositiveButton("OK") { _: DialogInterface?, _: Int -> - val selectedValues: MutableSet = HashSet() - for (i in values.indices) { - if (selected[i]) selectedValues.add(entryValues[i].toString()) - } - setValues(selectedValues) - } - builder.show() - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/ThemePreference.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/ThemePreference.kt deleted file mode 100644 index 9414de4c..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/ThemePreference.kt +++ /dev/null @@ -1,47 +0,0 @@ -package ac.mdiq.podcini.preferences - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.ThemePreferenceBinding -import android.content.Context -import android.util.AttributeSet -import androidx.cardview.widget.CardView -import androidx.preference.Preference -import androidx.preference.PreferenceViewHolder -import com.google.android.material.elevation.SurfaceColors - -class ThemePreference : Preference { - var binding: ThemePreferenceBinding? = null - - constructor(context: Context) : super(context) { - layoutResource = R.layout.theme_preference - } - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - layoutResource = R.layout.theme_preference - } - - override fun onBindViewHolder(holder: PreferenceViewHolder) { - super.onBindViewHolder(holder) - binding = ThemePreferenceBinding.bind(holder.itemView) - updateUi() - } - - fun updateThemeCard(card: CardView, theme: UserPreferences.ThemePreference) { - val density = context.resources.displayMetrics.density - val surfaceColor = SurfaceColors.getColorForElevation(context, 1 * density) - val surfaceColorActive = SurfaceColors.getColorForElevation(context, 32 * density) - val activeTheme = UserPreferences.theme - card.setCardBackgroundColor(if (theme == activeTheme) surfaceColorActive else surfaceColor) - card.setOnClickListener { - UserPreferences.theme = theme - onPreferenceChangeListener?.onPreferenceChange(this, UserPreferences.theme) - updateUi() - } - } - - fun updateUi() { - updateThemeCard(binding!!.themeSystemCard, UserPreferences.ThemePreference.SYSTEM) - updateThemeCard(binding!!.themeLightCard, UserPreferences.ThemePreference.LIGHT) - updateThemeCard(binding!!.themeDarkCard, UserPreferences.ThemePreference.DARK) - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UsageStatistics.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UsageStatistics.kt index fdf0a861..7195b7e4 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UsageStatistics.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UsageStatistics.kt @@ -26,7 +26,6 @@ object UsageStatistics { /** * Sets up the UsageStatistics class. - * * @throws IllegalArgumentException if context is null */ @JvmStatic 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 888c822f..42ea8f9b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt @@ -23,13 +23,7 @@ import java.net.Proxy object UserPreferences { private val TAG: String = UserPreferences::class.simpleName ?: "Anonymous" - // Experimental - const val EPISODE_CLEANUP_QUEUE: Int = -1 - const val EPISODE_CLEANUP_NULL: Int = -2 - const val EPISODE_CLEANUP_EXCEPT_FAVORITE: Int = -3 - const val EPISODE_CLEANUP_DEFAULT: Int = 0 - - const val EPISODE_CACHE_SIZE_UNLIMITED: Int = -1 + const val EPISODE_CACHE_SIZE_UNLIMITED: Int = 0 const val DEFAULT_PAGE_REMEMBER: String = "remember" @@ -104,8 +98,7 @@ object UserPreferences { /** * Returns the capacity of the episode cache. This method will return the - * negative integer EPISODE_CACHE_SIZE_UNLIMITED if the cache size is set to - * 'unlimited'. + * EPISODE_CACHE_SIZE_UNLIMITED (0) if the cache size is set to 'unlimited'. */ val episodeCacheSize: Int get() = appPrefs.getString(Prefs.prefEpisodeCacheSize.name, "20")!!.toInt() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AutoDownloadPreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AutoDownloadPreferencesFragment.kt deleted file mode 100644 index bc478ebf..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AutoDownloadPreferencesFragment.kt +++ /dev/null @@ -1,168 +0,0 @@ -package ac.mdiq.podcini.preferences.fragments - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.net.utils.NetworkUtils.autodownloadSelectedNetworks -import ac.mdiq.podcini.net.utils.NetworkUtils.isEnableAutodownloadWifiFilter -import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.preferences.UserPreferences.appPrefs -import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload -import ac.mdiq.podcini.ui.activity.PreferenceActivity -import ac.mdiq.podcini.util.Logd -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Context -import android.net.wifi.WifiConfiguration -import android.net.wifi.WifiManager -import android.os.Build -import android.os.Bundle -import android.util.Log -import androidx.preference.CheckBoxPreference -import androidx.preference.ListPreference -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat - -class AutoDownloadPreferencesFragment : PreferenceFragmentCompat() { - private var selectedNetworks: Array? = null - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.preferences_autodownload) - - setupAutoDownloadScreen() - buildAutodownloadSelectedNetworksPreference() - setSelectedNetworksEnabled(isEnableAutodownloadWifiFilter) - buildEpisodeCleanupPreference() - } - - override fun onStart() { - super.onStart() - (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.pref_automatic_download_title) - } - - override fun onResume() { - super.onResume() - checkAutodownloadItemVisibility(isEnableAutodownload) - } - - private fun setupAutoDownloadScreen() { - findPreference(UserPreferences.Prefs.prefEnableAutoDl.name)!!.onPreferenceChangeListener = - Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any? -> - if (newValue is Boolean) checkAutodownloadItemVisibility(newValue) - true - } - if (Build.VERSION.SDK_INT >= 29) findPreference(UserPreferences.Prefs.prefEnableAutoDownloadWifiFilter.name)!!.isVisible = false - findPreference(UserPreferences.Prefs.prefEnableAutoDownloadWifiFilter.name)?.onPreferenceChangeListener = - Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any? -> - if (newValue is Boolean) { - setSelectedNetworksEnabled(newValue) - return@OnPreferenceChangeListener true - } else return@OnPreferenceChangeListener false - } - } - - private fun checkAutodownloadItemVisibility(autoDownload: Boolean) { - findPreference(UserPreferences.Prefs.prefEpisodeCacheSize.name)!!.isEnabled = autoDownload - findPreference(UserPreferences.Prefs.prefEnableAutoDownloadOnBattery.name)!!.isEnabled = autoDownload - findPreference(UserPreferences.Prefs.prefEnableAutoDownloadWifiFilter.name)!!.isEnabled = autoDownload - findPreference(UserPreferences.Prefs.prefEpisodeCleanup.name)!!.isEnabled = autoDownload - setSelectedNetworksEnabled(autoDownload && isEnableAutodownloadWifiFilter) - } - - @SuppressLint("MissingPermission") // getConfiguredNetworks needs location permission starting with API 29 - private fun buildAutodownloadSelectedNetworksPreference() { - if (Build.VERSION.SDK_INT >= 29) return - val activity: Activity? = activity - - if (selectedNetworks != null) clearAutodownloadSelectedNetworsPreference() - - // get configured networks - val wifiservice = activity!!.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - val networks = wifiservice.configuredNetworks - - if (networks == null) { - Log.e(TAG, "Couldn't get list of configure Wi-Fi networks") - return - } - networks.sortWith { x: WifiConfiguration, y: WifiConfiguration -> blankIfNull(x.SSID).compareTo(blankIfNull(y.SSID), ignoreCase = true) } - selectedNetworks = arrayOfNulls(networks.size) - val prefValues = listOf(*autodownloadSelectedNetworks) - val prefScreen = preferenceScreen - val clickListener = Preference.OnPreferenceClickListener { preference: Preference -> - if (preference is CheckBoxPreference) { - val key = preference.getKey() - val prefValuesList: MutableList = ArrayList(listOf(*autodownloadSelectedNetworks)) - val newValue = preference.isChecked - Logd(TAG, "Selected network $key. New state: $newValue") - - val index = prefValuesList.indexOf(key) - when { - // remove network - index >= 0 && !newValue -> prefValuesList.removeAt(index) - index < 0 && newValue -> prefValuesList.add(key) - } - - setAutodownloadSelectedNetworks(prefValuesList.toTypedArray()) - return@OnPreferenceClickListener true - } else return@OnPreferenceClickListener false - } - // create preference for each known network. attach listener and set - // value - for (i in networks.indices) { - val config = networks[i] - - val pref = CheckBoxPreference(activity) - val key = config.networkId.toString() - pref.title = config.SSID - pref.key = key - pref.onPreferenceClickListener = clickListener - pref.isPersistent = false - pref.isChecked = prefValues.contains(key) - selectedNetworks!![i] = pref - prefScreen.addPreference(pref) - } - } - - private fun setAutodownloadSelectedNetworks(value: Array?) { - appPrefs.edit().putString(UserPreferences.Prefs.prefAutodownloadSelectedNetworks.name, value!!.joinToString()).apply() - } - - private fun clearAutodownloadSelectedNetworsPreference() { - if (selectedNetworks != null) { - val prefScreen = preferenceScreen - for (network in selectedNetworks!!) if (network != null) prefScreen.removePreference(network) - } - } - - private fun buildEpisodeCleanupPreference() { - val res = requireActivity().resources - - val pref = findPreference(UserPreferences.Prefs.prefEpisodeCleanup.name) - val values = res.getStringArray(R.array.episode_cleanup_values) - val entries = arrayOfNulls(values.size) - for (x in values.indices) { - when (val v = values[x].toInt()) { - UserPreferences.EPISODE_CLEANUP_EXCEPT_FAVORITE -> entries[x] = res.getString(R.string.episode_cleanup_except_favorite_removal) - UserPreferences.EPISODE_CLEANUP_QUEUE -> entries[x] = res.getString(R.string.episode_cleanup_queue_removal) - UserPreferences.EPISODE_CLEANUP_NULL -> entries[x] = res.getString(R.string.episode_cleanup_never) - 0 -> entries[x] = res.getString(R.string.episode_cleanup_after_listening) - in 1..23 -> entries[x] = res.getQuantityString(R.plurals.episode_cleanup_hours_after_listening, v, v) - else -> { - val numDays = v / 24 // assume underlying value will be NOT fraction of days, e.g., 36 (hours) - entries[x] = res.getQuantityString(R.plurals.episode_cleanup_days_after_listening, numDays, numDays) - } - } - } - pref!!.entries = entries - } - - private fun setSelectedNetworksEnabled(b: Boolean) { - if (selectedNetworks != null) for (p in selectedNetworks!!) p!!.isEnabled = b - } - - companion object { - private val TAG: String = AutoDownloadPreferencesFragment::class.simpleName ?: "Anonymous" - - private fun blankIfNull(`val`: String?): String { - return `val` ?: "" - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/DownloadsPreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/DownloadsPreferencesFragment.kt deleted file mode 100644 index 4cd5d25d..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/DownloadsPreferencesFragment.kt +++ /dev/null @@ -1,416 +0,0 @@ -package ac.mdiq.podcini.preferences.fragments - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.ProxySettingsBinding -import ac.mdiq.podcini.net.download.service.PodciniHttpClient -import ac.mdiq.podcini.net.download.service.PodciniHttpClient.newBuilder -import ac.mdiq.podcini.net.download.service.PodciniHttpClient.reinit -import ac.mdiq.podcini.net.feed.FeedUpdateManager.restartUpdateAlarm -import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.preferences.UserPreferences.appPrefs -import ac.mdiq.podcini.preferences.UserPreferences.proxyConfig -import ac.mdiq.podcini.storage.model.ProxyConfig -import ac.mdiq.podcini.ui.activity.PreferenceActivity -import ac.mdiq.podcini.ui.compose.CustomTheme -import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr -import android.app.Dialog -import android.content.Context -import android.content.DialogInterface -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.util.Patterns -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.* -import androidx.appcompat.app.AlertDialog -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -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.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -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.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.TwoStatePreference -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.Credentials.basic -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Request.Builder -import okhttp3.Response -import okhttp3.Route -import java.io.IOException -import java.net.InetSocketAddress -import java.net.Proxy -import java.net.SocketAddress -import java.util.concurrent.TimeUnit - -class DownloadsPreferencesFragment : PreferenceFragmentCompat() { - private var blockAutoDeleteLocal = true - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { -// addPreferencesFromResource(R.xml.preferences_downloads) -// setupNetworkScreen() - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.downloads_pref) - return ComposeView(requireContext()).apply { - setContent { - CustomTheme(requireContext()) { - val textColor = MaterialTheme.colorScheme.onSurface - val scrollState = rememberScrollState() - Column(modifier = Modifier.fillMaxWidth().padding(16.dp).verticalScroll(scrollState)) { - Text(stringResource(R.string.automation), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.feed_refresh_title), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) - var interval by remember { mutableStateOf(appPrefs.getString(UserPreferences.Prefs.prefAutoUpdateIntervall.name, "12")!!) } - TextField(value = interval, onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) interval = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), label = { Text("(hours)") }, - singleLine = true, modifier = Modifier.weight(0.5f), - trailingIcon = { - Icon(imageVector = Icons.Filled.Settings, contentDescription = "Settings icon", - modifier = Modifier.size(30.dp).padding(start = 10.dp).clickable(onClick = { - if (interval.isEmpty()) interval = "0" - appPrefs.edit().putString(UserPreferences.Prefs.prefAutoUpdateIntervall.name, interval).apply() - restartUpdateAlarm(requireContext(), true) - })) - }) - } - Text(stringResource(R.string.feed_refresh_sum), color = textColor) - } - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - (activity as PreferenceActivity).openScreen(R.xml.preferences_autodownload) - })) { - Text(stringResource(R.string.pref_automatic_download_title), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.pref_automatic_download_sum), color = textColor) - } - 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_delete_title), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.pref_auto_delete_sum), color = textColor) - } - Switch(checked = false, onCheckedChange = { appPrefs.edit().putBoolean(UserPreferences.Prefs.prefAutoDelete.name, it).apply() }) - } - 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_local_delete_title), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.pref_auto_local_delete_sum), color = textColor) - } - Switch(checked = false, onCheckedChange = { - if (blockAutoDeleteLocal && it) { -// showAutoDeleteEnableDialog() - MaterialAlertDialogBuilder(requireContext()) - .setMessage(R.string.pref_auto_local_delete_dialog_body) - .setPositiveButton(R.string.yes) { _: DialogInterface?, _: Int -> - blockAutoDeleteLocal = false - (findPreference(Prefs.prefAutoDeleteLocal.name) as TwoStatePreference?)!!.isChecked = true - blockAutoDeleteLocal = true - } - .setNegativeButton(R.string.cancel_label, null) - .show() - } - }) - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.pref_keeps_important_episodes_title), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.pref_keeps_important_episodes_sum), color = textColor) - } - Switch(checked = true, onCheckedChange = { appPrefs.edit().putBoolean(UserPreferences.Prefs.prefFavoriteKeepsEpisode.name, it).apply() }) - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.pref_delete_removes_from_queue_title), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.pref_delete_removes_from_queue_sum), color = textColor) - } - Switch(checked = true, onCheckedChange = { appPrefs.edit().putBoolean(UserPreferences.Prefs.prefDeleteRemovesFromQueue.name, it).apply() }) - } - Text(stringResource(R.string.download_pref_details), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 10.dp)) - var showMeteredNetworkOptions by remember { mutableStateOf(false) } - var tempSelectedOptions by remember { mutableStateOf(appPrefs.getStringSet(UserPreferences.Prefs.prefMobileUpdateTypes.name, setOf("images"))!!) } - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { showMeteredNetworkOptions = true })) { - Text(stringResource(R.string.pref_metered_network_title), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.pref_mobileUpdate_sum), color = textColor) - } - if (showMeteredNetworkOptions) { - AlertDialog(onDismissRequest = { showMeteredNetworkOptions = false }, - title = { Text(stringResource(R.string.pref_metered_network_title), style = MaterialTheme.typography.headlineSmall) }, - text = { - Column { - MobileUpdateOptions.entries.forEach { option -> - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(5.dp) - .clickable { - tempSelectedOptions = if (tempSelectedOptions.contains(option.name)) tempSelectedOptions - option.name - else tempSelectedOptions + option.name - }) { - Checkbox(checked = tempSelectedOptions.contains(option.name), - onCheckedChange = { - tempSelectedOptions = if (tempSelectedOptions.contains(option.name)) tempSelectedOptions - option.name - else tempSelectedOptions + option.name - }) - Text(stringResource(option.res), modifier = Modifier.padding(start = 16.dp), style = MaterialTheme.typography.bodyMedium) - } - } - } - }, - confirmButton = { - TextButton(onClick = { - appPrefs.edit().putStringSet(UserPreferences.Prefs.prefMobileUpdateTypes.name, tempSelectedOptions).apply() - showMeteredNetworkOptions = false - }) { Text(text = "OK") } - }, - dismissButton = { TextButton(onClick = { showMeteredNetworkOptions = false }) { Text(text = "Cancel") } } - ) - } - - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - ProxyDialog(requireContext()).show() - })) { - Text(stringResource(R.string.pref_proxy_title), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.pref_proxy_sum), color = textColor) - } - } - } - } - } - } - - enum class MobileUpdateOptions(val res: Int) { - feed_refresh(R.string.pref_mobileUpdate_refresh), - episode_download(R.string.pref_mobileUpdate_episode_download), - auto_download(R.string.pref_mobileUpdate_auto_download), - streaming(R.string.pref_mobileUpdate_streaming), - images(R.string.pref_mobileUpdate_images), - sync(R.string.synchronization_pref); - } - - class ProxyDialog(private val context: Context) { - private lateinit var dialog: AlertDialog - private lateinit var spType: Spinner - private lateinit var etHost: EditText - private lateinit var etPort: EditText - private lateinit var etUsername: EditText - private lateinit var etPassword: EditText - private lateinit var txtvMessage: TextView - private var testSuccessful = false - private val port: Int - get() { - val port = etPort.text.toString() - if (port.isNotEmpty()) try { return port.toInt() } catch (e: NumberFormatException) { } - return 0 - } - - fun show(): Dialog { - val content = View.inflate(context, R.layout.proxy_settings, null) - val binding = ProxySettingsBinding.bind(content) - spType = binding.spType - dialog = MaterialAlertDialogBuilder(context) - .setTitle(R.string.pref_proxy_title) - .setView(content) - .setNegativeButton(R.string.cancel_label, null) - .setPositiveButton(R.string.proxy_test_label, null) - .setNeutralButton(R.string.reset, null) - .show() - // To prevent cancelling the dialog on button click - dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { - if (!testSuccessful) { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false - test() - return@setOnClickListener - } - setProxyConfig() - reinit() - dialog.dismiss() - } - dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { - etHost.text.clear() - etPort.text.clear() - etUsername.text.clear() - etPassword.text.clear() - setProxyConfig() - } - val types: MutableList = ArrayList() - types.add(Proxy.Type.DIRECT.name) - types.add(Proxy.Type.HTTP.name) - types.add(Proxy.Type.SOCKS.name) - val adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, types) - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - spType.setAdapter(adapter) - val proxyConfig = proxyConfig - spType.setSelection(adapter.getPosition(proxyConfig.type.name)) - etHost = binding.etHost - if (!proxyConfig.host.isNullOrEmpty()) etHost.setText(proxyConfig.host) - etHost.addTextChangedListener(requireTestOnChange) - etPort = binding.etPort - if (proxyConfig.port > 0) etPort.setText(proxyConfig.port.toString()) - etPort.addTextChangedListener(requireTestOnChange) - etUsername = binding.etUsername - if (!proxyConfig.username.isNullOrEmpty()) etUsername.setText(proxyConfig.username) - etUsername.addTextChangedListener(requireTestOnChange) - etPassword = binding.etPassword - if (!proxyConfig.password.isNullOrEmpty()) etPassword.setText(proxyConfig.password) - etPassword.addTextChangedListener(requireTestOnChange) - if (proxyConfig.type == Proxy.Type.DIRECT) { - enableSettings(false) - setTestRequired(false) - } - spType.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) { - dialog.getButton(AlertDialog.BUTTON_NEUTRAL).visibility = if (position == 0) View.GONE else View.VISIBLE - enableSettings(position > 0) - setTestRequired(position > 0) - } - override fun onNothingSelected(parent: AdapterView<*>?) { - enableSettings(false) - } - } - txtvMessage = binding.txtvMessage - checkValidity() - return dialog - } - private fun setProxyConfig() { - val type = spType.selectedItem as String - val typeEnum = Proxy.Type.valueOf(type) - val host = etHost.text.toString() - val port = etPort.text.toString() - var username: String? = etUsername.text.toString() - if (username.isNullOrEmpty()) username = null - var password: String? = etPassword.text.toString() - if (password.isNullOrEmpty()) password = null - var portValue = 0 - if (port.isNotEmpty()) portValue = port.toInt() - val config = ProxyConfig(typeEnum, host, portValue, username, password) - proxyConfig = config - PodciniHttpClient.setProxyConfig(config) - } - private val requireTestOnChange: TextWatcher = object : TextWatcher { - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} - override fun afterTextChanged(s: Editable) { - setTestRequired(true) - } - } - private fun enableSettings(enable: Boolean) { - etHost.isEnabled = enable - etPort.isEnabled = enable - etUsername.isEnabled = enable - etPassword.isEnabled = enable - } - private fun checkValidity(): Boolean { - var valid = true - if (spType.selectedItemPosition > 0) valid = checkHost() - valid = valid and checkPort() - return valid - } - private fun checkHost(): Boolean { - val host = etHost.text.toString() - if (host.isEmpty()) { - etHost.error = context.getString(R.string.proxy_host_empty_error) - return false - } - if ("localhost" != host && !Patterns.DOMAIN_NAME.matcher(host).matches()) { - etHost.error = context.getString(R.string.proxy_host_invalid_error) - return false - } - return true - } - private fun checkPort(): Boolean { - val port = port - if (port < 0 || port > 65535) { - etPort.error = context.getString(R.string.proxy_port_invalid_error) - return false - } - return true - } - private fun setTestRequired(required: Boolean) { - if (required) { - testSuccessful = false - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.proxy_test_label) - } else { - testSuccessful = true - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(android.R.string.ok) - } - dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true - } - private fun test() { - if (!checkValidity()) { - setTestRequired(true) - return - } - val res = context.theme.obtainStyledAttributes(intArrayOf(android.R.attr.textColorPrimary)) - val textColorPrimary = res.getColor(0, 0) - res.recycle() - val checking = context.getString(R.string.proxy_checking) - txtvMessage.setTextColor(textColorPrimary) - txtvMessage.text = "{faw_circle_o_notch spin} $checking" - txtvMessage.visibility = View.VISIBLE - val coroutineScope = CoroutineScope(Dispatchers.Main) - coroutineScope.launch(Dispatchers.IO) { - try { - val type = spType.selectedItem as String - val host = etHost.text.toString() - val port = etPort.text.toString() - val username = etUsername.text.toString() - val password = etPassword.text.toString() - var portValue = 8080 - if (port.isNotEmpty()) portValue = port.toInt() - val address: SocketAddress = InetSocketAddress.createUnresolved(host, portValue) - val proxyType = Proxy.Type.valueOf(type.uppercase()) - val builder: OkHttpClient.Builder = newBuilder().connectTimeout(10, TimeUnit.SECONDS).proxy(Proxy(proxyType, address)) - if (username.isNotEmpty()) { - builder.proxyAuthenticator { _: Route?, response: Response -> - val credentials = basic(username, password) - response.request.newBuilder().header("Proxy-Authorization", credentials).build() - } - } - val client: OkHttpClient = builder.build() - val request: Request = Builder().url("https://www.example.com").head().build() - try { - client.newCall(request).execute().use { response -> if (!response.isSuccessful) throw IOException(response.message) } - } catch (e: IOException) { throw e } - withContext(Dispatchers.Main) { - txtvMessage.setTextColor(getColorFromAttr(context, R.attr.icon_green)) - val message = String.format("%s %s", "{faw_check}", context.getString(R.string.proxy_test_successful)) - txtvMessage.text = message - setTestRequired(false) - } - } catch (e: Throwable) { - e.printStackTrace() - txtvMessage.setTextColor(getColorFromAttr(context, R.attr.icon_red)) - val message = String.format("%s %s: %s", "{faw_close}", context.getString(R.string.proxy_test_failed), e.message) - txtvMessage.text = message - setTestRequired(true) - } - } - } - } - - @Suppress("EnumEntryName") - private enum class Prefs { - prefAutoDownloadSettings, - prefAutoDeleteLocal, - prefProxy, - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt deleted file mode 100644 index 0476531d..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt +++ /dev/null @@ -1,1192 +0,0 @@ -package ac.mdiq.podcini.preferences.fragments - -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.ExportWriter -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.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.database.Episodes.hasAlmostEnded -import ac.mdiq.podcini.storage.utils.FileNameGenerator.generateFileName -import ac.mdiq.podcini.storage.utils.FilesUtils.getDataFolder -import ac.mdiq.podcini.ui.activity.OpmlImportActivity -import ac.mdiq.podcini.ui.activity.PreferenceActivity -import ac.mdiq.podcini.ui.compose.CustomTheme -import ac.mdiq.podcini.util.Logd -import android.app.Activity.RESULT_OK -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.DialogInterface -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.os.ParcelFileDescriptor -import android.text.format.Formatter -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.ActivityResult -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.result.contract.ActivityResultContracts.CreateDocument -import androidx.annotation.StringRes -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.core.app.ShareCompat.IntentBuilder -import androidx.core.content.FileProvider -import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.lifecycleScope -import androidx.preference.PreferenceFragmentCompat -import com.google.android.material.dialog.MaterialAlertDialogBuilder -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.nio.charset.Charset -import java.text.SimpleDateFormat -import java.util.* -import kotlin.Throws - -class ImportExportPreferencesFragment : PreferenceFragmentCompat() { - - private val chooseOpmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - result: ActivityResult -> this.chooseOpmlExportPathResult(result) } - - private val chooseHtmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - result: ActivityResult -> this.chooseHtmlExportPathResult(result) } - - private val chooseFavoritesExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - result: ActivityResult -> this.chooseFavoritesExportPathResult(result) } - - private val chooseProgressExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - result: ActivityResult -> this.chooseProgressExportPathResult(result) } - - private val restoreProgressLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - result: ActivityResult -> this.restoreProgressResult(result) } - - private val restoreDatabaseLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - result: ActivityResult -> this.restoreDatabaseResult(result) } - - private val backupDatabaseLauncher = registerForActivityResult(BackupDatabase()) { uri: Uri? -> this.backupDatabaseResult(uri) } - - private val chooseOpmlImportPathLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { - uri: Uri? -> this.chooseOpmlImportPathResult(uri) } - - private val restorePreferencesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - result: ActivityResult -> this.restorePreferencesResult(result) } - - private val backupPreferencesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - val data: Uri? = it.data?.data - if (data != null) PreferencesTransporter.exportToDocument(data, requireContext()) - } - } - - private val restoreMediaFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - result: ActivityResult -> this.restoreMediaFilesResult(result) } - - private val backupMediaFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - result: ActivityResult -> this.exportMediaFilesResult(result) } - - private var showProgress by mutableStateOf(false) - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - (activity as PreferenceActivity).supportActionBar?.setTitle(R.string.import_export_pref) - return ComposeView(requireContext()).apply { - setContent { - CustomTheme(requireContext()) { - val textColor = MaterialTheme.colorScheme.onSurface - if (showProgress) { - Dialog(onDismissRequest = { showProgress = false }) { - Surface(modifier = Modifier.fillMaxSize(), shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)) { - Box(contentAlignment = Alignment.Center) { - CircularProgressIndicator() - Text("Loading...", modifier = Modifier.align(Alignment.BottomCenter)) - } - } - } - } - val scrollState = rememberScrollState() - Column(modifier = Modifier.fillMaxWidth().padding(16.dp).verticalScroll(scrollState)) { - Text(stringResource(R.string.database), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - exportDatabase() - })) { - Text(stringResource(R.string.database_export_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.database_export_summary), color = textColor) - } - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - importDatabase() - })) { - Text(stringResource(R.string.database_import_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.database_import_summary), color = textColor) - } - HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp)) - - Text(stringResource(R.string.media_files), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp)) - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - exportMediaFiles() - })) { - Text(stringResource(R.string.media_files_export_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.media_files_export_summary), color = textColor) - } - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - importMediaFiles() - })) { - Text(stringResource(R.string.media_files_import_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.media_files_import_summary), color = textColor) - } - HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp)) - - Text(stringResource(R.string.preferences), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp)) - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - exportPreferences() - })) { - Text(stringResource(R.string.preferences_export_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.preferences_export_summary), color = textColor) - } - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - importPreferences() - })) { - Text(stringResource(R.string.preferences_import_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.preferences_import_summary), color = textColor) - } - HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp)) - - Text(stringResource(R.string.opml), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp)) - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - openExportPathPicker(Export.OPML, chooseOpmlExportPathLauncher, OpmlWriter()) - })) { - Text(stringResource(R.string.opml_export_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.opml_export_summary), color = textColor) - } - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - try { - chooseOpmlImportPathLauncher.launch("*/*") - } catch (e: ActivityNotFoundException) { - Log.e(TAG, "No activity found. Should never happen...") - } - })) { - Text(stringResource(R.string.opml_import_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.opml_import_summary), color = textColor) - } - HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp)) - - Text(stringResource(R.string.progress), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp)) - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - openExportPathPicker(Export.PROGRESS, chooseProgressExportPathLauncher, EpisodesProgressWriter()) - })) { - Text(stringResource(R.string.progress_export_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.progress_export_summary), color = textColor) - } - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - importEpisodeProgress() - })) { - Text(stringResource(R.string.progress_import_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.progress_import_summary), color = textColor) - } - HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp)) - - Text(stringResource(R.string.html), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp)) - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - openExportPathPicker(Export.HTML, chooseHtmlExportPathLauncher, HtmlWriter()) - })) { - Text(stringResource(R.string.html_export_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.html_export_summary), color = textColor) - } - Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { - openExportPathPicker(Export.FAVORITES, chooseFavoritesExportPathLauncher, FavoritesWriter()) - })) { - Text(stringResource(R.string.favorites_export_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.favorites_export_summary), color = textColor) - } - } - } - } - } - } - - private fun dateStampFilename(fname: String): String { - return String.format(fname, SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())) - } - - private fun exportWithWriter(exportWriter: ExportWriter, uri: Uri?, exportType: Export) { - val context: Context? = activity - showProgress = true - if (uri == null) { - lifecycleScope.launch(Dispatchers.IO) { - try { - val output = ExportWorker(exportWriter, requireContext()).exportFile() - withContext(Dispatchers.Main) { - val fileUri = FileProvider.getUriForFile(context!!.applicationContext, context.getString(R.string.provider_authority), output!!) - showExportSuccessSnackbar(fileUri, exportType.contentType) - } - } catch (e: Exception) { showTransportErrorDialog(e) - } finally { showProgress = false } - } - } else { - lifecycleScope.launch(Dispatchers.IO) { - val worker = DocumentFileExportWorker(exportWriter, context!!, uri) - try { - val output = worker.exportFile() - withContext(Dispatchers.Main) { - showExportSuccessSnackbar(output.uri, exportType.contentType) - } - } catch (e: Exception) { showTransportErrorDialog(e) - } finally { showProgress = false } - } - } - } - - private fun exportPreferences() { - 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) - backupPreferencesLauncher.launch(intent) - } - - private fun importPreferences() { - val builder = MaterialAlertDialogBuilder(requireActivity()) - builder.setTitle(R.string.preferences_import_label) - builder.setMessage(R.string.preferences_import_warning) - - // add a button - builder.setNegativeButton(R.string.no, null) - builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - intent.addCategory(Intent.CATEGORY_DEFAULT) - restorePreferencesLauncher.launch(intent) - } - - // create and show the alert dialog - builder.show() - } - - private fun exportMediaFiles() { - 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) - backupMediaFilesLauncher.launch(intent) - } - - private fun importMediaFiles() { - val builder = MaterialAlertDialogBuilder(requireActivity()) - builder.setTitle(R.string.media_files_import_label) - builder.setMessage(R.string.media_files_import_notice) - - // add a button - builder.setNegativeButton(R.string.no, null) - builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - intent.addCategory(Intent.CATEGORY_DEFAULT) - restoreMediaFilesLauncher.launch(intent) - } - - // create and show the alert dialog - builder.show() - } - - private fun exportDatabase() { - backupDatabaseLauncher.launch(dateStampFilename("PodciniBackup-%s.realm")) - } - - private fun importDatabase() { - // setup the alert builder - val builder = MaterialAlertDialogBuilder(requireActivity()) - builder.setTitle(R.string.realm_database_import_label) - builder.setMessage(R.string.database_import_warning) - - // add a button - builder.setNegativeButton(R.string.no, null) - builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - intent.setType("*/*") - intent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/octet-stream")) - intent.addCategory(Intent.CATEGORY_OPENABLE) - restoreDatabaseLauncher.launch(intent) - } - - // create and show the alert dialog - builder.show() - } - - private fun showImportSuccessDialog() { - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setTitle(R.string.successful_import_label) - builder.setMessage(R.string.import_ok) - builder.setCancelable(false) - builder.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> forceRestart() } - builder.show() - } - - private fun showExportSuccessSnackbar(uri: Uri?, mimeType: String?) { - Snackbar.make(requireView(), R.string.export_success_title, Snackbar.LENGTH_LONG) - .setAction(R.string.share_label) { IntentBuilder(requireContext()).setType(mimeType).addStream(uri!!).setChooserTitle(R.string.share_label).startChooser() } - .show() - } - - private fun showTransportErrorDialog(error: Throwable) { - showProgress = false - val alert = MaterialAlertDialogBuilder(requireContext()) - alert.setPositiveButton(android.R.string.ok) { dialog: DialogInterface, _: Int -> dialog.dismiss() } - alert.setTitle(R.string.import_export_error_label) - alert.setMessage(error.message) - alert.show() - } - - private fun importEpisodeProgress() { - // setup the alert builder - val builder = MaterialAlertDialogBuilder(requireActivity()) - builder.setTitle(R.string.progress_import_label) - builder.setMessage(R.string.progress_import_warning) - - // add a button - builder.setNegativeButton(R.string.no, null) - builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - intent.setType("*/*") - intent.addCategory(Intent.CATEGORY_OPENABLE) - restoreProgressLauncher.launch(intent) - } - // create and show the alert dialog - builder.show() - } - - private fun chooseProgressExportPathResult(result: ActivityResult) { - if (result.resultCode != RESULT_OK || result.data == null) return - val uri = result.data!!.data!! -// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0 -// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - exportWithWriter(EpisodesProgressWriter(), uri, Export.PROGRESS) - } - - private fun chooseOpmlExportPathResult(result: ActivityResult) { - if (result.resultCode != RESULT_OK || result.data == null) return - val uri = result.data!!.data!! -// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0 -// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - exportWithWriter(OpmlWriter(), uri, Export.OPML) - } - - private fun chooseHtmlExportPathResult(result: ActivityResult) { - if (result.resultCode != RESULT_OK || result.data == null) return - val uri = result.data!!.data!! -// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0 -// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - exportWithWriter(HtmlWriter(), uri, Export.HTML) - } - - private fun chooseFavoritesExportPathResult(result: ActivityResult) { - if (result.resultCode != RESULT_OK || result.data == null) return - val uri = result.data!!.data!! -// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0 -// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - exportWithWriter(FavoritesWriter(), uri, Export.FAVORITES) - } - - private fun restoreProgressResult(result: ActivityResult) { - if (result.resultCode != RESULT_OK || result.data?.data == null) return - val uri = result.data!!.data - uri?.let { -// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0 -// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - if (isJsonFile(uri)) { - showProgress = true - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - val inputStream: InputStream? = requireContext().contentResolver.openInputStream(uri) - val reader = BufferedReader(InputStreamReader(inputStream)) - EpisodeProgressReader.readDocument(reader) - reader.close() - } - withContext(Dispatchers.Main) { - showImportSuccessDialog() - showProgress = false - } - } catch (e: Throwable) { showTransportErrorDialog(e) } - } - } else { - val context = requireContext() - val message = context.getString(R.string.import_file_type_toast) + ".json" - showTransportErrorDialog(Throwable(message)) - } - } - } - - private fun isJsonFile(uri: Uri): Boolean { - val fileName = uri.lastPathSegment ?: return false - return fileName.endsWith(".json", ignoreCase = true) - } - - private fun restoreDatabaseResult(result: ActivityResult) { - if (result.resultCode != RESULT_OK || result.data == null) return - val uri = result.data!!.data - uri?.let { -// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0 -// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - if (isRealmFile(uri)) { - showProgress = true - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - DatabaseTransporter.importBackup(uri, requireContext()) - } - withContext(Dispatchers.Main) { - showImportSuccessDialog() - showProgress = false - } - } catch (e: Throwable) { showTransportErrorDialog(e) } - } - } else { - val context = requireContext() - val message = context.getString(R.string.import_file_type_toast) + ".realm" - showTransportErrorDialog(Throwable(message)) - } - } - } - - private fun isRealmFile(uri: Uri): Boolean { - val fileName = uri.lastPathSegment ?: return false - return fileName.endsWith(".realm", ignoreCase = true) - } - - private fun isPrefDir(uri: Uri): Boolean { - val fileName = uri.lastPathSegment ?: return false - return fileName.contains("Podcini-Prefs", ignoreCase = true) - } - - private fun isMediaFilesDir(uri: Uri): Boolean { - val fileName = uri.lastPathSegment ?: return false - return fileName.contains("Podcini-MediaFiles", ignoreCase = true) - } - - private fun restorePreferencesResult(result: ActivityResult) { - if (result.resultCode != RESULT_OK || result.data?.data == null) return - val uri = result.data!!.data!! -// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0 -// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - if (isPrefDir(uri)) { - showProgress = true - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - PreferencesTransporter.importBackup(uri, requireContext()) - } - withContext(Dispatchers.Main) { - showImportSuccessDialog() - showProgress = false - } - } catch (e: Throwable) { showTransportErrorDialog(e) } - } - } else { - val context = requireContext() - val message = context.getString(R.string.import_directory_toast) + "Podcini-Prefs" - showTransportErrorDialog(Throwable(message)) - } - } - - private fun restoreMediaFilesResult(result: ActivityResult) { - if (result.resultCode != RESULT_OK || result.data?.data == null) return - val uri = result.data!!.data!! -// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0 -// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - if (isMediaFilesDir(uri)) { - showProgress = true - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - MediaFilesTransporter.importBackup(uri, requireContext()) - } - withContext(Dispatchers.Main) { - showImportSuccessDialog() - showProgress = false - } - } catch (e: Throwable) { showTransportErrorDialog(e) } - } - } else { - val context = requireContext() - val message = context.getString(R.string.import_directory_toast) + "Podcini-MediaFiles" - showTransportErrorDialog(Throwable(message)) - } - } - - private fun exportMediaFilesResult(result: ActivityResult) { - if (result.resultCode != RESULT_OK || result.data?.data == null) return - val uri = result.data!!.data!! -// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0 -// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - showProgress = true - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - MediaFilesTransporter.exportToDocument(uri, requireContext()) - } - withContext(Dispatchers.Main) { - showExportSuccessSnackbar(uri, null) - showProgress = false - } - } catch (e: Throwable) { showTransportErrorDialog(e) } - } - } - - private fun backupDatabaseResult(uri: Uri?) { - if (uri == null) return - showProgress = true - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - DatabaseTransporter.exportToDocument(uri, requireContext()) - } - withContext(Dispatchers.Main) { - showExportSuccessSnackbar(uri, "application/x-sqlite3") - showProgress = false - } - } catch (e: Throwable) { showTransportErrorDialog(e) } - } - } - - private fun chooseOpmlImportPathResult(uri: Uri?) { - if (uri == null) return - Logd(TAG, "chooseOpmlImportPathResult: uri: $uri") -// OpmlTransporter.startImport(requireContext(), uri) -// showImportSuccessDialog() - val intent = Intent(context, OpmlImportActivity::class.java) - intent.setData(uri) - startActivity(intent) - } - - private fun openExportPathPicker(exportType: Export, result: ActivityResultLauncher, writer: ExportWriter) { - val title = dateStampFilename(exportType.outputNameTemplate) - - val intentPickAction = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType(exportType.contentType) - .putExtra(Intent.EXTRA_TITLE, title) - - // Creates an implicit intent to launch a file manager which lets - // the user choose a specific directory to export to. - try { - result.launch(intentPickAction) - return - } catch (e: ActivityNotFoundException) { Log.e(TAG, "No activity found. Should never happen...") } - - // If we are using a SDK lower than API 21 or the implicit intent failed - // fallback to the legacy export process - exportWithWriter(writer, null, exportType) - } - - private class BackupDatabase : CreateDocument() { - override fun createIntent(context: Context, input: String): Intent { - return super.createIntent(context, input) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("application/x-sqlite3") - } - } - - enum class Export(val contentType: String, val outputNameTemplate: String, @field:StringRes val labelResId: Int) { - OPML(CONTENT_TYPE_OPML, "podcini-feeds-%s.opml", R.string.opml_export_label), - OPML_SELECTED(CONTENT_TYPE_OPML, "podcini-feeds-selected-%s.opml", R.string.opml_export_label), - HTML(CONTENT_TYPE_HTML, "podcini-feeds-%s.html", R.string.html_export_label), - FAVORITES(CONTENT_TYPE_HTML, "podcini-favorites-%s.html", R.string.favorites_export_label), - PROGRESS(CONTENT_TYPE_PROGRESS, "podcini-progress-%s.json", R.string.progress_export_label), - } - - class DocumentFileExportWorker(private val exportWriter: ExportWriter, private val context: Context, private val outputFileUri: Uri) { - suspend fun exportFile(feeds: List? = null): DocumentFile { - return withContext(Dispatchers.IO) { - val output = DocumentFile.fromSingleUri(context, outputFileUri) - var outputStream: OutputStream? = null - var writer: OutputStreamWriter? = null - try { - if (output == null) throw IOException() - val uri = output.uri - outputStream = context.contentResolver.openOutputStream(uri, "wt") - if (outputStream == null) throw IOException() - writer = OutputStreamWriter(outputStream, Charset.forName("UTF-8")) - val feeds_ = feeds ?: getFeedList() - Logd(TAG, "feeds_: ${feeds_.size}") - exportWriter.writeDocument(feeds_, writer, context) - output - } catch (e: IOException) { throw e - } finally { - if (writer != null) try { writer.close() } catch (e: IOException) { throw e } - if (outputStream != null) try { outputStream.close() } catch (e: IOException) { throw e } - } - } - } - } - - /** - * Writes an OPML file into the export directory in the background. - */ - class ExportWorker private constructor(private val exportWriter: ExportWriter, private val output: File, private val context: Context) { - constructor(exportWriter: ExportWriter, context: Context) : this(exportWriter, File(getDataFolder(EXPORT_DIR), - DEFAULT_OUTPUT_NAME + "." + exportWriter.fileExtension()), context) - suspend fun exportFile(feeds: List? = null): File? { - return withContext(Dispatchers.IO) { - if (output.exists()) { - val success = output.delete() - Logd(TAG, "Overwriting previously exported file: $success") - } - var writer: OutputStreamWriter? = null - try { - writer = OutputStreamWriter(FileOutputStream(output), Charset.forName("UTF-8")) - val feeds_ = feeds ?: getFeedList() - Logd(TAG, "feeds_: ${feeds_.size}") - exportWriter.writeDocument(feeds_, writer, context) - output // return the output file - } catch (e: IOException) { - Log.e(TAG, "Error during file export", e) - null // return null in case of error - } finally { writer?.close() } - } - } - companion object { - private const val EXPORT_DIR = "export/" - private val TAG: String = ExportWorker::class.simpleName ?: "Anonymous" - private const val DEFAULT_OUTPUT_NAME = "podcini-feeds" - } - } - - object PreferencesTransporter { - private val TAG: String = PreferencesTransporter::class.simpleName ?: "Anonymous" - @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("Podcini-Prefs") ?: throw IOException("Error creating subdirectory Podcini-Prefs") - 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 -> -// val prefName = file.name.substring(0, file.name.lastIndexOf('.')) - 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!! -// contains info on existing widgets, no need to import - 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 { -// for debug version importing release version - BuildConfig.DEBUG && !destName.contains(".debug") -> destName = destName.replace("podcini.R", "podcini.R.debug") -// for release version importing debug version - !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 { } - - } - } - - object MediaFilesTransporter { - private val TAG: String = MediaFilesTransporter::class.simpleName ?: "Anonymous" - 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("Podcini-MediaFiles") ?: throw IOException("Error creating subdirectory Podcini-Prefs") - mediaDir.listFiles()?.forEach { file -> - copyRecursive(context, file, mediaDir, exportSubDir) - } - } catch (e: IOException) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { } - } - private fun copyRecursive(context: Context, srcFile: File, srcRootDir: File, destRootDir: DocumentFile) { - val relativePath = srcFile.absolutePath.substring(srcRootDir.absolutePath.length+1) - if (srcFile.isDirectory) { - val dirFiles = srcFile.listFiles() - if (!dirFiles.isNullOrEmpty()) { - val destDir = destRootDir.findFile(relativePath) ?: destRootDir.createDirectory(relativePath) ?: return - dirFiles.forEach { file -> - copyRecursive(context, file, srcFile, destDir) - } - } - } else { - val destFile = destRootDir.createFile("application/octet-stream", relativePath) ?: return - copyFile(srcFile, destFile, context) - } - } - private fun copyFile(sourceFile: File, destFile: DocumentFile, context: Context) { - try { - val outputStream = context.contentResolver.openOutputStream(destFile.uri) ?: return - val inputStream = FileInputStream(sourceFile) - copyStream(inputStream, outputStream) - inputStream.close() - outputStream.close() - } catch (e: IOException) { - Log.e("Error", "Error copying file: $e") - throw e - } - } - private fun copyRecursive(context: Context, srcFile: DocumentFile, srcRootDir: DocumentFile, destRootDir: File) { - val relativePath = srcFile.uri.path?.substring(srcRootDir.uri.path!!.length+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("Podcini-MediaFiles") != 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 - } - } - } - - object DatabaseTransporter { - private val TAG: String = DatabaseTransporter::class.simpleName ?: "Anonymous" - @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) } - } - } - - /** Reads OPML documents. */ - object EpisodeProgressReader { - private const 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!!.setLastPlayedTime(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) - } - } - - /** Writes saved favorites to file. */ - 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.favorite.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.getLastPlayedTime())) - .started(media.startPosition / 1000) - .position(media.getPosition() / 1000) - .playedDuration(media.playedDuration / 1000) - .total(media.getDuration() / 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" - } - companion object { - private const val TAG = "EpisodesProgressWriter" - } - } - - /** Writes saved favorites to file. */ - class FavoritesWriter : ExportWriter { - @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.favorite.name), EpisodeSortOrder.DATE_NEW_OLD) - val favoritesByFeed = buildFeedMap(allFavorites) - writer!!.append(templateParts[0]) - for (feedId in favoritesByFeed.keys) { - val favorites: List = favoritesByFeed[feedId]!! - 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!!) - .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" - } - companion object { - private val TAG: String = FavoritesWriter::class.simpleName ?: "Anonymous" - private const val FAVORITE_TEMPLATE = "html-export-favorites-item-template.html" - private const val FEED_TEMPLATE = "html-export-feed-template.html" - private const val UTF_8 = "UTF-8" - } - } - - /** Writes HTML documents. */ - 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" - } - companion object { - private val TAG: String = HtmlWriter::class.simpleName ?: "Anonymous" - } - } - - companion object { - private val TAG: String = ImportExportPreferencesFragment::class.simpleName ?: "Anonymous" - - private const val CONTENT_TYPE_OPML = "text/x-opml" - private const val CONTENT_TYPE_HTML = "text/html" - private const val CONTENT_TYPE_PROGRESS = "text/x-json" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/MainPreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/MainPreferencesFragment.kt deleted file mode 100644 index fd5a624f..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/MainPreferencesFragment.kt +++ /dev/null @@ -1,328 +0,0 @@ -package ac.mdiq.podcini.preferences.fragments - -import ac.mdiq.podcini.BuildConfig -import ac.mdiq.podcini.R -import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.preferences.UserPreferences.appPrefs -import ac.mdiq.podcini.ui.activity.BugReportActivity -import ac.mdiq.podcini.ui.activity.PreferenceActivity -import ac.mdiq.podcini.ui.activity.PreferenceActivity.Screens -import ac.mdiq.podcini.ui.compose.CustomTheme -import ac.mdiq.podcini.util.IntentUtils.openInBrowser -import android.annotation.SuppressLint -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.io.BufferedReader -import java.io.IOException -import java.io.InputStreamReader -import javax.xml.parsers.DocumentBuilderFactory - -class MainPreferencesFragment : PreferenceFragmentCompat() { - - var copyrightNoticeText by mutableStateOf("") - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.settings_label) - val packageHash = requireContext().packageName.hashCode() - when { - packageHash != 1329568231 && packageHash != 1297601420 -> { - copyrightNoticeText = ("This application is based on Podcini." - + " The Podcini team does NOT provide support for this unofficial version." - + " If you can read this message, the developers of this modification" - + " violate the GNU General Public License (GPL).") - } - packageHash == 1297601420 -> copyrightNoticeText = "This is a development version of Podcini and not meant for daily use" - } - - return ComposeView(requireContext()).apply { - setContent { - CustomTheme(requireContext()) { - val textColor = MaterialTheme.colorScheme.onSurface - val scrollState = rememberScrollState() - Column(modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp).verticalScroll(scrollState)) { - if (copyrightNoticeText.isNotBlank()) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Icon(imageVector = Icons.Filled.Info, contentDescription = "", tint = Color.Red, modifier = Modifier.size(40.dp).padding(end = 15.dp)) - Text(copyrightNoticeText, color = textColor) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_appearance), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) - Column(modifier = Modifier.weight(1f).clickable(onClick = { - (activity as PreferenceActivity).openScreen(R.xml.preferences_user_interface) - })) { - Text(stringResource(R.string.user_interface_label), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.user_interface_sum), color = textColor) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_play_24dp), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) - Column(modifier = Modifier.weight(1f).clickable(onClick = { - (activity as PreferenceActivity).openScreen(R.xml.preferences_playback) - })) { - Text(stringResource(R.string.playback_pref), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.playback_pref_sum), color = textColor) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_download), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) - Column(modifier = Modifier.weight(1f).clickable(onClick = { - (activity as PreferenceActivity).openScreen(Screens.preferences_downloads) - })) { - Text(stringResource(R.string.downloads_pref), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.downloads_pref_sum), color = textColor) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_cloud), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) - Column(modifier = Modifier.weight(1f).clickable(onClick = { - (activity as PreferenceActivity).openScreen(R.xml.preferences_synchronization) - })) { - Text(stringResource(R.string.synchronization_pref), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.synchronization_sum), color = textColor) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_storage), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) - Column(modifier = Modifier.weight(1f).clickable(onClick = { - (activity as PreferenceActivity).openScreen(Screens.preferences_import_export) - })) { - Text(stringResource(R.string.import_export_pref), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.import_export_summary), color = textColor) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_notifications), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) - Column(modifier = Modifier.weight(1f).clickable(onClick = { - (activity as PreferenceActivity).openScreen(R.xml.preferences_notifications) - })) { - Text(stringResource(R.string.notification_pref_fragment), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.pref_backup_on_google_title), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.pref_backup_on_google_sum), color = textColor) - } - Switch(checked = true, onCheckedChange = { - appPrefs.edit().putBoolean(UserPreferences.Prefs.prefOPMLBackup.name, it).apply() - // Restart the app - val intent = context?.packageManager?.getLaunchIntentForPackage(requireContext().packageName) - intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) - context?.startActivity(intent) - }) - } - HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp).padding(top = 10.dp)) - Text(stringResource(R.string.project_pref), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 15.dp)) - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_questionmark), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) - Column(modifier = Modifier.weight(1f).clickable(onClick = { - openInBrowser(requireContext(), "https://github.com/XilinJia/Podcini") - })) { - Text(stringResource(R.string.documentation_support), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_chat), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) - Column(modifier = Modifier.weight(1f).clickable(onClick = { - openInBrowser(requireContext(), "https://github.com/XilinJia/Podcini/discussions") - })) { - Text(stringResource(R.string.visit_user_forum), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_contribute), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) - Column(modifier = Modifier.weight(1f).clickable(onClick = { - openInBrowser(requireContext(), "https://github.com/XilinJia/Podcini") - })) { - Text(stringResource(R.string.pref_contribute), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_bug), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) - Column(modifier = Modifier.weight(1f).clickable(onClick = { - startActivity(Intent(activity, BugReportActivity::class.java)) - })) { - Text(stringResource(R.string.bug_report_title), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_info), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) - Column(modifier = Modifier.weight(1f).clickable(onClick = { - parentFragmentManager.beginTransaction().replace(R.id.settingsContainer, AboutFragment()).addToBackStack(getString(R.string.about_pref)).commit() - })) { - Text(stringResource(R.string.about_pref), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - } - } - } - } - } - } - } - - class AboutFragment : PreferenceFragmentCompat() { - @SuppressLint("CommitTransaction") - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - (activity as PreferenceActivity).supportActionBar?.setTitle(R.string.about_pref) - return ComposeView(requireContext()).apply { - setContent { - CustomTheme(requireContext()) { - val textColor = MaterialTheme.colorScheme.onSurface - Column(modifier = Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp)) { - Image(painter = painterResource(R.drawable.teaser), contentDescription = "") - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 10.dp, top = 5.dp, bottom = 5.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_star), contentDescription = "", tint = textColor) - Column(Modifier.padding(start = 10.dp).clickable(onClick = { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(getString(R.string.bug_report_title), findPreference("about_version")!!.summary) - clipboard.setPrimaryClip(clip) - if (Build.VERSION.SDK_INT <= 32) Snackbar.make(requireView(), R.string.copied_to_clipboard, Snackbar.LENGTH_SHORT).show() - })) { - Text(stringResource(R.string.podcini_version), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(String.format("%s (%s)", BuildConfig.VERSION_NAME, BuildConfig.COMMIT_HASH), color = textColor) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 10.dp, top = 5.dp, bottom = 5.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_questionmark), contentDescription = "", tint = textColor) - Column(Modifier.padding(start = 10.dp).clickable(onClick = { - openInBrowser(requireContext(), "https://github.com/XilinJia/Podcini/") - })) { - Text(stringResource(R.string.online_help), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.online_help_sum), color = textColor) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 10.dp, top = 5.dp, bottom = 5.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_info), contentDescription = "", tint = textColor) - Column(Modifier.padding(start = 10.dp).clickable(onClick = { - openInBrowser(requireContext(), "https://github.com/XilinJia/Podcini/blob/main/PrivacyPolicy.md") - })) { - Text(stringResource(R.string.privacy_policy), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text("Podcini PrivacyPolicy", color = textColor) - } - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 10.dp, top = 5.dp, bottom = 5.dp)) { - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_info), contentDescription = "", tint = textColor) - Column(Modifier.padding(start = 10.dp).clickable(onClick = { - parentFragmentManager.beginTransaction().replace(R.id.settingsContainer, LicensesFragment()).addToBackStack(getString(R.string.translators)).commit() - })) { - Text(stringResource(R.string.licenses), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.licenses_summary), color = textColor) - } - } - } - } - } - } - } - - class LicensesFragment : Fragment() { - private val licenses = mutableStateListOf() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val composeView = ComposeView(requireContext()).apply { setContent { CustomTheme(requireContext()) { MainView() } } } - lifecycleScope.launch(Dispatchers.IO) { - licenses.clear() - val stream = requireContext().assets.open("licenses.xml") - val docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder() - val libraryList = docBuilder.parse(stream).getElementsByTagName("library") - for (i in 0 until libraryList.length) { - val lib = libraryList.item(i).attributes - licenses.add(LicenseItem(lib.getNamedItem("name").textContent, - String.format("By %s, %s license", lib.getNamedItem("author").textContent, lib.getNamedItem("license").textContent), lib.getNamedItem("website").textContent, lib.getNamedItem("licenseText").textContent)) - } - }.invokeOnCompletion { throwable -> if (throwable!= null) Toast.makeText(context, throwable.message, Toast.LENGTH_LONG).show() } - return composeView - } - - @Composable - fun MainView() { - val lazyListState = rememberLazyListState() - val textColor = MaterialTheme.colorScheme.onSurface - var showDialog by remember { mutableStateOf(false) } - var curLicenseIndex by remember { mutableIntStateOf(-1) } - if (showDialog) Dialog(onDismissRequest = { showDialog = false }) { - Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text(licenses[curLicenseIndex].title, color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Row { - Button(onClick = { openInBrowser(requireContext(), licenses[curLicenseIndex].licenseUrl) }) { Text("View website") } - Spacer(Modifier.weight(1f)) - Button(onClick = { showLicenseText(licenses[curLicenseIndex].licenseTextFile) }) { Text("View license") } - } - } - } - } - LazyColumn(state = lazyListState, modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 20.dp), - verticalArrangement = Arrangement.spacedBy(8.dp)) { - itemsIndexed(licenses) { index, item -> - Column(Modifier.clickable(onClick = { - curLicenseIndex = index - showDialog = true - })) { - Text(item.title, color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(item.subtitle, color = textColor, style = MaterialTheme.typography.bodySmall) - } - } - } - } - - private fun showLicenseText(licenseTextFile: String) { - try { - val reader = BufferedReader(InputStreamReader(requireContext().assets.open(licenseTextFile), "UTF-8")) - val licenseText = StringBuilder() - var line = "" - while ((reader.readLine()?.also { line = it }) != null) licenseText.append(line).append("\n") - MaterialAlertDialogBuilder(requireContext()).setMessage(licenseText).show() - } catch (e: IOException) { e.printStackTrace() } - } - - override fun onStart() { - super.onStart() - (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.licenses) - } - - private class LicenseItem(val title: String, val subtitle: String, val licenseUrl: String, val licenseTextFile: String) - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/NotificationPreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/NotificationPreferencesFragment.kt deleted file mode 100644 index 8649be93..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/NotificationPreferencesFragment.kt +++ /dev/null @@ -1,28 +0,0 @@ -package ac.mdiq.podcini.preferences.fragments - -import android.os.Bundle -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import ac.mdiq.podcini.R -import ac.mdiq.podcini.ui.activity.PreferenceActivity -import ac.mdiq.podcini.net.sync.SynchronizationSettings - -class NotificationPreferencesFragment : PreferenceFragmentCompat() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.preferences_notifications) - setUpScreen() - } - - override fun onStart() { - super.onStart() - (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.notification_pref_fragment) - } - - private fun setUpScreen() { - findPreference(PREF_GPODNET_NOTIFICATIONS)!!.isEnabled = SynchronizationSettings.isProviderConnected - } - - companion object { - private const val PREF_GPODNET_NOTIFICATIONS = "pref_gpodnet_notifications" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/PlaybackPreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/PlaybackPreferencesFragment.kt deleted file mode 100644 index 7ba7d47c..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/PlaybackPreferencesFragment.kt +++ /dev/null @@ -1,184 +0,0 @@ -package ac.mdiq.podcini.preferences.fragments - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.prefPlaybackSpeed -import ac.mdiq.podcini.preferences.UsageStatistics -import ac.mdiq.podcini.preferences.UsageStatistics.doNotAskAgain -import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.preferences.UserPreferences.fallbackSpeed -import ac.mdiq.podcini.preferences.UserPreferences.setVideoMode -import ac.mdiq.podcini.preferences.UserPreferences.speedforwardSpeed -import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode -import ac.mdiq.podcini.ui.activity.PreferenceActivity -import ac.mdiq.podcini.ui.compose.CustomTheme -import ac.mdiq.podcini.ui.compose.PlaybackSpeedDialog -import ac.mdiq.podcini.ui.dialog.SkipPreferenceDialog -import ac.mdiq.podcini.util.EventFlow -import ac.mdiq.podcini.util.FlowEvent -import android.content.Context -import android.content.DialogInterface -import android.os.Build -import android.os.Bundle -import android.view.ViewGroup -import androidx.collection.ArrayMap -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.ComposeView -import androidx.preference.ListPreference -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlin.math.round - -class PlaybackPreferencesFragment : PreferenceFragmentCompat() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.preferences_playback) - setupPlaybackScreen() -// buildSmartMarkAsPlayedPreference() - } - - override fun onStart() { - super.onStart() - (activity as PreferenceActivity).supportActionBar?.setTitle(R.string.playback_pref) - } - - private fun setupPlaybackScreen() { - findPreference(Prefs.prefPlaybackSpeedLauncher.name)?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - val composeView = ComposeView(requireContext()).apply { - setContent { - val showDialog = remember { mutableStateOf(true) } - CustomTheme(requireContext()) { - PlaybackSpeedDialog(listOf(), initSpeed = prefPlaybackSpeed, maxSpeed = 3f, isGlobal = true, onDismiss = { - showDialog.value = false - (view as? ViewGroup)?.removeView(this@apply) - }) { speed -> UserPreferences.setPlaybackSpeed(speed) } - } - } - } - (view as? ViewGroup)?.addView(composeView) - true - } - findPreference(Prefs.prefPlaybackRewindDeltaLauncher.name)?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND, null) - true - } - findPreference(Prefs.prefPlaybackVideoModeLauncher.name)?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - VideoModeDialog.showDialog(requireContext()) - true - } - - findPreference(Prefs.prefPlaybackSpeedForwardLauncher.name)?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - val composeView = ComposeView(requireContext()).apply { - setContent { - val showDialog = remember { mutableStateOf(true) } - CustomTheme(requireContext()) { - PlaybackSpeedDialog(listOf(), initSpeed = speedforwardSpeed, maxSpeed = 10f, isGlobal = true, onDismiss = { - showDialog.value = false - (view as? ViewGroup)?.removeView(this@apply) - }) { speed -> - val speed_ = when { - speed < 0.0f -> 0.0f - speed > 10.0f -> 10.0f - else -> 0f - } - speedforwardSpeed = round(10 * speed_) / 10 - } - } - } - } - (view as? ViewGroup)?.addView(composeView) - true - } - findPreference(Prefs.prefPlaybackFallbackSpeedLauncher.name)?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - val composeView = ComposeView(requireContext()).apply { - setContent { - val showDialog = remember { mutableStateOf(true) } - CustomTheme(requireContext()) { - PlaybackSpeedDialog(listOf(), initSpeed = fallbackSpeed, maxSpeed = 3f, isGlobal = true, onDismiss = { - showDialog.value = false - (view as? ViewGroup)?.removeView(this@apply) - }) { speed -> - val speed_ = when { - speed < 0.0f -> 0.0f - speed > 3.0f -> 3.0f - else -> 0f - } - fallbackSpeed = round(100 * speed_) / 100f - } - } - } - } - (view as? ViewGroup)?.addView(composeView) - true - } - findPreference(Prefs.prefPlaybackFastForwardDeltaLauncher.name)?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD, null) - true - } - findPreference(Prefs.prefStreamOverDownload.name)?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _: Preference?, _: Any? -> - // Update all visible lists to reflect new streaming action button -// TODO: need another event type? - EventFlow.postEvent(FlowEvent.EpisodePlayedEvent()) - // User consciously decided whether to prefer the streaming button, disable suggestion to change that - doNotAskAgain(UsageStatistics.ACTION_STREAM) - true - } - if (Build.VERSION.SDK_INT >= 31) { - findPreference(UserPreferences.Prefs.prefUnpauseOnHeadsetReconnect.name)?.isVisible = false - findPreference(UserPreferences.Prefs.prefUnpauseOnBluetoothReconnect.name)?.isVisible = false - } - buildEnqueueLocationPreference() - } - - private fun buildEnqueueLocationPreference() { - val res = requireActivity().resources - val options: MutableMap = ArrayMap() - run { - val keys = res.getStringArray(R.array.enqueue_location_values) - val values = res.getStringArray(R.array.enqueue_location_options) - for (i in keys.indices) { - options[keys[i]] = values[i] - } - } - - val pref = requirePreference(UserPreferences.Prefs.prefEnqueueLocation.name) - pref.summary = res.getString(R.string.pref_enqueue_location_sum, options[pref.value]) - pref.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any? -> - if (newValue !is String) return@OnPreferenceChangeListener false - pref.summary = res.getString(R.string.pref_enqueue_location_sum, options[newValue]) - true - } - } - - private fun requirePreference(key: CharSequence): T { - // Possibly put it to a common method in abstract base class - return findPreference(key) ?: throw IllegalArgumentException("Preference with key '$key' is not found") - } - - object VideoModeDialog { - fun showDialog(context: Context) { - val dialog = MaterialAlertDialogBuilder(context) - dialog.setTitle(context.getString(R.string.pref_playback_video_mode)) - dialog.setNegativeButton(android.R.string.cancel) { d: DialogInterface, _: Int -> d.dismiss() } - val selected = videoPlayMode - val entryValues = listOf(*context.resources.getStringArray(R.array.video_mode_options_values)) - val selectedIndex = entryValues.indexOf("" + selected) - val items = context.resources.getStringArray(R.array.video_mode_options) - dialog.setSingleChoiceItems(items, selectedIndex) { d: DialogInterface, which: Int -> - if (selectedIndex != which) setVideoMode(entryValues[which].toInt()) - d.dismiss() - } - dialog.show() - } - } - - private enum class Prefs { - prefPlaybackSpeedLauncher, - prefPlaybackRewindDeltaLauncher, - prefPlaybackFallbackSpeedLauncher, - prefPlaybackSpeedForwardLauncher, - prefPlaybackFastForwardDeltaLauncher, - prefStreamOverDownload, - prefPlaybackVideoModeLauncher, - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/SynchronizationPreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/SynchronizationPreferencesFragment.kt deleted file mode 100644 index eb87416e..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/SynchronizationPreferencesFragment.kt +++ /dev/null @@ -1,700 +0,0 @@ -package ac.mdiq.podcini.preferences.fragments - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.* -import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient -import ac.mdiq.podcini.net.sync.SyncService -import ac.mdiq.podcini.net.sync.SynchronizationCredentials -import ac.mdiq.podcini.net.sync.SynchronizationProviderViewData -import ac.mdiq.podcini.net.sync.SynchronizationSettings -import ac.mdiq.podcini.net.sync.SynchronizationSettings.isProviderConnected -import ac.mdiq.podcini.net.sync.SynchronizationSettings.setSelectedSyncProvider -import ac.mdiq.podcini.net.sync.SynchronizationSettings.setWifiSyncEnabled -import ac.mdiq.podcini.net.sync.SynchronizationSettings.wifiSyncEnabledKey -import ac.mdiq.podcini.net.sync.gpoddernet.GpodnetService -import ac.mdiq.podcini.net.sync.gpoddernet.model.GpodnetDevice -import ac.mdiq.podcini.net.sync.nextcloud.NextcloudLoginFlow -import ac.mdiq.podcini.net.sync.wifi.WifiSyncService.Companion.hostPort -import ac.mdiq.podcini.net.sync.wifi.WifiSyncService.Companion.startInstantSync -import ac.mdiq.podcini.storage.utils.FileNameGenerator.generateFileName -import ac.mdiq.podcini.ui.activity.PreferenceActivity -import ac.mdiq.podcini.util.EventFlow -import ac.mdiq.podcini.util.FlowEvent -import ac.mdiq.podcini.util.Logd -import android.app.Activity -import android.app.Dialog -import android.content.Context -import android.content.Context.WIFI_SERVICE -import android.content.DialogInterface -import android.net.wifi.WifiManager -import android.os.Build -import android.os.Bundle -import android.text.format.DateUtils -import android.text.method.HideReturnsTransformationMethod -import android.text.method.PasswordTransformationMethod -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.* -import androidx.appcompat.app.AlertDialog -import androidx.core.text.HtmlCompat -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.lifecycleScope -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.* -import java.util.regex.Pattern - -class SynchronizationPreferencesFragment : PreferenceFragmentCompat() { - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.preferences_synchronization) - setupScreen() - updateScreen() - } - - override fun onStart() { - super.onStart() - (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.synchronization_pref) - updateScreen() - procFlowEvents() - } - - override fun onStop() { - super.onStop() - cancelFlowEvents() - (activity as PreferenceActivity).supportActionBar!!.subtitle = "" - } - - private var eventSink: Job? = null - private fun cancelFlowEvents() { - eventSink?.cancel() - eventSink = null - } - private fun procFlowEvents() { - if (eventSink != null) return - eventSink = lifecycleScope.launch { - EventFlow.events.collectLatest { event -> - Logd("SynchronizationPreferencesFragment", "Received event: ${event.TAG}") - when (event) { - is FlowEvent.SyncServiceEvent -> syncStatusChanged(event) - else -> {} - } - } - } - } - - fun syncStatusChanged(event: FlowEvent.SyncServiceEvent) { - if (!isProviderConnected && !wifiSyncEnabledKey) return - - updateScreen() - if (event.messageResId == R.string.sync_status_error || event.messageResId == R.string.sync_status_success) - updateLastSyncReport(SynchronizationSettings.isLastSyncSuccessful, SynchronizationSettings.lastSyncAttempt) - else (activity as PreferenceActivity).supportActionBar!!.setSubtitle(event.messageResId) - } - - private fun setupScreen() { - val activity: Activity? = activity - findPreference(Prefs.pref_gpodnet_setlogin_information.name)?.setOnPreferenceClickListener { - val dialog: AuthenticationDialog = object : AuthenticationDialog(requireContext(), R.string.pref_gpodnet_setlogin_information_title, - false, SynchronizationCredentials.username, null) { - override fun onConfirmed(username: String, password: String) { - SynchronizationCredentials.password = password - } - } - dialog.show() - true - } - findPreference(Prefs.pref_synchronization_sync.name)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { - SyncService.syncImmediately(requireActivity().applicationContext) - true - } - findPreference(Prefs.pref_synchronization_force_full_sync.name)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { - SyncService.fullSync(requireContext()) - true - } - findPreference(Prefs.pref_synchronization_logout.name)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { - SynchronizationCredentials.clear(requireContext()) - Snackbar.make(requireView(), R.string.pref_synchronization_logout_toast, Snackbar.LENGTH_LONG).show() - setSelectedSyncProvider(null) - updateScreen() - true - } - } - - private fun updateScreen() { - val preferenceInstantSync = findPreference(Prefs.preference_instant_sync.name) - preferenceInstantSync!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { - WifiAuthenticationFragment().show(childFragmentManager, WifiAuthenticationFragment.TAG) - true - } - - val loggedIn = isProviderConnected - val preferenceHeader = findPreference(Prefs.preference_synchronization_description.name) - if (loggedIn) { - val selectedProvider = SynchronizationProviderViewData.fromIdentifier(selectedSyncProviderKey) - preferenceHeader!!.title = "" - if (selectedProvider != null) { - preferenceHeader.setSummary(selectedProvider.summaryResource) - preferenceHeader.setIcon(selectedProvider.iconResource) - } - preferenceHeader.onPreferenceClickListener = null - } else { - preferenceHeader!!.setTitle(R.string.synchronization_choose_title) - preferenceHeader.setSummary(R.string.synchronization_summary_unchoosen) - preferenceHeader.setIcon(R.drawable.ic_cloud) - preferenceHeader.onPreferenceClickListener = Preference.OnPreferenceClickListener { - chooseProviderAndLogin() - true - } - } - - val gpodnetSetLoginPreference = findPreference(Prefs.pref_gpodnet_setlogin_information.name) - gpodnetSetLoginPreference!!.isVisible = isProviderSelected(SynchronizationProviderViewData.GPODDER_NET) - gpodnetSetLoginPreference.isEnabled = loggedIn - findPreference(Prefs.pref_synchronization_sync.name)!!.isVisible = loggedIn - findPreference(Prefs.pref_synchronization_force_full_sync.name)!!.isVisible = loggedIn - findPreference(Prefs.pref_synchronization_logout.name)!!.isVisible = loggedIn - if (loggedIn) { - val summary = getString(R.string.synchronization_login_status, - SynchronizationCredentials.username, SynchronizationCredentials.hosturl) - val formattedSummary = HtmlCompat.fromHtml(summary, HtmlCompat.FROM_HTML_MODE_LEGACY) - findPreference(Prefs.pref_synchronization_logout.name)!!.summary = formattedSummary - updateLastSyncReport(SynchronizationSettings.isLastSyncSuccessful, SynchronizationSettings.lastSyncAttempt) - } else { - findPreference(Prefs.pref_synchronization_logout.name)?.summary = "" - (activity as PreferenceActivity).supportActionBar?.setSubtitle("") - } - } - - private fun chooseProviderAndLogin() { - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setTitle(R.string.dialog_choose_sync_service_title) - - val providers = SynchronizationProviderViewData.entries.toTypedArray() - val adapter: ListAdapter = object : ArrayAdapter(requireContext(), R.layout.alertdialog_sync_provider_chooser, providers) { - var holder: ViewHolder? = null - - inner class ViewHolder { - var icon: ImageView? = null - var title: TextView? = null - } - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - var convertView = convertView - val inflater = LayoutInflater.from(context) - if (convertView == null) { - convertView = inflater.inflate(R.layout.alertdialog_sync_provider_chooser, null) - val binding = AlertdialogSyncProviderChooserBinding.bind(convertView) - holder = ViewHolder() - if (holder != null) { - holder!!.icon = binding.icon - holder!!.title = binding.title - convertView.tag = holder - } - } else holder = convertView.tag as ViewHolder - - val synchronizationProviderViewData = getItem(position) - holder!!.title!!.setText(synchronizationProviderViewData!!.summaryResource) - holder!!.icon!!.setImageResource(synchronizationProviderViewData.iconResource) - return convertView!! - } - } - - builder.setAdapter(adapter) { _: DialogInterface?, which: Int -> - when (providers[which]) { - SynchronizationProviderViewData.GPODDER_NET -> GpodderAuthenticationFragment().show(childFragmentManager, - GpodderAuthenticationFragment.TAG) - SynchronizationProviderViewData.NEXTCLOUD_GPODDER -> NextcloudAuthenticationFragment().show(childFragmentManager, - NextcloudAuthenticationFragment.TAG) - } - updateScreen() - } - - builder.show() - } - - private fun isProviderSelected(provider: SynchronizationProviderViewData): Boolean { - val selectedSyncProviderKey = selectedSyncProviderKey - return provider.identifier == selectedSyncProviderKey - } - - private val selectedSyncProviderKey: String - get() = SynchronizationSettings.selectedSyncProviderKey?:"" - - private fun updateLastSyncReport(successful: Boolean, lastTime: Long) { - val status = String.format("%1\$s (%2\$s)", getString(if (successful) R.string.gpodnetsync_pref_report_successful else R.string.gpodnetsync_pref_report_failed), - DateUtils.getRelativeDateTimeString(context, lastTime, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, DateUtils.FORMAT_SHOW_TIME)) - (activity as PreferenceActivity).supportActionBar!!.subtitle = status - } - - /** - * Displays a dialog with a username and password text field and an optional checkbox to save username and preferences. - */ - abstract class AuthenticationDialog(context: Context, titleRes: Int, enableUsernameField: Boolean, usernameInitialValue: String?, passwordInitialValue: String?) - : MaterialAlertDialogBuilder(context) { - - var passwordHidden: Boolean = true - - init { - setTitle(titleRes) - val viewBinding = AuthenticationDialogBinding.inflate(LayoutInflater.from(context)) - setView(viewBinding.root) - - viewBinding.usernameEditText.isEnabled = enableUsernameField - if (usernameInitialValue != null) viewBinding.usernameEditText.setText(usernameInitialValue) - if (passwordInitialValue != null) viewBinding.passwordEditText.setText(passwordInitialValue) - - viewBinding.showPasswordButton.setOnClickListener { - if (passwordHidden) { - viewBinding.passwordEditText.transformationMethod = HideReturnsTransformationMethod.getInstance() - viewBinding.showPasswordButton.alpha = 1.0f - } else { - viewBinding.passwordEditText.transformationMethod = PasswordTransformationMethod.getInstance() - viewBinding.showPasswordButton.alpha = 0.6f - } - passwordHidden = !passwordHidden - } - - setOnCancelListener { onCancelled() } - setNegativeButton(R.string.cancel_label) { _: DialogInterface?, _: Int -> onCancelled() } - setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> - onConfirmed(viewBinding.usernameEditText.text.toString(), viewBinding.passwordEditText.text.toString()) - } - } - - protected open fun onCancelled() {} - - protected abstract fun onConfirmed(username: String, password: String) - } - - /** - * Guides the user through the authentication process. - */ - class NextcloudAuthenticationFragment : DialogFragment(), NextcloudLoginFlow.AuthenticationCallback { - private var binding: NextcloudAuthDialogBinding? = null - private var nextcloudLoginFlow: NextcloudLoginFlow? = null - private var shouldDismiss = false - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = MaterialAlertDialogBuilder(requireContext()) - dialog.setTitle(R.string.gpodnetauth_login_butLabel) - dialog.setNegativeButton(R.string.cancel_label, null) - dialog.setCancelable(false) - this.isCancelable = false - - binding = NextcloudAuthDialogBinding.inflate(layoutInflater) - dialog.setView(binding!!.root) - - binding!!.chooseHostButton.setOnClickListener { - nextcloudLoginFlow = NextcloudLoginFlow(getHttpClient(), binding!!.serverUrlText.text.toString(), requireContext(), this) - startLoginFlow() - } - if (savedInstanceState?.getStringArrayList(EXTRA_LOGIN_FLOW) != null) { - nextcloudLoginFlow = NextcloudLoginFlow.fromInstanceState(getHttpClient(), requireContext(), this, - savedInstanceState.getStringArrayList(EXTRA_LOGIN_FLOW)!!) - startLoginFlow() - } - return dialog.create() - } - private fun startLoginFlow() { - binding!!.errorText.visibility = View.GONE - binding!!.chooseHostButton.visibility = View.GONE - binding!!.loginProgressContainer.visibility = View.VISIBLE - binding!!.serverUrlText.isEnabled = false - nextcloudLoginFlow!!.start() - } - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - if (nextcloudLoginFlow != null) outState.putStringArrayList(EXTRA_LOGIN_FLOW, nextcloudLoginFlow!!.saveInstanceState()) - } - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - nextcloudLoginFlow?.cancel() - } - override fun onResume() { - super.onResume() - nextcloudLoginFlow?.onResume() - - if (shouldDismiss) dismiss() - } - override fun onNextcloudAuthenticated(server: String, username: String, password: String) { - setSelectedSyncProvider(SynchronizationProviderViewData.NEXTCLOUD_GPODDER) - SynchronizationCredentials.clear(requireContext()) - SynchronizationCredentials.password = password - SynchronizationCredentials.hosturl = server - SynchronizationCredentials.username = username - SyncService.fullSync(requireContext()) - if (isResumed) dismiss() - else shouldDismiss = true - } - override fun onNextcloudAuthError(errorMessage: String?) { - binding!!.loginProgressContainer.visibility = View.GONE - binding!!.errorText.visibility = View.VISIBLE - binding!!.errorText.text = errorMessage - binding!!.chooseHostButton.visibility = View.VISIBLE - binding!!.serverUrlText.isEnabled = true - } - - companion object { - val TAG = NextcloudAuthenticationFragment::class.simpleName ?: "Anonymous" - private const val EXTRA_LOGIN_FLOW = "LoginFlow" - } - } - - /** - * Guides the user through the authentication process. - */ - class GpodderAuthenticationFragment : DialogFragment() { - private var viewFlipper: ViewFlipper? = null - private var currentStep = -1 - private var service: GpodnetService? = null - @Volatile - private var username: String? = null - @Volatile - private var password: String? = null - @Volatile - private var selectedDevice: GpodnetDevice? = null - private var devices: List? = null - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = MaterialAlertDialogBuilder(requireContext()) - dialog.setTitle(R.string.gpodnetauth_login_butLabel) - dialog.setNegativeButton(R.string.cancel_label, null) - dialog.setCancelable(false) - this.isCancelable = false - val binding = GpodnetauthDialogBinding.inflate(layoutInflater) -// val root = View.inflate(context, R.layout.gpodnetauth_dialog, null) - viewFlipper = binding.viewflipper - advance() - dialog.setView(binding.root) - - return dialog.create() - } - private fun setupHostView(view: View) { - val binding = GpodnetauthHostBinding.bind(view) - val selectHost = binding.chooseHostButton - val serverUrlText = binding.serverUrlText - selectHost.setOnClickListener { - if (serverUrlText.text.isNullOrEmpty()) return@setOnClickListener - - SynchronizationCredentials.clear(requireContext()) - SynchronizationCredentials.hosturl = serverUrlText.text.toString() - service = GpodnetService(getHttpClient(), SynchronizationCredentials.hosturl, SynchronizationCredentials.deviceID?:"", - SynchronizationCredentials.username?:"", SynchronizationCredentials.password?:"") - dialog?.setTitle(SynchronizationCredentials.hosturl) - advance() - } - } - private fun setupLoginView(view: View) { - val binding = GpodnetauthCredentialsBinding.bind(view) - val username = binding.etxtUsername - val password = binding.etxtPassword - val login = binding.butLogin - val txtvError = binding.credentialsError - val progressBar = binding.progBarLogin - val createAccountWarning = binding.createAccountWarning - - if (SynchronizationCredentials.hosturl != null && SynchronizationCredentials.hosturl!!.startsWith("http://")) - createAccountWarning.visibility = View.VISIBLE - - password.setOnEditorActionListener { _: TextView?, actionID: Int, _: KeyEvent? -> actionID == EditorInfo.IME_ACTION_GO && login.performClick() } - - login.setOnClickListener { - val usernameStr = username.text.toString() - val passwordStr = password.text.toString() - - if (usernameHasUnwantedChars(usernameStr)) { - txtvError.setText(R.string.gpodnetsync_username_characters_error) - txtvError.visibility = View.VISIBLE - return@setOnClickListener - } - - login.isEnabled = false - progressBar.visibility = View.VISIBLE - txtvError.visibility = View.GONE - val inputManager = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - inputManager.hideSoftInputFromWindow(login.windowToken, InputMethodManager.HIDE_NOT_ALWAYS) - - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - service?.setCredentials(usernameStr, passwordStr) - service?.login() - if (service != null) devices = service!!.devices - this@GpodderAuthenticationFragment.username = usernameStr - this@GpodderAuthenticationFragment.password = passwordStr - } - withContext(Dispatchers.Main) { - login.isEnabled = true - progressBar.visibility = View.GONE - advance() - } - } catch (e: Throwable) { - login.isEnabled = true - progressBar.visibility = View.GONE - txtvError.text = e.cause!!.message - txtvError.visibility = View.VISIBLE - } - } - } - } - private fun setupDeviceView(view: View) { - val binding = GpodnetauthDeviceBinding.bind(view) - val deviceName = binding.deviceName - val devicesContainer = binding.devicesContainer - deviceName.setText(generateDeviceName()) - - val createDeviceButton = binding.createDeviceButton - createDeviceButton.setOnClickListener { createDevice(view) } - - for (device in devices!!) { - val rBinding = GpodnetauthDeviceRowBinding.inflate(layoutInflater) -// val row = View.inflate(context, R.layout.gpodnetauth_device_row, null) - val selectDeviceButton = rBinding.selectDeviceButton - selectDeviceButton.setOnClickListener { - selectedDevice = device - advance() - } - selectDeviceButton.text = device.caption - devicesContainer.addView(rBinding.root) - } - } - private fun createDevice(view: View) { - val binding = GpodnetauthDeviceBinding.bind(view) - val deviceName = binding.deviceName - val txtvError = binding.deviceSelectError - val progBarCreateDevice = binding.progbarCreateDevice - - val deviceNameStr = deviceName.text.toString() - if (isDeviceInList(deviceNameStr)) return - - progBarCreateDevice.visibility = View.VISIBLE - txtvError.visibility = View.GONE - deviceName.isEnabled = false - - lifecycleScope.launch { - try { - val device = withContext(Dispatchers.IO) { - val deviceId = generateDeviceId(deviceNameStr) - service!!.configureDevice(deviceId, deviceNameStr, GpodnetDevice.DeviceType.MOBILE) - GpodnetDevice(deviceId, deviceNameStr, GpodnetDevice.DeviceType.MOBILE.toString(), 0) - } - withContext(Dispatchers.Main) { - progBarCreateDevice.visibility = View.GONE - selectedDevice = device - advance() - } - } catch (e: Throwable) { - deviceName.isEnabled = true - progBarCreateDevice.visibility = View.GONE - txtvError.text = e.message - txtvError.visibility = View.VISIBLE - } - } - } - private fun generateDeviceName(): String { - val baseName = getString(R.string.gpodnetauth_device_name_default, Build.MODEL) - var name = baseName - var num = 1 - while (isDeviceInList(name)) { - name = "$baseName ($num)" - num++ - } - return name - } - private fun generateDeviceId(name: String): String { - // devices names must be of a certain form: - // https://gpoddernet.readthedocs.org/en/latest/api/reference/general.html#devices - return generateFileName(name).replace("\\W".toRegex(), "_").lowercase() - } - private fun isDeviceInList(name: String): Boolean { - if (devices == null) return false - - val id = generateDeviceId(name) - for (device in devices!!) { - if (device.id == id || device.caption == name) return true - } - return false - } - private fun setupFinishView(view: View) { - val binding = GpodnetauthFinishBinding.bind(view) - val sync = binding.butSyncNow - - sync.setOnClickListener { - dismiss() - SyncService.sync(requireContext()) - } - } - private fun advance() { - if (currentStep < STEP_FINISH) { - val view = viewFlipper!!.getChildAt(currentStep + 1) - when (currentStep) { - STEP_DEFAULT -> setupHostView(view) - STEP_HOSTNAME -> setupLoginView(view) - STEP_LOGIN -> { - check(!(username == null || password == null)) { "Username and password must not be null here" } - setupDeviceView(view) - } - STEP_DEVICE -> { - checkNotNull(selectedDevice) { "Device must not be null here" } - setSelectedSyncProvider(SynchronizationProviderViewData.GPODDER_NET) - SynchronizationCredentials.username = username - SynchronizationCredentials.password = password - SynchronizationCredentials.deviceID = selectedDevice!!.id - setupFinishView(view) - } - } - if (currentStep != STEP_DEFAULT) viewFlipper!!.showNext() - currentStep++ - } else dismiss() - } - private fun usernameHasUnwantedChars(username: String): Boolean { - val special = Pattern.compile("[!@#$%&*()+=|<>?{}\\[\\]~]") - val containsUnwantedChars = special.matcher(username) - return containsUnwantedChars.find() - } - - companion object { - val TAG = GpodderAuthenticationFragment::class.simpleName ?: "Anonymous" - - private const val STEP_DEFAULT = -1 - private const val STEP_HOSTNAME = 0 - private const val STEP_LOGIN = 1 - private const val STEP_DEVICE = 2 - private const val STEP_FINISH = 3 - } - } - - class WifiAuthenticationFragment : DialogFragment() { - private var binding: WifiSyncDialogBinding? = null - private var portNum = 0 - private var isGuest: Boolean? = null - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = MaterialAlertDialogBuilder(requireContext()) - dialog.setTitle(R.string.connect_to_peer) - dialog.setNegativeButton(R.string.cancel_label, null) - dialog.setPositiveButton(R.string.confirm_label, null) - - binding = WifiSyncDialogBinding.inflate(layoutInflater) - dialog.setView(binding!!.root) - - binding!!.hostAddressText.setText(SynchronizationCredentials.hosturl?:"") - portNum = SynchronizationCredentials.hostport - if (portNum == 0) portNum = hostPort - binding!!.hostPortText.setText(portNum.toString()) - - binding!!.guestButton.setOnClickListener { - binding!!.hostAddressText.visibility = View.VISIBLE - binding!!.hostPortText.visibility = View.VISIBLE - binding!!.hostButton.visibility = View.INVISIBLE - SynchronizationCredentials.hosturl = binding!!.hostAddressText.text.toString() - portNum = binding!!.hostPortText.text.toString().toInt() - isGuest = true - SynchronizationCredentials.hostport = portNum - } - binding!!.hostButton.setOnClickListener { - binding!!.hostAddressText.visibility = View.VISIBLE - binding!!.hostPortText.visibility = View.VISIBLE - binding!!.guestButton.visibility = View.INVISIBLE - val wifiManager = requireContext().applicationContext.getSystemService(WIFI_SERVICE) as WifiManager - val ipAddress = wifiManager.connectionInfo.ipAddress - val ipString = String.format(Locale.US, "%d.%d.%d.%d", ipAddress and 0xff, ipAddress shr 8 and 0xff, ipAddress shr 16 and 0xff, ipAddress shr 24 and 0xff) - binding!!.hostAddressText.setText(ipString) - binding!!.hostAddressText.isEnabled = false - portNum = binding!!.hostPortText.text.toString().toInt() - isGuest = false - SynchronizationCredentials.hostport = portNum - } - procFlowEvents() - return dialog.create() - } - override fun onDestroy() { - cancelFlowEvents() - super.onDestroy() - } - override fun onResume() { - super.onResume() - val d = dialog as? AlertDialog - if (d != null) { - val confirmButton = d.getButton(Dialog.BUTTON_POSITIVE) as Button - confirmButton.setOnClickListener { - Logd(TAG, "confirm button pressed") - if (isGuest == null) { - Toast.makeText(requireContext(), R.string.host_or_guest, Toast.LENGTH_LONG).show() - return@setOnClickListener - } - binding!!.progressContainer.visibility = View.VISIBLE - confirmButton.visibility = View.INVISIBLE - val cancelButton = d.getButton(Dialog.BUTTON_NEGATIVE) as Button - cancelButton.visibility = View.INVISIBLE - portNum = binding!!.hostPortText.text.toString().toInt() - setWifiSyncEnabled(true) - startInstantSync(requireContext(), portNum, binding!!.hostAddressText.text.toString(), isGuest!!) - } - } - } - - private var eventSink: Job? = null - private fun cancelFlowEvents() { - eventSink?.cancel() - eventSink = null - } - private fun procFlowEvents() { - if (eventSink != null) return - eventSink = lifecycleScope.launch { - EventFlow.events.collectLatest { event -> - Logd(TAG, "Received event: ${event.TAG}") - when (event) { - is FlowEvent.SyncServiceEvent -> syncStatusChanged(event) - else -> {} - } - } - } - } - fun syncStatusChanged(event: FlowEvent.SyncServiceEvent) { - when (event.messageResId) { - R.string.sync_status_error -> { - Toast.makeText(requireContext(), event.message, Toast.LENGTH_LONG).show() - dialog?.dismiss() - } - R.string.sync_status_success -> { - Toast.makeText(requireContext(), R.string.sync_status_success, Toast.LENGTH_LONG).show() - dialog?.dismiss() - } - R.string.sync_status_in_progress -> { - binding!!.progressBar.progress = event.message.toInt() - } - else -> { - Logd(TAG, "Sync result unknow ${event.messageResId}") -// Toast.makeText(context, "Sync result unknow ${event.messageResId}", Toast.LENGTH_LONG).show() - } - } - } - - companion object { - val TAG = WifiAuthenticationFragment::class.simpleName ?: "Anonymous" - } - } - - @Suppress("EnumEntryName") - private enum class Prefs { - preference_instant_sync, - preference_synchronization_description, - pref_gpodnet_setlogin_information, - pref_synchronization_sync, - pref_synchronization_force_full_sync, - pref_synchronization_logout, - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/UserInterfacePreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/UserInterfacePreferencesFragment.kt deleted file mode 100644 index 800b3298..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/UserInterfacePreferencesFragment.kt +++ /dev/null @@ -1,169 +0,0 @@ -package ac.mdiq.podcini.preferences.fragments - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.preferences.UserPreferences.defaultPage -import ac.mdiq.podcini.preferences.UserPreferences.fullNotificationButtons -import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems -import ac.mdiq.podcini.preferences.UserPreferences.setShowRemainTimeSetting -import ac.mdiq.podcini.ui.activity.PreferenceActivity -import ac.mdiq.podcini.ui.activity.PreferenceActivity.Companion.getTitleOfPage -import ac.mdiq.podcini.ui.activity.PreferenceActivity.Screens -import ac.mdiq.podcini.ui.activity.PreferenceActivity.SwipePreferencesFragment -import ac.mdiq.podcini.ui.fragment.NavDrawerFragment.Companion.navMap -import ac.mdiq.podcini.util.EventFlow -import ac.mdiq.podcini.util.FlowEvent -import android.content.Context -import android.content.DialogInterface -import android.os.Build -import android.os.Bundle -import androidx.appcompat.app.AlertDialog -import androidx.core.app.ActivityCompat -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar - -class UserInterfacePreferencesFragment : PreferenceFragmentCompat() { - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.preferences_user_interface) - setupInterfaceScreen() - } - - override fun onStart() { - super.onStart() - (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.user_interface_label) - } - - private fun setupInterfaceScreen() { - val restartApp = Preference.OnPreferenceChangeListener { _: Preference?, _: Any? -> - ActivityCompat.recreate(requireActivity()) - true - } - findPreference(UserPreferences.Prefs.prefTheme.name)!!.onPreferenceChangeListener = restartApp - findPreference(UserPreferences.Prefs.prefThemeBlack.name)!!.onPreferenceChangeListener = restartApp - findPreference(UserPreferences.Prefs.prefTintedColors.name)!!.onPreferenceChangeListener = restartApp - if (Build.VERSION.SDK_INT < 31) findPreference(UserPreferences.Prefs.prefTintedColors.name)!!.isVisible = false - - findPreference(UserPreferences.Prefs.showTimeLeft.name)?.setOnPreferenceChangeListener { _: Preference?, newValue: Any? -> - setShowRemainTimeSetting(newValue as Boolean?) - // TODO: need another event type? -// EventFlow.postEvent(FlowEvent.EpisodePlayedEvent()) - EventFlow.postEvent(FlowEvent.PlayerSettingsEvent()) - true - } - - fun drawerPreferencesDialog(context: Context, callback: Runnable?) { - val hiddenItems = hiddenDrawerItems.map { it.trim() }.toMutableSet() -// val navTitles = context.resources.getStringArray(R.array.nav_drawer_titles) - val navTitles = navMap.values.map { context.resources.getString(it.nameRes).trim() }.toTypedArray() - val checked = BooleanArray(navMap.size) - for (i in navMap.keys.indices) { - val tag = navMap.keys.toList()[i] - if (!hiddenItems.contains(tag)) checked[i] = true - } - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(R.string.drawer_preferences) - builder.setMultiChoiceItems(navTitles, checked) { _: DialogInterface?, which: Int, isChecked: Boolean -> - if (isChecked) hiddenItems.remove(navMap.keys.toList()[which]) - else hiddenItems.add((navMap.keys.toList()[which]).trim()) - } - builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> - hiddenDrawerItems = hiddenItems.toList() - if (hiddenItems.contains(defaultPage)) { - for (tag in navMap.keys) { - if (!hiddenItems.contains(tag)) { - defaultPage = tag - break - } - } - } - callback?.run() - } - builder.setNegativeButton(R.string.cancel_label, null) - builder.create().show() - } - - findPreference(UserPreferences.Prefs.prefHiddenDrawerItems.name)?.setOnPreferenceClickListener { - drawerPreferencesDialog(requireContext(), null) - true - } - - findPreference(UserPreferences.Prefs.prefFullNotificationButtons.name)?.setOnPreferenceClickListener { - showFullNotificationButtonsDialog() - true - } - findPreference(PREF_SWIPE)?.setOnPreferenceClickListener { - (activity as PreferenceActivity).openScreen(Screens.preferences_swipe) - true - } - if (Build.VERSION.SDK_INT >= 26) findPreference(UserPreferences.Prefs.prefExpandNotify.name)!!.isVisible = false - } - - - private fun showFullNotificationButtonsDialog() { - val context: Context? = activity - - val preferredButtons = fullNotificationButtons - val allButtonNames = context!!.resources.getStringArray(R.array.full_notification_buttons_options) - val buttonIDs = intArrayOf(2, 3, 4) - val exactItems = 2 - val completeListener = DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> fullNotificationButtons = preferredButtons } - val title = context.resources.getString( - R.string.pref_full_notification_buttons_title) - - showNotificationButtonsDialog(preferredButtons.toMutableList(), allButtonNames, buttonIDs, title, exactItems, completeListener) - } - - private fun showNotificationButtonsDialog(preferredButtons: MutableList?, allButtonNames: Array, buttonIds: IntArray, - title: String, exactItems: Int, completeListener: DialogInterface.OnClickListener) { - val checked = BooleanArray(allButtonNames.size) // booleans default to false in java - - val context: Context? = activity - - // Clear buttons that are not part of the setting anymore - for (i in preferredButtons!!.indices.reversed()) { - var isValid = false - for (j in checked.indices) { - if (buttonIds[j] == preferredButtons[i]) { - isValid = true - break - } - } - if (!isValid) preferredButtons.removeAt(i) - } - - for (i in checked.indices) if (preferredButtons.contains(buttonIds[i])) checked[i] = true - - val builder = MaterialAlertDialogBuilder(context!!) - builder.setTitle(title) - builder.setMultiChoiceItems(allButtonNames, - checked) { _: DialogInterface?, which: Int, isChecked: Boolean -> - checked[which] = isChecked - if (isChecked) preferredButtons.add(buttonIds[which]) - else preferredButtons.remove(buttonIds[which]) - } - builder.setPositiveButton(R.string.confirm_label, null) - builder.setNegativeButton(R.string.cancel_label, null) - val dialog = builder.create() - - dialog.show() - - val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) - - positiveButton.setOnClickListener { - if (preferredButtons.size != exactItems) { - val selectionView = dialog.listView - Snackbar.make(selectionView, String.format(context.resources.getString(R.string.pref_compact_notification_buttons_dialog_error_exact), exactItems), Snackbar.LENGTH_SHORT).show() - } else { - completeListener.onClick(dialog, AlertDialog.BUTTON_POSITIVE) - dialog.cancel() - } - } - } - - companion object { - private const val PREF_SWIPE = "prefSwipe" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoCleanups.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoCleanups.kt index 2f11f318..ee224e96 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoCleanups.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoCleanups.kt @@ -1,7 +1,6 @@ package ac.mdiq.podcini.storage.algorithms import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.preferences.UserPreferences.EPISODE_CLEANUP_NULL import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.preferences.UserPreferences.episodeCacheSize import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload @@ -13,6 +12,7 @@ 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.PlayState +import ac.mdiq.podcini.ui.activity.PreferenceActivity.AutoDownloadPreferencesFragment.EpisodeCleanupOptions import ac.mdiq.podcini.util.Logd import android.content.Context import android.util.Log @@ -25,7 +25,7 @@ object AutoCleanups { private val TAG: String = AutoCleanups::class.simpleName ?: "Anonymous" private var episodeCleanupValue: Int - get() = appPrefs.getString(UserPreferences.Prefs.prefEpisodeCleanup.name, "" + EPISODE_CLEANUP_NULL)!!.toInt() + get() = appPrefs.getString(UserPreferences.Prefs.prefEpisodeCleanup.name, EpisodeCleanupOptions.Never.num.toString())!!.toIntOrNull() ?: EpisodeCleanupOptions.Never.num set(episodeCleanupValue) { appPrefs.edit().putString(UserPreferences.Prefs.prefEpisodeCleanup.name, episodeCleanupValue.toString()).apply() } @@ -46,9 +46,9 @@ object AutoCleanups { if (!isEnableAutodownload) return APNullCleanupAlgorithm() return when (val cleanupValue = episodeCleanupValue) { - UserPreferences.EPISODE_CLEANUP_EXCEPT_FAVORITE -> ExceptFavoriteCleanupAlgorithm() - UserPreferences.EPISODE_CLEANUP_QUEUE -> APQueueCleanupAlgorithm() - EPISODE_CLEANUP_NULL -> APNullCleanupAlgorithm() + EpisodeCleanupOptions.ExceptFavorites.num -> ExceptFavoriteCleanupAlgorithm() + EpisodeCleanupOptions.NotInQueue.num -> APQueueCleanupAlgorithm() + EpisodeCleanupOptions.Never.num -> APNullCleanupAlgorithm() else -> APCleanupAlgorithm(cleanupValue) } } @@ -61,9 +61,7 @@ object AutoCleanups { get() { val candidates: MutableList = ArrayList() val downloadedItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.downloaded.name), EpisodeSortOrder.DATE_NEW_OLD) - for (item in downloadedItems) { - if (item.media != null && item.media!!.downloaded && !item.isSUPER) candidates.add(item) - } + for (item in downloadedItems) if (item.media != null && item.media!!.downloaded && !item.isSUPER) candidates.add(item) return candidates } override fun getReclaimableItems(): Int { @@ -82,13 +80,8 @@ object AutoCleanups { val delete = if (candidates.size > numToRemove) candidates.subList(0, numToRemove) else candidates for (item in delete) { if (item.media == null) continue - try { - runBlocking { deleteEpisodeMedia(context, item).join() } - } catch (e: InterruptedException) { - e.printStackTrace() - } catch (e: ExecutionException) { - e.printStackTrace() - } + try { runBlocking { deleteEpisodeMedia(context, item).join() } + } catch (e: InterruptedException) { e.printStackTrace() } catch (e: ExecutionException) { e.printStackTrace() } } val counter = delete.size Log.i(TAG, String.format(Locale.US, "Auto-delete deleted %d episodes (%d requested)", counter, numToRemove)) @@ -96,7 +89,7 @@ object AutoCleanups { } public override fun getDefaultCleanupParameter(): Int { val cacheSize = episodeCacheSize - if (cacheSize != UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED) { + if (cacheSize > UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED) { val downloadedEpisodes = getEpisodesCount(EpisodeFilter(EpisodeFilter.States.downloaded.name)) if (downloadedEpisodes > cacheSize) return downloadedEpisodes - cacheSize } @@ -115,8 +108,7 @@ object AutoCleanups { val downloadedItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.downloaded.name), EpisodeSortOrder.DATE_NEW_OLD) val idsInQueues = getInQueueEpisodeIds() for (item in downloadedItems) { - if (item.media != null && item.media!!.downloaded && !idsInQueues.contains(item.id) && !item.isSUPER) - candidates.add(item) + if (item.media != null && item.media!!.downloaded && !idsInQueues.contains(item.id) && !item.isSUPER) candidates.add(item) } return candidates } @@ -136,13 +128,8 @@ object AutoCleanups { val delete = if (candidates.size > numToRemove) candidates.subList(0, numToRemove) else candidates for (item in delete) { if (item.media == null) continue - try { - runBlocking { deleteEpisodeMedia(context, item).join() } - } catch (e: InterruptedException) { - e.printStackTrace() - } catch (e: ExecutionException) { - e.printStackTrace() - } + try { runBlocking { deleteEpisodeMedia(context, item).join() } + } catch (e: InterruptedException) { e.printStackTrace() } catch (e: ExecutionException) { e.printStackTrace() } } val counter = delete.size Log.i(TAG, String.format(Locale.US, "Auto-delete deleted %d episodes (%d requested)", counter, numToRemove)) @@ -203,13 +190,8 @@ object AutoCleanups { } val delete = if (candidates.size > numToRemove) candidates.subList(0, numToRemove) else candidates for (item in delete) { - try { - runBlocking { deleteEpisodeMedia(context, item).join() } - } catch (e: InterruptedException) { - e.printStackTrace() - } catch (e: ExecutionException) { - e.printStackTrace() - } + try { runBlocking { deleteEpisodeMedia(context, item).join() } + } catch (e: InterruptedException) { e.printStackTrace() } catch (e: ExecutionException) { e.printStackTrace() } } val counter = delete.size Log.i(TAG, String.format(Locale.US, "Auto-delete deleted %d episodes (%d requested)", counter, numToRemove)) @@ -276,7 +258,7 @@ object AutoCleanups { * @return the number of episodes to delete in order to make room */ fun getNumEpisodesToCleanup(amountOfRoomNeeded: Int): Int { - if (amountOfRoomNeeded >= 0 && episodeCacheSize != UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED) { + if (amountOfRoomNeeded >= 0 && episodeCacheSize > UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED) { val downloadedEpisodes = getEpisodesCount(EpisodeFilter(EpisodeFilter.States.downloaded.name)) if (downloadedEpisodes + amountOfRoomNeeded >= episodeCacheSize) return (downloadedEpisodes + amountOfRoomNeeded - episodeCacheSize) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoDownloads.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoDownloads.kt index 993e2611..60d57a8d 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoDownloads.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoDownloads.kt @@ -171,7 +171,7 @@ object AutoDownloads { val autoDownloadableCount = candidates.size val downloadedCount = getEpisodesCount(EpisodeFilter(EpisodeFilter.States.downloaded.name)) val deletedCount = AutoCleanups.build().makeRoomForEpisodes(context, autoDownloadableCount) - val cacheIsUnlimited = episodeCacheSize == UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED + val cacheIsUnlimited = episodeCacheSize <= UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED val allowedCount = if (cacheIsUnlimited || episodeCacheSize >= downloadedCount + autoDownloadableCount) autoDownloadableCount else episodeCacheSize - (downloadedCount - deletedCount) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt index f9de3daa..0e8c5e3b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt @@ -66,9 +66,7 @@ object Feeds { fun buildTags() { val tagsSet = mutableSetOf() val feedsCopy = getFeedList() - for (feed in feedsCopy) { - if (feed.preferences != null) tagsSet.addAll(feed.preferences!!.tags.filter { it != TAG_ROOT }) - } + for (feed in feedsCopy) if (feed.preferences != null) tagsSet.addAll(feed.preferences!!.tags.filter { it != TAG_ROOT }) val newTags = tagsSet - tags.toSet() if (newTags.isNotEmpty()) { tags.clear() @@ -160,8 +158,7 @@ object Feeds { fun getFeed(feedId: Long, copy: Boolean = false): Feed? { val f = realm.query(Feed::class, "id == $feedId").first().find() return if (f != null) { - if (copy) realm.copyFromRealm(f) - else f + if (copy) realm.copyFromRealm(f) else f } else null } @@ -170,9 +167,7 @@ object Feeds { if (feed.id != 0L) return getFeed(feed.id, copy) val feeds = getFeedList() val feedId = feed.identifyingValue - for (f in feeds) { - if (f.identifyingValue == feedId) return if (copy) realm.copyFromRealm(f) else f - } + for (f in feeds) if (f.identifyingValue == feedId) return if (copy) realm.copyFromRealm(f) else f return null } @@ -769,12 +764,7 @@ object Feeds { } internal fun canonicalizeTitle(title: String?): String { if (title == null) return "" - return title - .trim { it <= ' ' } - .replace('“', '"') - .replace('”', '"') - .replace('„', '"') - .replace('—', '-') + return title.trim { it <= ' ' }.replace('“', '"').replace('”', '"').replace('„', '"').replace('—', '-') } } } \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt index 070463fb..eba8b805 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt @@ -1,5 +1,6 @@ package ac.mdiq.podcini.storage.database +import ac.mdiq.podcini.R import ac.mdiq.podcini.net.download.service.DownloadServiceInterface import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.InTheatre.curQueue @@ -24,8 +25,11 @@ import java.util.* object Queues { private val TAG: String = Queues::class.simpleName ?: "Anonymous" - enum class EnqueueLocation { - BACK, FRONT, AFTER_CURRENTLY_PLAYING, RANDOM + enum class EnqueueLocation(val res: Int) { + BACK(R.string.enqueue_location_back), + FRONT(R.string.enqueue_location_front), + AFTER_CURRENTLY_PLAYING(R.string.enqueue_location_after_current), + RANDOM(R.string.enqueue_location_random) } /** 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 0a2ca131..d4e6fd13 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 @@ -1,57 +1,159 @@ package ac.mdiq.podcini.ui.activity +import ac.mdiq.podcini.BuildConfig +import ac.mdiq.podcini.PodciniApp.Companion.forceRestart import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.SettingsActivityBinding +import ac.mdiq.podcini.databinding.* +import ac.mdiq.podcini.net.download.service.PodciniHttpClient +import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient +import ac.mdiq.podcini.net.download.service.PodciniHttpClient.newBuilder +import ac.mdiq.podcini.net.download.service.PodciniHttpClient.reinit +import ac.mdiq.podcini.net.feed.FeedUpdateManager.restartUpdateAlarm +import ac.mdiq.podcini.net.sync.SyncService +import ac.mdiq.podcini.net.sync.SyncService.Companion.isValidGuid +import ac.mdiq.podcini.net.sync.SynchronizationCredentials +import ac.mdiq.podcini.net.sync.SynchronizationProviderViewData +import ac.mdiq.podcini.net.sync.SynchronizationSettings +import ac.mdiq.podcini.net.sync.SynchronizationSettings.isProviderConnected +import ac.mdiq.podcini.net.sync.SynchronizationSettings.setSelectedSyncProvider +import ac.mdiq.podcini.net.sync.SynchronizationSettings.setWifiSyncEnabled +import ac.mdiq.podcini.net.sync.SynchronizationSettings.wifiSyncEnabledKey +import ac.mdiq.podcini.net.sync.gpoddernet.GpodnetService +import ac.mdiq.podcini.net.sync.gpoddernet.model.GpodnetDevice +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.net.sync.nextcloud.NextcloudLoginFlow +import ac.mdiq.podcini.net.sync.wifi.WifiSyncService.Companion.hostPort +import ac.mdiq.podcini.net.sync.wifi.WifiSyncService.Companion.startInstantSync +import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.prefPlaybackSpeed +import ac.mdiq.podcini.preferences.* +import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlWriter import ac.mdiq.podcini.preferences.ThemeSwitcher.getTheme -import ac.mdiq.podcini.preferences.fragments.* -import ac.mdiq.podcini.preferences.fragments.SynchronizationPreferencesFragment +import ac.mdiq.podcini.preferences.UserPreferences.appPrefs +import ac.mdiq.podcini.preferences.UserPreferences.defaultPage +import ac.mdiq.podcini.preferences.UserPreferences.fallbackSpeed +import ac.mdiq.podcini.preferences.UserPreferences.fastForwardSecs +import ac.mdiq.podcini.preferences.UserPreferences.fullNotificationButtons +import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems +import ac.mdiq.podcini.preferences.UserPreferences.proxyConfig +import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs +import ac.mdiq.podcini.preferences.UserPreferences.setVideoMode +import ac.mdiq.podcini.preferences.UserPreferences.speedforwardSpeed +import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode +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.Queues.EnqueueLocation +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.ui.actions.SwipeActions.Companion.SwipeActionsDialog import ac.mdiq.podcini.ui.compose.CustomTheme -import ac.mdiq.podcini.ui.fragment.AllEpisodesFragment -import ac.mdiq.podcini.ui.fragment.DownloadsFragment -import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment -import ac.mdiq.podcini.ui.fragment.HistoryFragment -import ac.mdiq.podcini.ui.fragment.QueuesFragment -import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.ui.compose.PlaybackSpeedDialog +import ac.mdiq.podcini.ui.fragment.* +import ac.mdiq.podcini.ui.fragment.NavDrawerFragment.Companion.navMap +import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent +import ac.mdiq.podcini.util.IntentUtils.openInBrowser +import ac.mdiq.podcini.util.Logd import android.annotation.SuppressLint -import android.content.Intent +import android.app.Dialog +import android.content.* +import android.net.Uri +import android.net.wifi.WifiManager import android.os.Build import android.os.Bundle +import android.os.ParcelFileDescriptor import android.provider.Settings -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup +import android.text.Editable +import android.text.TextWatcher +import android.text.format.DateUtils +import android.text.format.Formatter +import android.text.method.HideReturnsTransformationMethod +import android.text.method.PasswordTransformationMethod +import android.util.Log +import android.util.Patterns +import android.view.* +import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager +import android.widget.* +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +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.ActivityCompat +import androidx.core.app.ShareCompat.IntentBuilder +import androidx.core.content.FileProvider +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.bytehamster.lib.preferencesearch.SearchPreferenceResult import com.bytehamster.lib.preferencesearch.SearchPreferenceResultListener +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.Job +import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch +import okhttp3.Credentials.basic +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Request.Builder +import okhttp3.Response +import okhttp3.Route +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.net.InetSocketAddress +import java.net.Proxy +import java.net.SocketAddress +import java.nio.channels.FileChannel +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.TimeUnit +import java.util.regex.Pattern +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.Throws +import kotlin.math.round /** * PreferenceActivity for API 11+. In order to change the behavior of the preference UI, see * PreferenceController. */ -class PreferenceActivity : AppCompatActivity(), SearchPreferenceResultListener { +class PreferenceActivity : AppCompatActivity() { private var _binding: SettingsActivityBinding? = null private val binding get() = _binding!! @@ -71,31 +173,7 @@ class PreferenceActivity : AppCompatActivity(), SearchPreferenceResultListener { supportFragmentManager.beginTransaction().replace(binding.settingsContainer.id, MainPreferencesFragment(), FRAGMENT_TAG).commit() val intent = intent - if (intent.getBooleanExtra(OPEN_AUTO_DOWNLOAD_SETTINGS, false)) openScreen(R.xml.preferences_autodownload) - } - - @SuppressLint("CommitTransaction") - fun openScreen(screen: Int): PreferenceFragmentCompat { - val fragment = when (screen) { - R.xml.preferences_user_interface -> UserInterfacePreferencesFragment() -// R.xml.preferences_downloads -> DownloadsPreferencesFragment() -// R.xml.preferences_import_export -> ImportExportPreferencesFragment() - R.xml.preferences_autodownload -> AutoDownloadPreferencesFragment() - R.xml.preferences_synchronization -> SynchronizationPreferencesFragment() - R.xml.preferences_playback -> PlaybackPreferencesFragment() - R.xml.preferences_notifications -> NotificationPreferencesFragment() -// R.xml.preferences_swipe -> SwipePreferencesFragment() - else -> UserInterfacePreferencesFragment() - } - - if (screen == R.xml.preferences_notifications && Build.VERSION.SDK_INT >= 26) { - val intent = Intent() - intent.setAction(Settings.ACTION_APP_NOTIFICATION_SETTINGS) - intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName) - startActivity(intent) - } else - supportFragmentManager.beginTransaction().replace(binding.settingsContainer.id, fragment).addToBackStack(getString(getTitleOfPage(screen))).commit() - return fragment + if (intent.getBooleanExtra(OPEN_AUTO_DOWNLOAD_SETTINGS, false)) openScreen(Screens.preferences_autodownload) } fun openScreen(screen: Screens): PreferenceFragmentCompat { @@ -109,7 +187,13 @@ class PreferenceActivity : AppCompatActivity(), SearchPreferenceResultListener { Screens.preferences_notifications -> NotificationPreferencesFragment() Screens.preferences_swipe -> SwipePreferencesFragment() } - supportFragmentManager.beginTransaction().replace(binding.settingsContainer.id, fragment).addToBackStack(getString(screen.titleRes)).commit() + if (screen == Screens.preferences_notifications && Build.VERSION.SDK_INT >= 26) { + val intent = Intent() + intent.setAction(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + startActivity(intent) + } else + supportFragmentManager.beginTransaction().replace(binding.settingsContainer.id, fragment).addToBackStack(getString(screen.titleRes)).commit() return fragment } @@ -129,16 +213,6 @@ class PreferenceActivity : AppCompatActivity(), SearchPreferenceResultListener { return false } - override fun onSearchResultClicked(result: SearchPreferenceResult) { - when (val screen = result.resourceFile) { - R.xml.preferences_notifications -> openScreen(screen) - else -> { - val fragment = openScreen(result.resourceFile) - result.highlight(fragment) - } - } - } - override fun onStart() { super.onStart() procFlowEvents() @@ -179,6 +253,584 @@ class PreferenceActivity : AppCompatActivity(), SearchPreferenceResultListener { s.show() } + class MainPreferencesFragment : PreferenceFragmentCompat() { + var copyrightNoticeText by mutableStateOf("") + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.settings_label) + val packageHash = requireContext().packageName.hashCode() + when { + packageHash != 1329568231 && packageHash != 1297601420 -> { + copyrightNoticeText = ("This application is based on Podcini." + + " The Podcini team does NOT provide support for this unofficial version." + + " If you can read this message, the developers of this modification" + + " violate the GNU General Public License (GPL).") + } + packageHash == 1297601420 -> copyrightNoticeText = "This is a development version of Podcini and not meant for daily use" + } + + return ComposeView(requireContext()).apply { + setContent { + CustomTheme(requireContext()) { + val textColor = MaterialTheme.colorScheme.onSurface + val scrollState = rememberScrollState() + Column(modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp).verticalScroll(scrollState)) { + if (copyrightNoticeText.isNotBlank()) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = Icons.Filled.Info, contentDescription = "", tint = Color.Red, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Text(copyrightNoticeText, color = textColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_appearance), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + (activity as PreferenceActivity).openScreen(Screens.preferences_user_interface) + })) { + Text(stringResource(R.string.user_interface_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.user_interface_sum), color = textColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_play_24dp), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + (activity as PreferenceActivity).openScreen(Screens.preferences_playback) + })) { + Text(stringResource(R.string.playback_pref), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.playback_pref_sum), color = textColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_download), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + (activity as PreferenceActivity).openScreen(Screens.preferences_downloads) + })) { + Text(stringResource(R.string.downloads_pref), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.downloads_pref_sum), color = textColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_cloud), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + (activity as PreferenceActivity).openScreen(Screens.preferences_synchronization) + })) { + Text(stringResource(R.string.synchronization_pref), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.synchronization_sum), color = textColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_storage), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + (activity as PreferenceActivity).openScreen(Screens.preferences_import_export) + })) { + Text(stringResource(R.string.import_export_pref), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.import_export_summary), color = textColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_notifications), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + (activity as PreferenceActivity).openScreen(Screens.preferences_notifications) + })) { + Text(stringResource(R.string.notification_pref_fragment), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_backup_on_google_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_backup_on_google_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefOPMLBackup.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefOPMLBackup.name, it).apply() + // Restart the app + val intent = context?.packageManager?.getLaunchIntentForPackage(requireContext().packageName) + intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + context?.startActivity(intent) + }) + } + HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp).padding(top = 10.dp)) + Text(stringResource(R.string.project_pref), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 15.dp)) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_questionmark), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + openInBrowser(requireContext(), "https://github.com/XilinJia/Podcini") + })) { + Text(stringResource(R.string.documentation_support), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_chat), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + openInBrowser(requireContext(), "https://github.com/XilinJia/Podcini/discussions") + })) { + Text(stringResource(R.string.visit_user_forum), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_contribute), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + openInBrowser(requireContext(), "https://github.com/XilinJia/Podcini") + })) { + Text(stringResource(R.string.pref_contribute), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_bug), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + startActivity(Intent(activity, BugReportActivity::class.java)) + })) { + Text(stringResource(R.string.bug_report_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_info), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + parentFragmentManager.beginTransaction().replace(R.id.settingsContainer, AboutFragment()).addToBackStack(getString(R.string.about_pref)).commit() + })) { + Text(stringResource(R.string.about_pref), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + } + } + } + } + } + } + + class AboutFragment : PreferenceFragmentCompat() { + @SuppressLint("CommitTransaction") + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + (activity as PreferenceActivity).supportActionBar?.setTitle(R.string.about_pref) + return ComposeView(requireContext()).apply { + setContent { + CustomTheme(requireContext()) { + val textColor = MaterialTheme.colorScheme.onSurface + Column(modifier = Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp)) { + Image(painter = painterResource(R.drawable.teaser), contentDescription = "") + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 10.dp, top = 5.dp, bottom = 5.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_star), contentDescription = "", tint = textColor) + Column(Modifier.padding(start = 10.dp).clickable(onClick = { + val clipboard = requireContext().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(getString(R.string.bug_report_title), findPreference("about_version")!!.summary) + clipboard.setPrimaryClip(clip) + if (Build.VERSION.SDK_INT <= 32) Snackbar.make(requireView(), R.string.copied_to_clipboard, Snackbar.LENGTH_SHORT).show() + })) { + Text(stringResource(R.string.podcini_version), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(String.format("%s (%s)", BuildConfig.VERSION_NAME, BuildConfig.COMMIT_HASH), color = textColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 10.dp, top = 5.dp, bottom = 5.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_questionmark), contentDescription = "", tint = textColor) + Column(Modifier.padding(start = 10.dp).clickable(onClick = { + openInBrowser(requireContext(), "https://github.com/XilinJia/Podcini/") + })) { + Text(stringResource(R.string.online_help), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.online_help_sum), color = textColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 10.dp, top = 5.dp, bottom = 5.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_info), contentDescription = "", tint = textColor) + Column(Modifier.padding(start = 10.dp).clickable(onClick = { + openInBrowser(requireContext(), "https://github.com/XilinJia/Podcini/blob/main/PrivacyPolicy.md") + })) { + Text(stringResource(R.string.privacy_policy), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text("Podcini PrivacyPolicy", color = textColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 10.dp, top = 5.dp, bottom = 5.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_info), contentDescription = "", tint = textColor) + Column(Modifier.padding(start = 10.dp).clickable(onClick = { + parentFragmentManager.beginTransaction().replace(R.id.settingsContainer, LicensesFragment()).addToBackStack(getString(R.string.translators)).commit() + })) { + Text(stringResource(R.string.licenses), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.licenses_summary), color = textColor) + } + } + } + } + } + } + } + + class LicensesFragment : Fragment() { + private val licenses = mutableStateListOf() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val composeView = ComposeView(requireContext()).apply { setContent { CustomTheme(requireContext()) { MainView() } } } + lifecycleScope.launch(Dispatchers.IO) { + licenses.clear() + val stream = requireContext().assets.open("licenses.xml") + val docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder() + val libraryList = docBuilder.parse(stream).getElementsByTagName("library") + for (i in 0 until libraryList.length) { + val lib = libraryList.item(i).attributes + licenses.add(LicenseItem(lib.getNamedItem("name").textContent, + String.format("By %s, %s license", lib.getNamedItem("author").textContent, lib.getNamedItem("license").textContent), lib.getNamedItem("website").textContent, lib.getNamedItem("licenseText").textContent)) + } + }.invokeOnCompletion { throwable -> if (throwable!= null) Toast.makeText(context, throwable.message, Toast.LENGTH_LONG).show() } + return composeView + } + + @Composable + fun MainView() { + val lazyListState = rememberLazyListState() + val textColor = MaterialTheme.colorScheme.onSurface + var showDialog by remember { mutableStateOf(false) } + var curLicenseIndex by remember { mutableIntStateOf(-1) } + if (showDialog) Dialog(onDismissRequest = { showDialog = false }) { + Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(licenses[curLicenseIndex].title, color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Row { + Button(onClick = { openInBrowser(requireContext(), licenses[curLicenseIndex].licenseUrl) }) { Text("View website") } + Spacer(Modifier.weight(1f)) + Button(onClick = { showLicenseText(licenses[curLicenseIndex].licenseTextFile) }) { Text("View license") } + } + } + } + } + LazyColumn(state = lazyListState, modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + itemsIndexed(licenses) { index, item -> + Column(Modifier.clickable(onClick = { + curLicenseIndex = index + showDialog = true + })) { + Text(item.title, color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(item.subtitle, color = textColor, style = MaterialTheme.typography.bodySmall) + } + } + } + } + + private fun showLicenseText(licenseTextFile: String) { + try { + val reader = BufferedReader(InputStreamReader(requireContext().assets.open(licenseTextFile), "UTF-8")) + val licenseText = StringBuilder() + var line = "" + while ((reader.readLine()?.also { line = it }) != null) licenseText.append(line).append("\n") + MaterialAlertDialogBuilder(requireContext()).setMessage(licenseText).show() + } catch (e: IOException) { e.printStackTrace() } + } + + override fun onStart() { + super.onStart() + (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.licenses) + } + + private class LicenseItem(val title: String, val subtitle: String, val licenseUrl: String, val licenseTextFile: String) + } + } + } + + class UserInterfacePreferencesFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.user_interface_label) + return ComposeView(requireContext()).apply { + setContent { + CustomTheme(requireContext()) { + val textColor = MaterialTheme.colorScheme.onSurface + val scrollState = rememberScrollState() + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) { + Text(stringResource(R.string.appearance), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Row(verticalAlignment = Alignment.CenterVertically) { + var checkIndex by remember { mutableIntStateOf( + when(UserPreferences.theme) { + UserPreferences.ThemePreference.SYSTEM -> 0 + UserPreferences.ThemePreference.LIGHT -> 1 + UserPreferences.ThemePreference.DARK -> 2 + else -> 0 + }) } + Spacer(Modifier.weight(1f)) + RadioButton(selected = checkIndex == 0, onClick = { + checkIndex = 0 + UserPreferences.theme = UserPreferences.ThemePreference.SYSTEM + ActivityCompat.recreate(requireActivity()) + }) + Text(stringResource(R.string.pref_theme_title_automatic), color = textColor, fontWeight = FontWeight.Bold) + Spacer(Modifier.weight(1f)) + RadioButton(selected = checkIndex == 1, onClick = { + checkIndex = 1 + UserPreferences.theme = UserPreferences.ThemePreference.LIGHT + ActivityCompat.recreate(requireActivity()) + }) + Text(stringResource(R.string.pref_theme_title_light), color = textColor, fontWeight = FontWeight.Bold) + Spacer(Modifier.weight(1f)) + RadioButton(selected = checkIndex == 2, onClick = { + checkIndex = 2 + UserPreferences.theme = UserPreferences.ThemePreference.DARK + ActivityCompat.recreate(requireActivity()) + }) + Text(stringResource(R.string.pref_theme_title_dark), color = textColor, fontWeight = FontWeight.Bold) + Spacer(Modifier.weight(1f)) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_black_theme_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_black_theme_message), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefThemeBlack.name, false)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefThemeBlack.name, it).apply() + ActivityCompat.recreate(requireActivity()) + }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_tinted_theme_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_tinted_theme_message), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefTintedColors.name, false)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefTintedColors.name, it).apply() + ActivityCompat.recreate(requireActivity()) + }) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + drawerPreferencesDialog(requireContext(), null) + })) { + Text(stringResource(R.string.pref_nav_drawer_items_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_nav_drawer_items_sum), color = textColor) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_episode_cover_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_episode_cover_summary), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefEpisodeCover.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefEpisodeCover.name, it).apply() + }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_show_remain_time_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_show_remain_time_summary), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.showTimeLeft.name, false)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.showTimeLeft.name, it).apply() + }) + } + Text(stringResource(R.string.subscriptions_label), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 15.dp)) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_swipe_refresh_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_swipe_refresh_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefSwipeToRefreshAll.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefSwipeToRefreshAll.name, it).apply() + }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_feedGridLayout_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_feedGridLayout_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefFeedGridLayout.name, false)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefFeedGridLayout.name, it).apply() + }) + } + Text(stringResource(R.string.external_elements), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 15.dp)) + if (Build.VERSION.SDK_INT < 26) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_expandNotify_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_expandNotify_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefExpandNotify.name, false)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefExpandNotify.name, it).apply() + }) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_persistNotify_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_persistNotify_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefPersistNotify.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefPersistNotify.name, it).apply() + }) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + showFullNotificationButtonsDialog() + })) { + Text(stringResource(R.string.pref_full_notification_buttons_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_full_notification_buttons_sum), color = textColor) + } + Text(stringResource(R.string.behavior), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 15.dp)) + var showDefaultPageOptions by remember { mutableStateOf(false) } + var tempSelectedOption by remember { mutableStateOf(appPrefs.getString(UserPreferences.Prefs.prefDefaultPage.name, DefaultPages.SubscriptionsFragment.name)!!) } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { showDefaultPageOptions = true })) { + Text(stringResource(R.string.pref_default_page), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_default_page_sum), color = textColor) + } + if (showDefaultPageOptions) { + AlertDialog(onDismissRequest = { showDefaultPageOptions = false }, + title = { Text(stringResource(R.string.pref_default_page), style = MaterialTheme.typography.titleLarge) }, + text = { + Column { + DefaultPages.entries.forEach { option -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable { tempSelectedOption = option.name }) { + Checkbox(checked = tempSelectedOption == option.name, onCheckedChange = { tempSelectedOption = option.name }) + Text(stringResource(option.res), modifier = Modifier.padding(start = 16.dp), style = MaterialTheme.typography.bodyMedium) + } + } + } + }, + confirmButton = { + TextButton(onClick = { + appPrefs.edit().putString(UserPreferences.Prefs.prefDefaultPage.name, tempSelectedOption).apply() + showDefaultPageOptions = false + }) { Text(text = "OK") } + }, + dismissButton = { TextButton(onClick = { showDefaultPageOptions = false }) { Text(text = "Cancel") } } + ) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_back_button_opens_drawer), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_back_button_opens_drawer_summary), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefBackButtonOpensDrawer.name, false)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefBackButtonOpensDrawer.name, it).apply() + }) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + (activity as PreferenceActivity).openScreen(Screens.preferences_swipe) + })) { + Text(stringResource(R.string.swipeactions_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.swipeactions_summary), color = textColor) + } + } + } + } + } + } + + enum class DefaultPages(val res: Int) { + SubscriptionsFragment(R.string.subscriptions_label), + QueuesFragment(R.string.queue_label), + AllEpisodesFragment(R.string.episodes_label), + DownloadsFragment(R.string.downloads_label), + PlaybackHistoryFragment(R.string.playback_history_label), + AddFeedFragment(R.string.add_feed_label), + StatisticsFragment(R.string.statistics_label), + remember(R.string.remember_last_page); + } + + fun drawerPreferencesDialog(context: Context, callback: Runnable?) { + val hiddenItems = hiddenDrawerItems.map { it.trim() }.toMutableSet() +// val navTitles = context.resources.getStringArray(R.array.nav_drawer_titles) + val navTitles = navMap.values.map { context.resources.getString(it.nameRes).trim() }.toTypedArray() + val checked = BooleanArray(navMap.size) + for (i in navMap.keys.indices) { + val tag = navMap.keys.toList()[i] + if (!hiddenItems.contains(tag)) checked[i] = true + } + val builder = MaterialAlertDialogBuilder(context) + builder.setTitle(R.string.drawer_preferences) + builder.setMultiChoiceItems(navTitles, checked) { _: DialogInterface?, which: Int, isChecked: Boolean -> + if (isChecked) hiddenItems.remove(navMap.keys.toList()[which]) + else hiddenItems.add((navMap.keys.toList()[which]).trim()) + } + builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> + hiddenDrawerItems = hiddenItems.toList() + if (hiddenItems.contains(defaultPage)) { + for (tag in navMap.keys) { + if (!hiddenItems.contains(tag)) { + defaultPage = tag + break + } + } + } + callback?.run() + } + builder.setNegativeButton(R.string.cancel_label, null) + builder.create().show() + } + + private fun showFullNotificationButtonsDialog() { + val context: Context? = activity + + val preferredButtons = fullNotificationButtons + val allButtonNames = context!!.resources.getStringArray(R.array.full_notification_buttons_options) + val buttonIDs = intArrayOf(2, 3, 4) + val exactItems = 2 + val completeListener = DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> fullNotificationButtons = preferredButtons } + val title = context.resources.getString( + R.string.pref_full_notification_buttons_title) + + showNotificationButtonsDialog(preferredButtons.toMutableList(), allButtonNames, buttonIDs, title, exactItems, completeListener) + } + + private fun showNotificationButtonsDialog(preferredButtons: MutableList?, allButtonNames: Array, buttonIds: IntArray, + title: String, exactItems: Int, completeListener: DialogInterface.OnClickListener) { + val checked = BooleanArray(allButtonNames.size) // booleans default to false in java + val context: Context? = activity + + // Clear buttons that are not part of the setting anymore + for (i in preferredButtons!!.indices.reversed()) { + var isValid = false + for (j in checked.indices) { + if (buttonIds[j] == preferredButtons[i]) { + isValid = true + break + } + } + if (!isValid) preferredButtons.removeAt(i) + } + + for (i in checked.indices) if (preferredButtons.contains(buttonIds[i])) checked[i] = true + + val builder = MaterialAlertDialogBuilder(context!!) + builder.setTitle(title) + builder.setMultiChoiceItems(allButtonNames, + checked) { _: DialogInterface?, which: Int, isChecked: Boolean -> + checked[which] = isChecked + if (isChecked) preferredButtons.add(buttonIds[which]) + else preferredButtons.remove(buttonIds[which]) + } + builder.setPositiveButton(R.string.confirm_label, null) + builder.setNegativeButton(R.string.cancel_label, null) + val dialog = builder.create() + + dialog.show() + + val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + + positiveButton.setOnClickListener { + if (preferredButtons.size != exactItems) { + val selectionView = dialog.listView + Snackbar.make(selectionView, String.format(context.resources.getString(R.string.pref_compact_notification_buttons_dialog_error_exact), exactItems), Snackbar.LENGTH_SHORT).show() + } else { + completeListener.onClick(dialog, AlertDialog.BUTTON_POSITIVE) + dialog.cancel() + } + } + } + } + class SwipePreferencesFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -210,6 +862,2606 @@ class PreferenceActivity : AppCompatActivity(), SearchPreferenceResultListener { } } + class PlaybackPreferencesFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} + + enum class PrefHardwareForwardButton(val res: Int, val res1: Int) { + FF(R.string.button_action_fast_forward, R.string.keycode_media_fast_forward), + RW(R.string.button_action_rewind, R.string.keycode_media_rewind), + SKIP(R.string.button_action_skip_episode, R.string.keycode_media_next), + START(R.string.button_action_restart_episode, R.string.keycode_media_previous); + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.playback_pref) + return ComposeView(requireContext()).apply { + setContent { + CustomTheme(requireContext()) { + val textColor = MaterialTheme.colorScheme.onSurface + val scrollState = rememberScrollState() + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) { + Text(stringResource(R.string.interruptions), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + var prefUnpauseOnHeadsetReconnect by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefPauseOnHeadsetDisconnect.name, true)) } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_pauseOnHeadsetDisconnect_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_pauseOnDisconnect_sum), color = textColor) + } + Switch(checked = prefUnpauseOnHeadsetReconnect, onCheckedChange = { + prefUnpauseOnHeadsetReconnect = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefPauseOnHeadsetDisconnect.name, it).apply() + }) + } + if (prefUnpauseOnHeadsetReconnect) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_unpauseOnHeadsetReconnect_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_unpauseOnHeadsetReconnect_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefUnpauseOnHeadsetReconnect.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefUnpauseOnHeadsetReconnect.name, it).apply() }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_unpauseOnBluetoothReconnect_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_unpauseOnBluetoothReconnect_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefUnpauseOnBluetoothReconnect.name, false)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefUnpauseOnBluetoothReconnect.name, it).apply() }) + } + } + // not used +// Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { +// Column(modifier = Modifier.weight(1f)) { +// Text(stringResource(R.string.pref_pausePlaybackForFocusLoss_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) +// Text(stringResource(R.string.pref_pausePlaybackForFocusLoss_sum), color = textColor) +// } +// var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefPauseForFocusLoss.name, true)) } +// Switch(checked = isChecked, onCheckedChange = { +// isChecked = it +// appPrefs.edit().putBoolean(UserPreferences.Prefs.prefPauseForFocusLoss.name, it).apply() }) +// } + HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp).padding(top = 10.dp)) + Text(stringResource(R.string.playback_control), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 15.dp)) + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.pref_fast_forward), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + var interval by remember { mutableStateOf(fastForwardSecs.toString()) } + var showIcon by remember { mutableStateOf(false) } + TextField(value = interval, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), label = { Text("seconds") }, + singleLine = true, modifier = Modifier.weight(0.5f), + onValueChange = { + if (it.isEmpty() || it.toIntOrNull() != null) interval = it + if (it.toIntOrNull() != null) showIcon = true + }, + trailingIcon = { + if (showIcon) Icon(imageVector = Icons.Filled.Settings, contentDescription = "Settings icon", + modifier = Modifier.size(30.dp).padding(start = 10.dp).clickable(onClick = { + if (interval.isNotBlank()) { + fastForwardSecs = interval.toInt() + showIcon = false + } + })) + }) + } + Text(stringResource(R.string.pref_fast_forward_sum), color = textColor) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.pref_rewind), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + var interval by remember { mutableStateOf(rewindSecs.toString()) } + var showIcon by remember { mutableStateOf(false) } + TextField(value = interval, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), label = { Text("seconds") }, + singleLine = true, modifier = Modifier.weight(0.5f), + onValueChange = { + if (it.isEmpty() || it.toIntOrNull() != null) interval = it + if (it.toIntOrNull() != null) showIcon = true + }, + trailingIcon = { + if (showIcon) Icon(imageVector = Icons.Filled.Settings, contentDescription = "Settings icon", + modifier = Modifier.size(30.dp).padding(start = 10.dp).clickable(onClick = { + if (interval.isNotBlank()) { + rewindSecs = interval.toInt() + showIcon = false + } + })) + }) + } + Text(stringResource(R.string.pref_rewind_sum), color = textColor) + } + var showSpeedDialog by remember { mutableStateOf(false) } + if (showSpeedDialog) PlaybackSpeedDialog(listOf(), initSpeed = prefPlaybackSpeed, maxSpeed = 3f, isGlobal = true, + onDismiss = { showSpeedDialog = false }) { speed -> UserPreferences.setPlaybackSpeed(speed) } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { showSpeedDialog = true })) { + Text(stringResource(R.string.playback_speed), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_playback_speed_sum), color = textColor) + } + var showFBSpeedDialog by remember { mutableStateOf(false) } + if (showFBSpeedDialog) PlaybackSpeedDialog(listOf(), initSpeed = fallbackSpeed, maxSpeed = 3f, isGlobal = true, + onDismiss = { showFBSpeedDialog = false }) { speed -> + val speed_ = when { + speed < 0.0f -> 0.0f + speed > 3.0f -> 3.0f + else -> 0f + } + fallbackSpeed = round(100 * speed_) / 100f + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { showFBSpeedDialog = true })) { + Text(stringResource(R.string.pref_fallback_speed), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_fallback_speed_sum), color = textColor) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_playback_time_respects_speed_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_playback_time_respects_speed_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefPlaybackTimeRespectsSpeed.name, false)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefPlaybackTimeRespectsSpeed.name, it).apply() }) + } + var showFFSpeedDialog by remember { mutableStateOf(false) } + if (showFFSpeedDialog) PlaybackSpeedDialog(listOf(), initSpeed = speedforwardSpeed, maxSpeed = 10f, isGlobal = true, + onDismiss = { showFFSpeedDialog = false }) { speed -> + val speed_ = when { + speed < 0.0f -> 0.0f + speed > 10.0f -> 10.0f + else -> 0f + } + speedforwardSpeed = round(10 * speed_) / 10 + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { showFFSpeedDialog = true })) { + Text(stringResource(R.string.pref_speed_forward), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_speed_forward_sum), color = textColor) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_stream_over_download_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_stream_over_download_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefStreamOverDownload.name, false)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefStreamOverDownload.name, it).apply() }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_low_quality_on_mobile_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_low_quality_on_mobile_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefLowQualityOnMobile.name, false)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefLowQualityOnMobile.name, it).apply() }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_use_adaptive_progress_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_use_adaptive_progress_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefUseAdaptiveProgressUpdate.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefUseAdaptiveProgressUpdate.name, it).apply() }) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { VideoModeDialog.showDialog(requireContext()) })) { + Text(stringResource(R.string.pref_playback_video_mode), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_playback_video_mode_sum), color = textColor) + } + HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp).padding(top = 10.dp)) + Text(stringResource(R.string.reassign_hardware_buttons), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 15.dp)) + var showHardwareForwardButtonOptions by remember { mutableStateOf(false) } + var tempFFSelectedOption by remember { mutableStateOf(R.string.keycode_media_fast_forward) } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { showHardwareForwardButtonOptions = true })) { + Text(stringResource(R.string.pref_hardware_forward_button_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_hardware_forward_button_summary), color = textColor) + } + if (showHardwareForwardButtonOptions) { + AlertDialog(onDismissRequest = { showHardwareForwardButtonOptions = false }, + title = { Text(stringResource(R.string.pref_hardware_forward_button_title), style = MaterialTheme.typography.titleLarge) }, + text = { + Column { + PrefHardwareForwardButton.entries.forEach { option -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(2.dp) + .clickable { tempFFSelectedOption = option.res1 }) { + Checkbox(checked = tempFFSelectedOption == option.res1, onCheckedChange = { tempFFSelectedOption = option.res1 }) + Text(stringResource(option.res), modifier = Modifier.padding(start = 16.dp), style = MaterialTheme.typography.bodyMedium) + } + } + } + }, + confirmButton = { + TextButton(onClick = { + appPrefs.edit().putString(UserPreferences.Prefs.prefHardwareForwardButton.name, tempFFSelectedOption.toString()).apply() + showHardwareForwardButtonOptions = false + }) { Text(text = "OK") } + }, + dismissButton = { TextButton(onClick = { showHardwareForwardButtonOptions = false }) { Text(text = "Cancel") } } + ) + } + var showHardwarePreviousButtonOptions by remember { mutableStateOf(false) } + var tempPRSelectedOption by remember { mutableStateOf(R.string.keycode_media_rewind) } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { showHardwarePreviousButtonOptions = true })) { + Text(stringResource(R.string.pref_hardware_previous_button_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_hardware_previous_button_summary), color = textColor) + } + if (showHardwarePreviousButtonOptions) { + AlertDialog(onDismissRequest = { showHardwarePreviousButtonOptions = false }, + title = { Text(stringResource(R.string.pref_hardware_previous_button_title), style = MaterialTheme.typography.titleLarge) }, + text = { + Column { + PrefHardwareForwardButton.entries.forEach { option -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(2.dp) + .clickable { tempPRSelectedOption = option.res1 }) { + Checkbox(checked = tempPRSelectedOption == option.res1, onCheckedChange = { tempPRSelectedOption = option.res1 }) + Text(stringResource(option.res), modifier = Modifier.padding(start = 16.dp), style = MaterialTheme.typography.bodyMedium) + } + } + } + }, + confirmButton = { + TextButton(onClick = { + appPrefs.edit().putString(UserPreferences.Prefs.prefHardwarePreviousButton.name, tempPRSelectedOption.toString()).apply() + showHardwarePreviousButtonOptions = false + }) { Text(text = "OK") } + }, + dismissButton = { TextButton(onClick = { showHardwarePreviousButtonOptions = false }) { Text(text = "Cancel") } } + ) + } + HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp).padding(top = 10.dp)) + Text(stringResource(R.string.queue_label), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 15.dp)) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_enqueue_downloaded_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_enqueue_downloaded_summary), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefEnqueueDownloaded.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefEnqueueDownloaded.name, it).apply() }) + } + var showEnqueueLocationOptions by remember { mutableStateOf(false) } + var tempLocationOption by remember { mutableStateOf(EnqueueLocation.BACK.name) } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { showEnqueueLocationOptions = true })) { + Text(stringResource(R.string.pref_enqueue_location_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_enqueue_location_sum, tempLocationOption), color = textColor) + } + if (showEnqueueLocationOptions) { + AlertDialog(onDismissRequest = { showEnqueueLocationOptions = false }, + title = { Text(stringResource(R.string.pref_hardware_previous_button_title), style = MaterialTheme.typography.titleLarge) }, + text = { + Column { + EnqueueLocation.entries.forEach { option -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(2.dp) + .clickable { tempLocationOption = option.name }) { + Checkbox(checked = tempLocationOption == option.name, onCheckedChange = { tempLocationOption = option.name }) + Text(stringResource(option.res), modifier = Modifier.padding(start = 16.dp), style = MaterialTheme.typography.bodyMedium) + } + } + } + }, + confirmButton = { + TextButton(onClick = { + appPrefs.edit().putString(UserPreferences.Prefs.prefEnqueueLocation.name, tempLocationOption).apply() + showEnqueueLocationOptions = false + }) { Text(text = "OK") } + }, + dismissButton = { TextButton(onClick = { showEnqueueLocationOptions = false }) { Text(text = "Cancel") } } + ) + } + + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_followQueue_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_followQueue_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefFollowQueue.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefFollowQueue.name, it).apply() }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_skip_keeps_episodes_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_skip_keeps_episodes_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefSkipKeepsEpisode.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefSkipKeepsEpisode.name, it).apply() }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_mark_played_removes_from_queue_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_mark_played_removes_from_queue_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefRemoveFromQueueMarkedPlayed.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefRemoveFromQueueMarkedPlayed.name, it).apply() }) + } + } + } + } + } + } + + object VideoModeDialog { + fun showDialog(context: Context) { + val dialog = MaterialAlertDialogBuilder(context) + dialog.setTitle(context.getString(R.string.pref_playback_video_mode)) + dialog.setNegativeButton(android.R.string.cancel) { d: DialogInterface, _: Int -> d.dismiss() } + val selected = videoPlayMode + val entryValues = listOf(*context.resources.getStringArray(R.array.video_mode_options_values)) + val selectedIndex = entryValues.indexOf("" + selected) + val items = context.resources.getStringArray(R.array.video_mode_options) + dialog.setSingleChoiceItems(items, selectedIndex) { d: DialogInterface, which: Int -> + if (selectedIndex != which) setVideoMode(entryValues[which].toInt()) + d.dismiss() + } + dialog.show() + } + } + } + + class ImportExportPreferencesFragment : PreferenceFragmentCompat() { + + private val chooseOpmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> this.chooseOpmlExportPathResult(result) } + + private val chooseHtmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> this.chooseHtmlExportPathResult(result) } + + private val chooseFavoritesExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> this.chooseFavoritesExportPathResult(result) } + + private val chooseProgressExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> this.chooseProgressExportPathResult(result) } + + private val restoreProgressLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> this.restoreProgressResult(result) } + + private val restoreDatabaseLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> this.restoreDatabaseResult(result) } + + private val backupDatabaseLauncher = registerForActivityResult(BackupDatabase()) { uri: Uri? -> this.backupDatabaseResult(uri) } + + private val chooseOpmlImportPathLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { + uri: Uri? -> this.chooseOpmlImportPathResult(uri) } + + private val restorePreferencesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> this.restorePreferencesResult(result) } + + private val backupPreferencesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + val data: Uri? = it.data?.data + if (data != null) PreferencesTransporter.exportToDocument(data, requireContext()) + } + } + + private val restoreMediaFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> this.restoreMediaFilesResult(result) } + + private val backupMediaFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> this.exportMediaFilesResult(result) } + + private var showProgress by mutableStateOf(false) + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + (activity as PreferenceActivity).supportActionBar?.setTitle(R.string.import_export_pref) + return ComposeView(requireContext()).apply { + setContent { + CustomTheme(requireContext()) { + val textColor = MaterialTheme.colorScheme.onSurface + if (showProgress) { + Dialog(onDismissRequest = { showProgress = false }) { + Surface(modifier = Modifier.fillMaxSize(), shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator() + Text("Loading...", modifier = Modifier.align(Alignment.BottomCenter)) + } + } + } + } + val scrollState = rememberScrollState() + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) { + Text(stringResource(R.string.database), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + exportDatabase() + })) { + Text(stringResource(R.string.database_export_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.database_export_summary), color = textColor) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + importDatabase() + })) { + Text(stringResource(R.string.database_import_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.database_import_summary), color = textColor) + } + HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp)) + + Text(stringResource(R.string.media_files), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp)) + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + exportMediaFiles() + })) { + Text(stringResource(R.string.media_files_export_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.media_files_export_summary), color = textColor) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + importMediaFiles() + })) { + Text(stringResource(R.string.media_files_import_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.media_files_import_summary), color = textColor) + } + HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp)) + + Text(stringResource(R.string.preferences), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp)) + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + exportPreferences() + })) { + Text(stringResource(R.string.preferences_export_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.preferences_export_summary), color = textColor) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + importPreferences() + })) { + Text(stringResource(R.string.preferences_import_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.preferences_import_summary), color = textColor) + } + HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp)) + + Text(stringResource(R.string.opml), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp)) + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + openExportPathPicker(ExportTypes.OPML, chooseOpmlExportPathLauncher, OpmlWriter()) + })) { + Text(stringResource(R.string.opml_export_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.opml_export_summary), color = textColor) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + try { + chooseOpmlImportPathLauncher.launch("*/*") + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "No activity found. Should never happen...") + } + })) { + Text(stringResource(R.string.opml_import_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.opml_import_summary), color = textColor) + } + HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp)) + + Text(stringResource(R.string.progress), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp)) + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + openExportPathPicker(ExportTypes.PROGRESS, chooseProgressExportPathLauncher, EpisodesProgressWriter()) + })) { + Text(stringResource(R.string.progress_export_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.progress_export_summary), color = textColor) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + importEpisodeProgress() + })) { + Text(stringResource(R.string.progress_import_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.progress_import_summary), color = textColor) + } + HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp)) + + Text(stringResource(R.string.html), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp)) + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + openExportPathPicker(ExportTypes.HTML, chooseHtmlExportPathLauncher, HtmlWriter()) + })) { + Text(stringResource(R.string.html_export_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.html_export_summary), color = textColor) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + openExportPathPicker(ExportTypes.FAVORITES, chooseFavoritesExportPathLauncher, FavoritesWriter()) + })) { + Text(stringResource(R.string.favorites_export_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.favorites_export_summary), color = textColor) + } + } + } + } + } + } + + private fun dateStampFilename(fname: String): String { + return String.format(fname, SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())) + } + + private fun exportWithWriter(exportWriter: ExportWriter, uri: Uri?, exportType: ExportTypes) { + val context: Context? = activity + showProgress = true + if (uri == null) { + lifecycleScope.launch(Dispatchers.IO) { + try { + val output = ExportWorker(exportWriter, requireContext()).exportFile() + withContext(Dispatchers.Main) { + val fileUri = FileProvider.getUriForFile(context!!.applicationContext, context.getString(R.string.provider_authority), output!!) + showExportSuccessSnackbar(fileUri, exportType.contentType) + } + } catch (e: Exception) { showTransportErrorDialog(e) + } finally { showProgress = false } + } + } else { + lifecycleScope.launch(Dispatchers.IO) { + val worker = DocumentFileExportWorker(exportWriter, context!!, uri) + try { + val output = worker.exportFile() + withContext(Dispatchers.Main) { + showExportSuccessSnackbar(output.uri, exportType.contentType) + } + } catch (e: Exception) { showTransportErrorDialog(e) + } finally { showProgress = false } + } + } + } + + private fun exportPreferences() { + 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) + backupPreferencesLauncher.launch(intent) + } + + private fun importPreferences() { + val builder = MaterialAlertDialogBuilder(requireActivity()) + builder.setTitle(R.string.preferences_import_label) + builder.setMessage(R.string.preferences_import_warning) + + // add a button + builder.setNegativeButton(R.string.no, null) + builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.addCategory(Intent.CATEGORY_DEFAULT) + restorePreferencesLauncher.launch(intent) + } + + // create and show the alert dialog + builder.show() + } + + private fun exportMediaFiles() { + 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) + backupMediaFilesLauncher.launch(intent) + } + + private fun importMediaFiles() { + val builder = MaterialAlertDialogBuilder(requireActivity()) + builder.setTitle(R.string.media_files_import_label) + builder.setMessage(R.string.media_files_import_notice) + + // add a button + builder.setNegativeButton(R.string.no, null) + builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.addCategory(Intent.CATEGORY_DEFAULT) + restoreMediaFilesLauncher.launch(intent) + } + + // create and show the alert dialog + builder.show() + } + + private fun exportDatabase() { + backupDatabaseLauncher.launch(dateStampFilename("PodciniBackup-%s.realm")) + } + + private fun importDatabase() { + // setup the alert builder + val builder = MaterialAlertDialogBuilder(requireActivity()) + builder.setTitle(R.string.realm_database_import_label) + builder.setMessage(R.string.database_import_warning) + + // add a button + builder.setNegativeButton(R.string.no, null) + builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.setType("*/*") + intent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/octet-stream")) + intent.addCategory(Intent.CATEGORY_OPENABLE) + restoreDatabaseLauncher.launch(intent) + } + + // create and show the alert dialog + builder.show() + } + + private fun showImportSuccessDialog() { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle(R.string.successful_import_label) + builder.setMessage(R.string.import_ok) + builder.setCancelable(false) + builder.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> forceRestart() } + builder.show() + } + + private fun showExportSuccessSnackbar(uri: Uri?, mimeType: String?) { + Snackbar.make(requireView(), R.string.export_success_title, Snackbar.LENGTH_LONG) + .setAction(R.string.share_label) { IntentBuilder(requireContext()).setType(mimeType).addStream(uri!!).setChooserTitle(R.string.share_label).startChooser() } + .show() + } + + private fun showTransportErrorDialog(error: Throwable) { + showProgress = false + val alert = MaterialAlertDialogBuilder(requireContext()) + alert.setPositiveButton(android.R.string.ok) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + alert.setTitle(R.string.import_export_error_label) + alert.setMessage(error.message) + alert.show() + } + + private fun importEpisodeProgress() { + // setup the alert builder + val builder = MaterialAlertDialogBuilder(requireActivity()) + builder.setTitle(R.string.progress_import_label) + builder.setMessage(R.string.progress_import_warning) + + // add a button + builder.setNegativeButton(R.string.no, null) + builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.setType("*/*") + intent.addCategory(Intent.CATEGORY_OPENABLE) + restoreProgressLauncher.launch(intent) + } + // create and show the alert dialog + builder.show() + } + + private fun chooseProgressExportPathResult(result: ActivityResult) { + if (result.resultCode != RESULT_OK || result.data == null) return + val uri = result.data!!.data!! +// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0 +// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) + exportWithWriter(EpisodesProgressWriter(), uri, ExportTypes.PROGRESS) + } + + private fun chooseOpmlExportPathResult(result: ActivityResult) { + if (result.resultCode != RESULT_OK || result.data == null) return + val uri = result.data!!.data!! +// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0 +// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) + exportWithWriter(OpmlWriter(), uri, ExportTypes.OPML) + } + + private fun chooseHtmlExportPathResult(result: ActivityResult) { + if (result.resultCode != RESULT_OK || result.data == null) return + val uri = result.data!!.data!! +// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0 +// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) + exportWithWriter(HtmlWriter(), uri, ExportTypes.HTML) + } + + private fun chooseFavoritesExportPathResult(result: ActivityResult) { + if (result.resultCode != RESULT_OK || result.data == null) return + val uri = result.data!!.data!! +// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0 +// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) + exportWithWriter(FavoritesWriter(), uri, ExportTypes.FAVORITES) + } + + private fun restoreProgressResult(result: ActivityResult) { + if (result.resultCode != RESULT_OK || result.data?.data == null) return + val uri = result.data!!.data + uri?.let { +// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0 +// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) + if (isJsonFile(uri)) { + showProgress = true + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + val inputStream: InputStream? = requireContext().contentResolver.openInputStream(uri) + val reader = BufferedReader(InputStreamReader(inputStream)) + EpisodeProgressReader.readDocument(reader) + reader.close() + } + withContext(Dispatchers.Main) { + showImportSuccessDialog() + showProgress = false + } + } catch (e: Throwable) { showTransportErrorDialog(e) } + } + } else { + val context = requireContext() + val message = context.getString(R.string.import_file_type_toast) + ".json" + showTransportErrorDialog(Throwable(message)) + } + } + } + + private fun isJsonFile(uri: Uri): Boolean { + val fileName = uri.lastPathSegment ?: return false + return fileName.endsWith(".json", ignoreCase = true) + } + + private fun restoreDatabaseResult(result: ActivityResult) { + if (result.resultCode != RESULT_OK || result.data == null) return + val uri = result.data!!.data + uri?.let { +// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0 +// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) + if (isRealmFile(uri)) { + showProgress = true + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + DatabaseTransporter.importBackup(uri, requireContext()) + } + withContext(Dispatchers.Main) { + showImportSuccessDialog() + showProgress = false + } + } catch (e: Throwable) { showTransportErrorDialog(e) } + } + } else { + val context = requireContext() + val message = context.getString(R.string.import_file_type_toast) + ".realm" + showTransportErrorDialog(Throwable(message)) + } + } + } + + private fun isRealmFile(uri: Uri): Boolean { + val fileName = uri.lastPathSegment ?: return false + return fileName.endsWith(".realm", ignoreCase = true) + } + + private fun isPrefDir(uri: Uri): Boolean { + val fileName = uri.lastPathSegment ?: return false + return fileName.contains("Podcini-Prefs", ignoreCase = true) + } + + private fun isMediaFilesDir(uri: Uri): Boolean { + val fileName = uri.lastPathSegment ?: return false + return fileName.contains("Podcini-MediaFiles", ignoreCase = true) + } + + private fun restorePreferencesResult(result: ActivityResult) { + if (result.resultCode != RESULT_OK || result.data?.data == null) return + val uri = result.data!!.data!! +// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0 +// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) + if (isPrefDir(uri)) { + showProgress = true + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + PreferencesTransporter.importBackup(uri, requireContext()) + } + withContext(Dispatchers.Main) { + showImportSuccessDialog() + showProgress = false + } + } catch (e: Throwable) { showTransportErrorDialog(e) } + } + } else { + val context = requireContext() + val message = context.getString(R.string.import_directory_toast) + "Podcini-Prefs" + showTransportErrorDialog(Throwable(message)) + } + } + + private fun restoreMediaFilesResult(result: ActivityResult) { + if (result.resultCode != RESULT_OK || result.data?.data == null) return + val uri = result.data!!.data!! +// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0 +// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) + if (isMediaFilesDir(uri)) { + showProgress = true + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + MediaFilesTransporter.importBackup(uri, requireContext()) + } + withContext(Dispatchers.Main) { + showImportSuccessDialog() + showProgress = false + } + } catch (e: Throwable) { showTransportErrorDialog(e) } + } + } else { + val context = requireContext() + val message = context.getString(R.string.import_directory_toast) + "Podcini-MediaFiles" + showTransportErrorDialog(Throwable(message)) + } + } + + private fun exportMediaFilesResult(result: ActivityResult) { + if (result.resultCode != RESULT_OK || result.data?.data == null) return + val uri = result.data!!.data!! +// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0 +// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) + showProgress = true + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + MediaFilesTransporter.exportToDocument(uri, requireContext()) + } + withContext(Dispatchers.Main) { + showExportSuccessSnackbar(uri, null) + showProgress = false + } + } catch (e: Throwable) { showTransportErrorDialog(e) } + } + } + + private fun backupDatabaseResult(uri: Uri?) { + if (uri == null) return + showProgress = true + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + DatabaseTransporter.exportToDocument(uri, requireContext()) + } + withContext(Dispatchers.Main) { + showExportSuccessSnackbar(uri, "application/x-sqlite3") + showProgress = false + } + } catch (e: Throwable) { showTransportErrorDialog(e) } + } + } + + private fun chooseOpmlImportPathResult(uri: Uri?) { + if (uri == null) return + Logd(TAG, "chooseOpmlImportPathResult: uri: $uri") +// OpmlTransporter.startImport(requireContext(), uri) +// showImportSuccessDialog() + val intent = Intent(context, OpmlImportActivity::class.java) + intent.setData(uri) + startActivity(intent) + } + + private fun openExportPathPicker(exportType: ExportTypes, result: ActivityResultLauncher, writer: ExportWriter) { + val title = dateStampFilename(exportType.outputNameTemplate) + + val intentPickAction = Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(exportType.contentType) + .putExtra(Intent.EXTRA_TITLE, title) + + // Creates an implicit intent to launch a file manager which lets + // the user choose a specific directory to export to. + try { + result.launch(intentPickAction) + return + } catch (e: ActivityNotFoundException) { Log.e(TAG, "No activity found. Should never happen...") } + + // If we are using a SDK lower than API 21 or the implicit intent failed + // fallback to the legacy export process + exportWithWriter(writer, null, exportType) + } + + private class BackupDatabase : CreateDocument() { + override fun createIntent(context: Context, input: String): Intent { + return super.createIntent(context, input) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("application/x-sqlite3") + } + } + + object PreferencesTransporter { + private val TAG: String = PreferencesTransporter::class.simpleName ?: "Anonymous" + @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("Podcini-Prefs") ?: throw IOException("Error creating subdirectory Podcini-Prefs") + 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 -> +// val prefName = file.name.substring(0, file.name.lastIndexOf('.')) + 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!! +// contains info on existing widgets, no need to import + 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 { +// for debug version importing release version + BuildConfig.DEBUG && !destName.contains(".debug") -> destName = destName.replace("podcini.R", "podcini.R.debug") +// for release version importing debug version + !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 { } + + } + } + + object MediaFilesTransporter { + private val TAG: String = MediaFilesTransporter::class.simpleName ?: "Anonymous" + 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("Podcini-MediaFiles") ?: throw IOException("Error creating subdirectory Podcini-Prefs") + mediaDir.listFiles()?.forEach { file -> + copyRecursive(context, file, mediaDir, exportSubDir) + } + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { } + } + private fun copyRecursive(context: Context, srcFile: File, srcRootDir: File, destRootDir: DocumentFile) { + val relativePath = srcFile.absolutePath.substring(srcRootDir.absolutePath.length+1) + if (srcFile.isDirectory) { + val dirFiles = srcFile.listFiles() + if (!dirFiles.isNullOrEmpty()) { + val destDir = destRootDir.findFile(relativePath) ?: destRootDir.createDirectory(relativePath) ?: return + dirFiles.forEach { file -> + copyRecursive(context, file, srcFile, destDir) + } + } + } else { + val destFile = destRootDir.createFile("application/octet-stream", relativePath) ?: return + copyFile(srcFile, destFile, context) + } + } + private fun copyFile(sourceFile: File, destFile: DocumentFile, context: Context) { + try { + val outputStream = context.contentResolver.openOutputStream(destFile.uri) ?: return + val inputStream = FileInputStream(sourceFile) + copyStream(inputStream, outputStream) + inputStream.close() + outputStream.close() + } catch (e: IOException) { + Log.e("Error", "Error copying file: $e") + throw e + } + } + private fun copyRecursive(context: Context, srcFile: DocumentFile, srcRootDir: DocumentFile, destRootDir: File) { + val relativePath = srcFile.uri.path?.substring(srcRootDir.uri.path!!.length+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("Podcini-MediaFiles") != 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 + } + } + } + + object DatabaseTransporter { + private val TAG: String = DatabaseTransporter::class.simpleName ?: "Anonymous" + @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) } + } + } + + /** Reads OPML documents. */ + object EpisodeProgressReader { + private const 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!!.setLastPlayedTime(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) + } + } + + /** Writes saved favorites to file. */ + 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.favorite.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.getLastPlayedTime())) + .started(media.startPosition / 1000) + .position(media.getPosition() / 1000) + .playedDuration(media.playedDuration / 1000) + .total(media.getDuration() / 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" + } + companion object { + private const val TAG = "EpisodesProgressWriter" + } + } + + /** Writes saved favorites to file. */ + class FavoritesWriter : ExportWriter { + @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.favorite.name), EpisodeSortOrder.DATE_NEW_OLD) + val favoritesByFeed = buildFeedMap(allFavorites) + writer!!.append(templateParts[0]) + for (feedId in favoritesByFeed.keys) { + val favorites: List = favoritesByFeed[feedId]!! + 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!!) + .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" + } + companion object { + private val TAG: String = FavoritesWriter::class.simpleName ?: "Anonymous" + private const val FAVORITE_TEMPLATE = "html-export-favorites-item-template.html" + private const val FEED_TEMPLATE = "html-export-feed-template.html" + private const val UTF_8 = "UTF-8" + } + } + + /** Writes HTML documents. */ + 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" + } + companion object { + private val TAG: String = HtmlWriter::class.simpleName ?: "Anonymous" + } + } + + companion object { + private val TAG: String = ImportExportPreferencesFragment::class.simpleName ?: "Anonymous" + } + } + + class DownloadsPreferencesFragment : PreferenceFragmentCompat() { + private var blockAutoDeleteLocal = true + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.downloads_pref) + return ComposeView(requireContext()).apply { + setContent { + CustomTheme(requireContext()) { + val textColor = MaterialTheme.colorScheme.onSurface + val scrollState = rememberScrollState() + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) { + Text(stringResource(R.string.automation), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.feed_refresh_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + var interval by remember { mutableStateOf(appPrefs.getString(UserPreferences.Prefs.prefAutoUpdateIntervall.name, "12")!!) } + 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 = { + if (it.isEmpty() || it.toIntOrNull() != null) { + interval = it + showIcon = true + } + }, + trailingIcon = { + if (showIcon) Icon(imageVector = Icons.Filled.Settings, contentDescription = "Settings icon", + modifier = Modifier.size(30.dp).padding(start = 10.dp).clickable(onClick = { + if (interval.isEmpty()) interval = "0" + appPrefs.edit().putString(UserPreferences.Prefs.prefAutoUpdateIntervall.name, interval).apply() + showIcon = false + restartUpdateAlarm(requireContext(), true) + })) + }) + } + Text(stringResource(R.string.feed_refresh_sum), color = textColor) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + (activity as PreferenceActivity).openScreen(Screens.preferences_autodownload) + })) { + Text(stringResource(R.string.pref_automatic_download_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_automatic_download_sum), color = textColor) + } + 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_delete_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_auto_delete_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefAutoDelete.name, false)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefAutoDelete.name, it).apply() }) + } + 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_local_delete_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_auto_local_delete_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefAutoDeleteLocal.name, false)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + if (blockAutoDeleteLocal && it) { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.pref_auto_local_delete_dialog_body) + .setPositiveButton(R.string.yes) { _: DialogInterface?, _: Int -> + blockAutoDeleteLocal = false + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefAutoDeleteLocal.name, it).apply() +// (findPreference(Prefs.prefAutoDeleteLocal.name) as TwoStatePreference?)!!.isChecked = true + blockAutoDeleteLocal = true + } + .setNegativeButton(R.string.cancel_label, null) + .show() + } + }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_keeps_important_episodes_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_keeps_important_episodes_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefFavoriteKeepsEpisode.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefFavoriteKeepsEpisode.name, it).apply() }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_delete_removes_from_queue_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_delete_removes_from_queue_sum), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefDeleteRemovesFromQueue.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefDeleteRemovesFromQueue.name, it).apply() }) + } + Text(stringResource(R.string.download_pref_details), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 10.dp)) + var showMeteredNetworkOptions by remember { mutableStateOf(false) } + var tempSelectedOptions by remember { mutableStateOf(appPrefs.getStringSet(UserPreferences.Prefs.prefMobileUpdateTypes.name, setOf("images"))!!) } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { showMeteredNetworkOptions = true })) { + Text(stringResource(R.string.pref_metered_network_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_mobileUpdate_sum), color = textColor) + } + if (showMeteredNetworkOptions) { + AlertDialog(onDismissRequest = { showMeteredNetworkOptions = false }, + title = { Text(stringResource(R.string.pref_metered_network_title), style = MaterialTheme.typography.titleLarge) }, + text = { + Column { + MobileUpdateOptions.entries.forEach { option -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(2.dp) + .clickable { + tempSelectedOptions = if (tempSelectedOptions.contains(option.name)) tempSelectedOptions - option.name + else tempSelectedOptions + option.name + }) { + Checkbox(checked = tempSelectedOptions.contains(option.name), + onCheckedChange = { + tempSelectedOptions = if (tempSelectedOptions.contains(option.name)) tempSelectedOptions - option.name + else tempSelectedOptions + option.name + }) + Text(stringResource(option.res), modifier = Modifier.padding(start = 16.dp), style = MaterialTheme.typography.bodyMedium) + } + } + } + }, + confirmButton = { + TextButton(onClick = { + appPrefs.edit().putStringSet(UserPreferences.Prefs.prefMobileUpdateTypes.name, tempSelectedOptions).apply() + showMeteredNetworkOptions = false + }) { Text(text = "OK") } + }, + dismissButton = { TextButton(onClick = { showMeteredNetworkOptions = false }) { Text(text = "Cancel") } } + ) + } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { + ProxyDialog(requireContext()).show() + })) { + Text(stringResource(R.string.pref_proxy_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_proxy_sum), color = textColor) + } + } + } + } + } + } + + enum class MobileUpdateOptions(val res: Int) { + feed_refresh(R.string.pref_mobileUpdate_refresh), + episode_download(R.string.pref_mobileUpdate_episode_download), + auto_download(R.string.pref_mobileUpdate_auto_download), + streaming(R.string.pref_mobileUpdate_streaming), + images(R.string.pref_mobileUpdate_images), + sync(R.string.synchronization_pref); + } + + class ProxyDialog(private val context: Context) { + private lateinit var dialog: AlertDialog + private lateinit var spType: Spinner + private lateinit var etHost: EditText + private lateinit var etPort: EditText + private lateinit var etUsername: EditText + private lateinit var etPassword: EditText + private lateinit var txtvMessage: TextView + private var testSuccessful = false + private val port: Int + get() { + val port = etPort.text.toString() + if (port.isNotEmpty()) try { return port.toInt() } catch (e: NumberFormatException) { } + return 0 + } + + fun show(): Dialog { + val content = View.inflate(context, R.layout.proxy_settings, null) + val binding = ProxySettingsBinding.bind(content) + spType = binding.spType + dialog = MaterialAlertDialogBuilder(context) + .setTitle(R.string.pref_proxy_title) + .setView(content) + .setNegativeButton(R.string.cancel_label, null) + .setPositiveButton(R.string.proxy_test_label, null) + .setNeutralButton(R.string.reset, null) + .show() + // To prevent cancelling the dialog on button click + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { + if (!testSuccessful) { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + test() + return@setOnClickListener + } + setProxyConfig() + reinit() + dialog.dismiss() + } + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { + etHost.text.clear() + etPort.text.clear() + etUsername.text.clear() + etPassword.text.clear() + setProxyConfig() + } + val types: MutableList = ArrayList() + types.add(Proxy.Type.DIRECT.name) + types.add(Proxy.Type.HTTP.name) + types.add(Proxy.Type.SOCKS.name) + val adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, types) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + spType.setAdapter(adapter) + val proxyConfig = proxyConfig + spType.setSelection(adapter.getPosition(proxyConfig.type.name)) + etHost = binding.etHost + if (!proxyConfig.host.isNullOrEmpty()) etHost.setText(proxyConfig.host) + etHost.addTextChangedListener(requireTestOnChange) + etPort = binding.etPort + if (proxyConfig.port > 0) etPort.setText(proxyConfig.port.toString()) + etPort.addTextChangedListener(requireTestOnChange) + etUsername = binding.etUsername + if (!proxyConfig.username.isNullOrEmpty()) etUsername.setText(proxyConfig.username) + etUsername.addTextChangedListener(requireTestOnChange) + etPassword = binding.etPassword + if (!proxyConfig.password.isNullOrEmpty()) etPassword.setText(proxyConfig.password) + etPassword.addTextChangedListener(requireTestOnChange) + if (proxyConfig.type == Proxy.Type.DIRECT) { + enableSettings(false) + setTestRequired(false) + } + spType.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) { + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).visibility = if (position == 0) View.GONE else View.VISIBLE + enableSettings(position > 0) + setTestRequired(position > 0) + } + override fun onNothingSelected(parent: AdapterView<*>?) { + enableSettings(false) + } + } + txtvMessage = binding.txtvMessage + checkValidity() + return dialog + } + private fun setProxyConfig() { + val type = spType.selectedItem as String + val typeEnum = Proxy.Type.valueOf(type) + val host = etHost.text.toString() + val port = etPort.text.toString() + var username: String? = etUsername.text.toString() + if (username.isNullOrEmpty()) username = null + var password: String? = etPassword.text.toString() + if (password.isNullOrEmpty()) password = null + var portValue = 0 + if (port.isNotEmpty()) portValue = port.toInt() + val config = ProxyConfig(typeEnum, host, portValue, username, password) + proxyConfig = config + PodciniHttpClient.setProxyConfig(config) + } + private val requireTestOnChange: TextWatcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable) { + setTestRequired(true) + } + } + private fun enableSettings(enable: Boolean) { + etHost.isEnabled = enable + etPort.isEnabled = enable + etUsername.isEnabled = enable + etPassword.isEnabled = enable + } + private fun checkValidity(): Boolean { + var valid = true + if (spType.selectedItemPosition > 0) valid = checkHost() + valid = valid and checkPort() + return valid + } + private fun checkHost(): Boolean { + val host = etHost.text.toString() + if (host.isEmpty()) { + etHost.error = context.getString(R.string.proxy_host_empty_error) + return false + } + if ("localhost" != host && !Patterns.DOMAIN_NAME.matcher(host).matches()) { + etHost.error = context.getString(R.string.proxy_host_invalid_error) + return false + } + return true + } + private fun checkPort(): Boolean { + val port = port + if (port < 0 || port > 65535) { + etPort.error = context.getString(R.string.proxy_port_invalid_error) + return false + } + return true + } + private fun setTestRequired(required: Boolean) { + if (required) { + testSuccessful = false + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.proxy_test_label) + } else { + testSuccessful = true + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(android.R.string.ok) + } + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true + } + private fun test() { + if (!checkValidity()) { + setTestRequired(true) + return + } + val res = context.theme.obtainStyledAttributes(intArrayOf(android.R.attr.textColorPrimary)) + val textColorPrimary = res.getColor(0, 0) + res.recycle() + val checking = context.getString(R.string.proxy_checking) + txtvMessage.setTextColor(textColorPrimary) + txtvMessage.text = "{faw_circle_o_notch spin} $checking" + txtvMessage.visibility = View.VISIBLE + val coroutineScope = CoroutineScope(Dispatchers.Main) + coroutineScope.launch(Dispatchers.IO) { + try { + val type = spType.selectedItem as String + val host = etHost.text.toString() + val port = etPort.text.toString() + val username = etUsername.text.toString() + val password = etPassword.text.toString() + var portValue = 8080 + if (port.isNotEmpty()) portValue = port.toInt() + val address: SocketAddress = InetSocketAddress.createUnresolved(host, portValue) + val proxyType = Proxy.Type.valueOf(type.uppercase()) + val builder: OkHttpClient.Builder = newBuilder().connectTimeout(10, TimeUnit.SECONDS).proxy(Proxy(proxyType, address)) + if (username.isNotEmpty()) { + builder.proxyAuthenticator { _: Route?, response: Response -> + val credentials = basic(username, password) + response.request.newBuilder().header("Proxy-Authorization", credentials).build() + } + } + val client: OkHttpClient = builder.build() + val request: Request = Builder().url("https://www.example.com").head().build() + try { + client.newCall(request).execute().use { response -> if (!response.isSuccessful) throw IOException(response.message) } + } catch (e: IOException) { throw e } + withContext(Dispatchers.Main) { + txtvMessage.setTextColor(getColorFromAttr(context, R.attr.icon_green)) + val message = String.format("%s %s", "{faw_check}", context.getString(R.string.proxy_test_successful)) + txtvMessage.text = message + setTestRequired(false) + } + } catch (e: Throwable) { + e.printStackTrace() + txtvMessage.setTextColor(getColorFromAttr(context, R.attr.icon_red)) + val message = String.format("%s %s: %s", "{faw_close}", context.getString(R.string.proxy_test_failed), e.message) + txtvMessage.text = message + setTestRequired(true) + } + } + } + } + } + + class AutoDownloadPreferencesFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} + + enum class EpisodeCleanupOptions(val res: Int, val num: Int) { + ExceptFavorites(R.string.episode_cleanup_except_favorite, -3), + Never(R.string.episode_cleanup_never, -2), + NotInQueue(R.string.episode_cleanup_not_in_queue, -1), + LimitBy(R.string.episode_cleanup_limit_by, 0) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.pref_automatic_download_title) + return ComposeView(requireContext()).apply { + setContent { + CustomTheme(requireContext()) { + val textColor = MaterialTheme.colorScheme.onSurface + val scrollState = rememberScrollState() + 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)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_automatic_download_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_automatic_download_sum), color = textColor) + } + Switch(checked = isEnabled, onCheckedChange = { + isEnabled = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefEnableAutoDl.name, it).apply() + }) + } + if (isEnabled) { + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.pref_episode_cache_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + var interval by remember { mutableStateOf(appPrefs.getString(UserPreferences.Prefs.prefEpisodeCacheSize.name, "25")!!) } + var showIcon by remember { mutableStateOf(false) } + TextField(value = interval, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), label = { Text("integer") }, + singleLine = true, modifier = Modifier.weight(0.5f), + onValueChange = { + if (it.isEmpty() || it.toIntOrNull() != null) { + interval = it + showIcon = true + } + }, + trailingIcon = { + if (showIcon) Icon(imageVector = Icons.Filled.Settings, contentDescription = "Settings icon", + modifier = Modifier.size(30.dp).padding(start = 10.dp).clickable(onClick = { + if (interval.isEmpty()) interval = "0" + appPrefs.edit().putString(UserPreferences.Prefs.prefEpisodeCacheSize.name, interval).apply() + showIcon = false + })) + }) + } + Text(stringResource(R.string.pref_episode_cache_summary), color = textColor) + } + var showCleanupOptions by remember { mutableStateOf(false) } + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { showCleanupOptions = true })) { + Text(stringResource(R.string.pref_episode_cleanup_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_episode_cleanup_summary), color = textColor) + } + if (showCleanupOptions) { + var tempCleanupOption by remember { mutableStateOf(appPrefs.getString(UserPreferences.Prefs.prefEpisodeCleanup.name, "-1")!!) } + var interval by remember { mutableStateOf(appPrefs.getString(UserPreferences.Prefs.prefEpisodeCleanup.name, "-1")!!) } + if ((interval.toIntOrNull() ?: -1) > 0) tempCleanupOption = EpisodeCleanupOptions.LimitBy.num.toString() + AlertDialog(onDismissRequest = { showCleanupOptions = false }, + title = { Text(stringResource(R.string.pref_episode_cleanup_title), style = MaterialTheme.typography.titleLarge) }, + text = { + Column { + EpisodeCleanupOptions.entries.forEach { option -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(2.dp) + .clickable { tempCleanupOption = option.num.toString() }) { + Checkbox(checked = tempCleanupOption == option.num.toString(), onCheckedChange = { tempCleanupOption = option.num.toString() }) + Text(stringResource(option.res), modifier = Modifier.padding(start = 16.dp), style = MaterialTheme.typography.bodyMedium) + } + } + if (tempCleanupOption == EpisodeCleanupOptions.LimitBy.num.toString()) { + TextField(value = interval, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), label = { Text("integer") }, singleLine = true, + onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) interval = it }) + } + } + }, + confirmButton = { + TextButton(onClick = { + var num = if (tempCleanupOption == EpisodeCleanupOptions.LimitBy.num.toString()) interval else tempCleanupOption + if (num.toIntOrNull() == null) num = EpisodeCleanupOptions.Never.num.toString() + appPrefs.edit().putString(UserPreferences.Prefs.prefEpisodeCleanup.name, num).apply() + showCleanupOptions = false + }) { Text(text = "OK") } + }, + dismissButton = { TextButton(onClick = { showCleanupOptions = false }) { Text(text = "Cancel") } } + ) + } + + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefEnableAutoDownloadOnBattery.name, true)) } + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.pref_automatic_download_on_battery_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_automatic_download_on_battery_sum), color = textColor) + } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefEnableAutoDownloadOnBattery.name, it).apply() + }) + } + } + } + } + } + } + } + } + + class SynchronizationPreferencesFragment : PreferenceFragmentCompat() { + + var selectedProvider by mutableStateOf(SynchronizationProviderViewData.fromIdentifier(selectedSyncProviderKey)) + var loggedIn by mutableStateOf(isProviderConnected) + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.synchronization_pref) + + return ComposeView(requireContext()).apply { + setContent { + CustomTheme(requireContext()) { + val textColor = MaterialTheme.colorScheme.onSurface + val scrollState = rememberScrollState() + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.wifi_sync), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + WifiAuthenticationFragment().show(childFragmentManager, WifiAuthenticationFragment.TAG) + })) { + Text(stringResource(R.string.wifi_sync), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.wifi_sync_summary_unchoosen), color = textColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + var titleRes by remember { mutableStateOf(0) } + var summaryRes by remember { mutableIntStateOf(R.string.synchronization_summary_unchoosen) } + var iconRes by remember { mutableIntStateOf(R.drawable.ic_notification_sync) } + var onClick: (() -> Unit)? = null + if (loggedIn) { + selectedProvider = SynchronizationProviderViewData.fromIdentifier(selectedSyncProviderKey) + if (selectedProvider != null) { + summaryRes = selectedProvider!!.summaryResource + iconRes = selectedProvider!!.iconResource + } + } else { + titleRes = R.string.synchronization_choose_title + summaryRes = R.string.synchronization_summary_unchoosen + iconRes = R.drawable.ic_cloud + onClick = { chooseProviderAndLogin() } + } + Icon(imageVector = ImageVector.vectorResource(iconRes), contentDescription = "", tint = textColor, modifier = Modifier.size(40.dp).padding(end = 15.dp)) + Column(modifier = Modifier.weight(1f).clickable(onClick = { + onClick?.invoke() + })) { + Text(stringResource(titleRes), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(summaryRes), color = textColor) + } + } + if (isProviderSelected(SynchronizationProviderViewData.GPODDER_NET)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f).clickable(onClick = { + val dialog: AuthenticationDialog = object : AuthenticationDialog(requireContext(), R.string.pref_gpodnet_setlogin_information_title, + false, SynchronizationCredentials.username, null) { + override fun onConfirmed(username: String, password: String) { + SynchronizationCredentials.password = password + } + } + dialog.show() + })) { + Text(stringResource(R.string.pref_gpodnet_setlogin_information_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.pref_gpodnet_setlogin_information_sum), color = textColor) + } + } + } + if (loggedIn) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f).clickable(onClick = { + SyncService.syncImmediately(requireActivity().applicationContext) + })) { + Text(stringResource(R.string.synchronization_sync_changes_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.synchronization_sync_summary), color = textColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f).clickable(onClick = { + SyncService.fullSync(requireContext()) + })) { + Text(stringResource(R.string.synchronization_full_sync_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.synchronization_force_sync_summary), color = textColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f).clickable(onClick = { + SynchronizationCredentials.clear(requireContext()) + Snackbar.make(requireView(), R.string.pref_synchronization_logout_toast, Snackbar.LENGTH_LONG).show() + setSelectedSyncProvider(null) + loggedIn = isProviderConnected + })) { + Text(stringResource(R.string.synchronization_logout), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + } + } + } + } + } + } + } + + override fun onStart() { + super.onStart() + procFlowEvents() + } + + override fun onStop() { + super.onStop() + cancelFlowEvents() + (activity as PreferenceActivity).supportActionBar!!.subtitle = "" + } + + private var eventSink: Job? = null + private fun cancelFlowEvents() { + eventSink?.cancel() + eventSink = null + } + private fun procFlowEvents() { + if (eventSink != null) return + eventSink = lifecycleScope.launch { + EventFlow.events.collectLatest { event -> + Logd("SynchronizationPreferencesFragment", "Received event: ${event.TAG}") + when (event) { + is FlowEvent.SyncServiceEvent -> syncStatusChanged(event) + else -> {} + } + } + } + } + + fun syncStatusChanged(event: FlowEvent.SyncServiceEvent) { + if (!isProviderConnected && !wifiSyncEnabledKey) return + loggedIn = isProviderConnected +// updateScreen() + if (event.messageResId == R.string.sync_status_error || event.messageResId == R.string.sync_status_success) + updateLastSyncReport(SynchronizationSettings.isLastSyncSuccessful, SynchronizationSettings.lastSyncAttempt) + else (activity as PreferenceActivity).supportActionBar!!.setSubtitle(event.messageResId) + } + +// private fun updateScreen() { +// val preferenceInstantSync = findPreference(Prefs.preference_instant_sync.name) +// preferenceInstantSync!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { +// WifiAuthenticationFragment().show(childFragmentManager, WifiAuthenticationFragment.TAG) +// true +// } +// +// val loggedIn = isProviderConnected +// val preferenceHeader = findPreference(Prefs.preference_synchronization_description.name) +// if (loggedIn) { +// val selectedProvider = SynchronizationProviderViewData.fromIdentifier(selectedSyncProviderKey) +// preferenceHeader!!.title = "" +// if (selectedProvider != null) { +// preferenceHeader.setSummary(selectedProvider.summaryResource) +// preferenceHeader.setIcon(selectedProvider.iconResource) +// } +// preferenceHeader.onPreferenceClickListener = null +// } else { +// preferenceHeader!!.setTitle(R.string.synchronization_choose_title) +// preferenceHeader.setSummary(R.string.synchronization_summary_unchoosen) +// preferenceHeader.setIcon(R.drawable.ic_cloud) +// preferenceHeader.onPreferenceClickListener = Preference.OnPreferenceClickListener { +// chooseProviderAndLogin() +// true +// } +// } +// +// val gpodnetSetLoginPreference = findPreference(Prefs.pref_gpodnet_setlogin_information.name) +// gpodnetSetLoginPreference!!.isVisible = isProviderSelected(SynchronizationProviderViewData.GPODDER_NET) +// gpodnetSetLoginPreference.isEnabled = loggedIn +// findPreference(Prefs.pref_synchronization_sync.name)!!.isVisible = loggedIn +// findPreference(Prefs.pref_synchronization_force_full_sync.name)!!.isVisible = loggedIn +// findPreference(Prefs.pref_synchronization_logout.name)!!.isVisible = loggedIn +// if (loggedIn) { +// val summary = getString(R.string.synchronization_login_status, +// SynchronizationCredentials.username, SynchronizationCredentials.hosturl) +// val formattedSummary = HtmlCompat.fromHtml(summary, HtmlCompat.FROM_HTML_MODE_LEGACY) +// findPreference(Prefs.pref_synchronization_logout.name)!!.summary = formattedSummary +// updateLastSyncReport(SynchronizationSettings.isLastSyncSuccessful, SynchronizationSettings.lastSyncAttempt) +// } else { +// findPreference(Prefs.pref_synchronization_logout.name)?.summary = "" +// (activity as PreferenceActivity).supportActionBar?.setSubtitle("") +// } +// } + + private fun chooseProviderAndLogin() { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle(R.string.dialog_choose_sync_service_title) + + val providers = SynchronizationProviderViewData.entries.toTypedArray() + val adapter: ListAdapter = object : ArrayAdapter(requireContext(), R.layout.alertdialog_sync_provider_chooser, providers) { + var holder: ViewHolder? = null + + inner class ViewHolder { + var icon: ImageView? = null + var title: TextView? = null + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + var convertView = convertView + val inflater = LayoutInflater.from(context) + if (convertView == null) { + convertView = inflater.inflate(R.layout.alertdialog_sync_provider_chooser, null) + val binding = AlertdialogSyncProviderChooserBinding.bind(convertView) + holder = ViewHolder() + if (holder != null) { + holder!!.icon = binding.icon + holder!!.title = binding.title + convertView.tag = holder + } + } else holder = convertView.tag as ViewHolder + + val synchronizationProviderViewData = getItem(position) + holder!!.title!!.setText(synchronizationProviderViewData!!.summaryResource) + holder!!.icon!!.setImageResource(synchronizationProviderViewData.iconResource) + return convertView!! + } + } + + builder.setAdapter(adapter) { _: DialogInterface?, which: Int -> + when (providers[which]) { + SynchronizationProviderViewData.GPODDER_NET -> GpodderAuthenticationFragment().show(childFragmentManager, + GpodderAuthenticationFragment.TAG) + SynchronizationProviderViewData.NEXTCLOUD_GPODDER -> NextcloudAuthenticationFragment().show(childFragmentManager, + NextcloudAuthenticationFragment.TAG) + } +// updateScreen() + loggedIn = isProviderConnected + } + + builder.show() + } + + private fun isProviderSelected(provider: SynchronizationProviderViewData): Boolean { + val selectedSyncProviderKey = selectedSyncProviderKey + return provider.identifier == selectedSyncProviderKey + } + + private val selectedSyncProviderKey: String + get() = SynchronizationSettings.selectedSyncProviderKey?:"" + + private fun updateLastSyncReport(successful: Boolean, lastTime: Long) { + val status = String.format("%1\$s (%2\$s)", getString(if (successful) R.string.gpodnetsync_pref_report_successful else R.string.gpodnetsync_pref_report_failed), + DateUtils.getRelativeDateTimeString(context, lastTime, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, DateUtils.FORMAT_SHOW_TIME)) + (activity as PreferenceActivity).supportActionBar!!.subtitle = status + } + + /** + * Displays a dialog with a username and password text field and an optional checkbox to save username and preferences. + */ + abstract class AuthenticationDialog(context: Context, titleRes: Int, enableUsernameField: Boolean, usernameInitialValue: String?, passwordInitialValue: String?) + : MaterialAlertDialogBuilder(context) { + + var passwordHidden: Boolean = true + + init { + setTitle(titleRes) + val viewBinding = AuthenticationDialogBinding.inflate(LayoutInflater.from(context)) + setView(viewBinding.root) + + viewBinding.usernameEditText.isEnabled = enableUsernameField + if (usernameInitialValue != null) viewBinding.usernameEditText.setText(usernameInitialValue) + if (passwordInitialValue != null) viewBinding.passwordEditText.setText(passwordInitialValue) + + viewBinding.showPasswordButton.setOnClickListener { + if (passwordHidden) { + viewBinding.passwordEditText.transformationMethod = HideReturnsTransformationMethod.getInstance() + viewBinding.showPasswordButton.alpha = 1.0f + } else { + viewBinding.passwordEditText.transformationMethod = PasswordTransformationMethod.getInstance() + viewBinding.showPasswordButton.alpha = 0.6f + } + passwordHidden = !passwordHidden + } + + setOnCancelListener { onCancelled() } + setNegativeButton(R.string.cancel_label) { _: DialogInterface?, _: Int -> onCancelled() } + setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> + onConfirmed(viewBinding.usernameEditText.text.toString(), viewBinding.passwordEditText.text.toString()) + } + } + + protected open fun onCancelled() {} + + protected abstract fun onConfirmed(username: String, password: String) + } + + /** + * Guides the user through the authentication process. + */ + class NextcloudAuthenticationFragment : DialogFragment(), NextcloudLoginFlow.AuthenticationCallback { + private var binding: NextcloudAuthDialogBinding? = null + private var nextcloudLoginFlow: NextcloudLoginFlow? = null + private var shouldDismiss = false + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = MaterialAlertDialogBuilder(requireContext()) + dialog.setTitle(R.string.gpodnetauth_login_butLabel) + dialog.setNegativeButton(R.string.cancel_label, null) + dialog.setCancelable(false) + this.isCancelable = false + + binding = NextcloudAuthDialogBinding.inflate(layoutInflater) + dialog.setView(binding!!.root) + + binding!!.chooseHostButton.setOnClickListener { + nextcloudLoginFlow = NextcloudLoginFlow(getHttpClient(), binding!!.serverUrlText.text.toString(), requireContext(), this) + startLoginFlow() + } + if (savedInstanceState?.getStringArrayList(EXTRA_LOGIN_FLOW) != null) { + nextcloudLoginFlow = NextcloudLoginFlow.fromInstanceState(getHttpClient(), requireContext(), this, + savedInstanceState.getStringArrayList(EXTRA_LOGIN_FLOW)!!) + startLoginFlow() + } + return dialog.create() + } + private fun startLoginFlow() { + binding!!.errorText.visibility = View.GONE + binding!!.chooseHostButton.visibility = View.GONE + binding!!.loginProgressContainer.visibility = View.VISIBLE + binding!!.serverUrlText.isEnabled = false + nextcloudLoginFlow!!.start() + } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (nextcloudLoginFlow != null) outState.putStringArrayList(EXTRA_LOGIN_FLOW, nextcloudLoginFlow!!.saveInstanceState()) + } + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + nextcloudLoginFlow?.cancel() + } + override fun onResume() { + super.onResume() + nextcloudLoginFlow?.onResume() + + if (shouldDismiss) dismiss() + } + override fun onNextcloudAuthenticated(server: String, username: String, password: String) { + setSelectedSyncProvider(SynchronizationProviderViewData.NEXTCLOUD_GPODDER) + SynchronizationCredentials.clear(requireContext()) + SynchronizationCredentials.password = password + SynchronizationCredentials.hosturl = server + SynchronizationCredentials.username = username + SyncService.fullSync(requireContext()) + if (isResumed) dismiss() + else shouldDismiss = true + } + override fun onNextcloudAuthError(errorMessage: String?) { + binding!!.loginProgressContainer.visibility = View.GONE + binding!!.errorText.visibility = View.VISIBLE + binding!!.errorText.text = errorMessage + binding!!.chooseHostButton.visibility = View.VISIBLE + binding!!.serverUrlText.isEnabled = true + } + + companion object { + val TAG = NextcloudAuthenticationFragment::class.simpleName ?: "Anonymous" + private const val EXTRA_LOGIN_FLOW = "LoginFlow" + } + } + + /** + * Guides the user through the authentication process. + */ + class GpodderAuthenticationFragment : DialogFragment() { + private var viewFlipper: ViewFlipper? = null + private var currentStep = -1 + private var service: GpodnetService? = null + @Volatile + private var username: String? = null + @Volatile + private var password: String? = null + @Volatile + private var selectedDevice: GpodnetDevice? = null + private var devices: List? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = MaterialAlertDialogBuilder(requireContext()) + dialog.setTitle(R.string.gpodnetauth_login_butLabel) + dialog.setNegativeButton(R.string.cancel_label, null) + dialog.setCancelable(false) + this.isCancelable = false + val binding = GpodnetauthDialogBinding.inflate(layoutInflater) +// val root = View.inflate(context, R.layout.gpodnetauth_dialog, null) + viewFlipper = binding.viewflipper + advance() + dialog.setView(binding.root) + + return dialog.create() + } + private fun setupHostView(view: View) { + val binding = GpodnetauthHostBinding.bind(view) + val selectHost = binding.chooseHostButton + val serverUrlText = binding.serverUrlText + selectHost.setOnClickListener { + if (serverUrlText.text.isNullOrEmpty()) return@setOnClickListener + + SynchronizationCredentials.clear(requireContext()) + SynchronizationCredentials.hosturl = serverUrlText.text.toString() + service = GpodnetService(getHttpClient(), SynchronizationCredentials.hosturl, SynchronizationCredentials.deviceID?:"", + SynchronizationCredentials.username?:"", SynchronizationCredentials.password?:"") + dialog?.setTitle(SynchronizationCredentials.hosturl) + advance() + } + } + private fun setupLoginView(view: View) { + val binding = GpodnetauthCredentialsBinding.bind(view) + val username = binding.etxtUsername + val password = binding.etxtPassword + val login = binding.butLogin + val txtvError = binding.credentialsError + val progressBar = binding.progBarLogin + val createAccountWarning = binding.createAccountWarning + + if (SynchronizationCredentials.hosturl != null && SynchronizationCredentials.hosturl!!.startsWith("http://")) + createAccountWarning.visibility = View.VISIBLE + + password.setOnEditorActionListener { _: TextView?, actionID: Int, _: KeyEvent? -> actionID == EditorInfo.IME_ACTION_GO && login.performClick() } + + login.setOnClickListener { + val usernameStr = username.text.toString() + val passwordStr = password.text.toString() + + if (usernameHasUnwantedChars(usernameStr)) { + txtvError.setText(R.string.gpodnetsync_username_characters_error) + txtvError.visibility = View.VISIBLE + return@setOnClickListener + } + + login.isEnabled = false + progressBar.visibility = View.VISIBLE + txtvError.visibility = View.GONE + val inputManager = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputManager.hideSoftInputFromWindow(login.windowToken, InputMethodManager.HIDE_NOT_ALWAYS) + + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + service?.setCredentials(usernameStr, passwordStr) + service?.login() + if (service != null) devices = service!!.devices + this@GpodderAuthenticationFragment.username = usernameStr + this@GpodderAuthenticationFragment.password = passwordStr + } + withContext(Dispatchers.Main) { + login.isEnabled = true + progressBar.visibility = View.GONE + advance() + } + } catch (e: Throwable) { + login.isEnabled = true + progressBar.visibility = View.GONE + txtvError.text = e.cause!!.message + txtvError.visibility = View.VISIBLE + } + } + } + } + private fun setupDeviceView(view: View) { + val binding = GpodnetauthDeviceBinding.bind(view) + val deviceName = binding.deviceName + val devicesContainer = binding.devicesContainer + deviceName.setText(generateDeviceName()) + + val createDeviceButton = binding.createDeviceButton + createDeviceButton.setOnClickListener { createDevice(view) } + + for (device in devices!!) { + val rBinding = GpodnetauthDeviceRowBinding.inflate(layoutInflater) +// val row = View.inflate(context, R.layout.gpodnetauth_device_row, null) + val selectDeviceButton = rBinding.selectDeviceButton + selectDeviceButton.setOnClickListener { + selectedDevice = device + advance() + } + selectDeviceButton.text = device.caption + devicesContainer.addView(rBinding.root) + } + } + private fun createDevice(view: View) { + val binding = GpodnetauthDeviceBinding.bind(view) + val deviceName = binding.deviceName + val txtvError = binding.deviceSelectError + val progBarCreateDevice = binding.progbarCreateDevice + + val deviceNameStr = deviceName.text.toString() + if (isDeviceInList(deviceNameStr)) return + + progBarCreateDevice.visibility = View.VISIBLE + txtvError.visibility = View.GONE + deviceName.isEnabled = false + + lifecycleScope.launch { + try { + val device = withContext(Dispatchers.IO) { + val deviceId = generateDeviceId(deviceNameStr) + service!!.configureDevice(deviceId, deviceNameStr, GpodnetDevice.DeviceType.MOBILE) + GpodnetDevice(deviceId, deviceNameStr, GpodnetDevice.DeviceType.MOBILE.toString(), 0) + } + withContext(Dispatchers.Main) { + progBarCreateDevice.visibility = View.GONE + selectedDevice = device + advance() + } + } catch (e: Throwable) { + deviceName.isEnabled = true + progBarCreateDevice.visibility = View.GONE + txtvError.text = e.message + txtvError.visibility = View.VISIBLE + } + } + } + private fun generateDeviceName(): String { + val baseName = getString(R.string.gpodnetauth_device_name_default, Build.MODEL) + var name = baseName + var num = 1 + while (isDeviceInList(name)) { + name = "$baseName ($num)" + num++ + } + return name + } + private fun generateDeviceId(name: String): String { + // devices names must be of a certain form: + // https://gpoddernet.readthedocs.org/en/latest/api/reference/general.html#devices + return generateFileName(name).replace("\\W".toRegex(), "_").lowercase() + } + private fun isDeviceInList(name: String): Boolean { + if (devices == null) return false + + val id = generateDeviceId(name) + for (device in devices!!) { + if (device.id == id || device.caption == name) return true + } + return false + } + private fun setupFinishView(view: View) { + val binding = GpodnetauthFinishBinding.bind(view) + val sync = binding.butSyncNow + + sync.setOnClickListener { + dismiss() + SyncService.sync(requireContext()) + } + } + private fun advance() { + if (currentStep < STEP_FINISH) { + val view = viewFlipper!!.getChildAt(currentStep + 1) + when (currentStep) { + STEP_DEFAULT -> setupHostView(view) + STEP_HOSTNAME -> setupLoginView(view) + STEP_LOGIN -> { + check(!(username == null || password == null)) { "Username and password must not be null here" } + setupDeviceView(view) + } + STEP_DEVICE -> { + checkNotNull(selectedDevice) { "Device must not be null here" } + setSelectedSyncProvider(SynchronizationProviderViewData.GPODDER_NET) + SynchronizationCredentials.username = username + SynchronizationCredentials.password = password + SynchronizationCredentials.deviceID = selectedDevice!!.id + setupFinishView(view) + } + } + if (currentStep != STEP_DEFAULT) viewFlipper!!.showNext() + currentStep++ + } else dismiss() + } + private fun usernameHasUnwantedChars(username: String): Boolean { + val special = Pattern.compile("[!@#$%&*()+=|<>?{}\\[\\]~]") + val containsUnwantedChars = special.matcher(username) + return containsUnwantedChars.find() + } + + companion object { + val TAG = GpodderAuthenticationFragment::class.simpleName ?: "Anonymous" + + private const val STEP_DEFAULT = -1 + private const val STEP_HOSTNAME = 0 + private const val STEP_LOGIN = 1 + private const val STEP_DEVICE = 2 + private const val STEP_FINISH = 3 + } + } + + class WifiAuthenticationFragment : DialogFragment() { + private var binding: WifiSyncDialogBinding? = null + private var portNum = 0 + private var isGuest: Boolean? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = MaterialAlertDialogBuilder(requireContext()) + dialog.setTitle(R.string.connect_to_peer) + dialog.setNegativeButton(R.string.cancel_label, null) + dialog.setPositiveButton(R.string.confirm_label, null) + + binding = WifiSyncDialogBinding.inflate(layoutInflater) + dialog.setView(binding!!.root) + + binding!!.hostAddressText.setText(SynchronizationCredentials.hosturl?:"") + portNum = SynchronizationCredentials.hostport + if (portNum == 0) portNum = hostPort + binding!!.hostPortText.setText(portNum.toString()) + + binding!!.guestButton.setOnClickListener { + binding!!.hostAddressText.visibility = View.VISIBLE + binding!!.hostPortText.visibility = View.VISIBLE + binding!!.hostButton.visibility = View.INVISIBLE + SynchronizationCredentials.hosturl = binding!!.hostAddressText.text.toString() + portNum = binding!!.hostPortText.text.toString().toInt() + isGuest = true + SynchronizationCredentials.hostport = portNum + } + binding!!.hostButton.setOnClickListener { + binding!!.hostAddressText.visibility = View.VISIBLE + binding!!.hostPortText.visibility = View.VISIBLE + binding!!.guestButton.visibility = View.INVISIBLE + val wifiManager = requireContext().applicationContext.getSystemService(WIFI_SERVICE) as WifiManager + val ipAddress = wifiManager.connectionInfo.ipAddress + val ipString = String.format(Locale.US, "%d.%d.%d.%d", ipAddress and 0xff, ipAddress shr 8 and 0xff, ipAddress shr 16 and 0xff, ipAddress shr 24 and 0xff) + binding!!.hostAddressText.setText(ipString) + binding!!.hostAddressText.isEnabled = false + portNum = binding!!.hostPortText.text.toString().toInt() + isGuest = false + SynchronizationCredentials.hostport = portNum + } + procFlowEvents() + return dialog.create() + } + override fun onDestroy() { + cancelFlowEvents() + super.onDestroy() + } + override fun onResume() { + super.onResume() + val d = dialog as? AlertDialog + if (d != null) { + val confirmButton = d.getButton(Dialog.BUTTON_POSITIVE) as Button + confirmButton.setOnClickListener { + Logd(TAG, "confirm button pressed") + if (isGuest == null) { + Toast.makeText(requireContext(), R.string.host_or_guest, Toast.LENGTH_LONG).show() + return@setOnClickListener + } + binding!!.progressContainer.visibility = View.VISIBLE + confirmButton.visibility = View.INVISIBLE + val cancelButton = d.getButton(Dialog.BUTTON_NEGATIVE) as Button + cancelButton.visibility = View.INVISIBLE + portNum = binding!!.hostPortText.text.toString().toInt() + setWifiSyncEnabled(true) + startInstantSync(requireContext(), portNum, binding!!.hostAddressText.text.toString(), isGuest!!) + } + } + } + + private var eventSink: Job? = null + private fun cancelFlowEvents() { + eventSink?.cancel() + eventSink = null + } + private fun procFlowEvents() { + if (eventSink != null) return + eventSink = lifecycleScope.launch { + EventFlow.events.collectLatest { event -> + Logd(TAG, "Received event: ${event.TAG}") + when (event) { + is FlowEvent.SyncServiceEvent -> syncStatusChanged(event) + else -> {} + } + } + } + } + fun syncStatusChanged(event: FlowEvent.SyncServiceEvent) { + when (event.messageResId) { + R.string.sync_status_error -> { + Toast.makeText(requireContext(), event.message, Toast.LENGTH_LONG).show() + dialog?.dismiss() + } + R.string.sync_status_success -> { + Toast.makeText(requireContext(), R.string.sync_status_success, Toast.LENGTH_LONG).show() + dialog?.dismiss() + } + R.string.sync_status_in_progress -> { + binding!!.progressBar.progress = event.message.toInt() + } + else -> { + Logd(TAG, "Sync result unknow ${event.messageResId}") +// Toast.makeText(context, "Sync result unknow ${event.messageResId}", Toast.LENGTH_LONG).show() + } + } + } + + companion object { + val TAG = WifiAuthenticationFragment::class.simpleName ?: "Anonymous" + } + } + } + + class NotificationPreferencesFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.notification_pref_fragment) + return ComposeView(requireContext()).apply { + setContent { + CustomTheme(requireContext()) { + val textColor = MaterialTheme.colorScheme.onSurface + val scrollState = rememberScrollState() + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) { + Text(stringResource(R.string.notification_group_errors), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.notification_channel_download_error), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.notification_channel_download_error_description), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefShowDownloadReport.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.prefShowDownloadReport.name, it).apply() + }) + } + if (SynchronizationSettings.isProviderConnected) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.notification_channel_sync_error), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.notification_channel_sync_error_description), color = textColor) + } + var isChecked by remember { mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.pref_gpodnet_notifications.name, true)) } + Switch(checked = isChecked, onCheckedChange = { + isChecked = it + appPrefs.edit().putBoolean(UserPreferences.Prefs.pref_gpodnet_notifications.name, it).apply() + }) + } + } + } + } + } + } + } + } + @Suppress("EnumEntryName") enum class Screens(val titleRes: Int) { preferences_swipe(R.string.swipeactions_label), @@ -225,19 +3477,5 @@ class PreferenceActivity : AppCompatActivity(), SearchPreferenceResultListener { companion object { private const val FRAGMENT_TAG = "tag_preferences" const val OPEN_AUTO_DOWNLOAD_SETTINGS: String = "OpenAutoDownloadSettings" - @JvmStatic - fun getTitleOfPage(preferences: Int): Int { - return when (preferences) { -// R.xml.preferences_downloads -> R.string.downloads_pref - R.xml.preferences_autodownload -> R.string.pref_automatic_download_title - R.xml.preferences_playback -> R.string.playback_pref -// R.xml.preferences_import_export -> R.string.import_export_pref - R.xml.preferences_user_interface -> R.string.user_interface_label - R.xml.preferences_synchronization -> R.string.synchronization_pref - R.xml.preferences_notifications -> R.string.notification_pref_fragment -// R.xml.preferences_swipe -> R.string.swipeactions_label - else -> R.string.settings_label - } - } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt index 8316412e..83a3501e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt @@ -35,6 +35,8 @@ import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter import ac.mdiq.podcini.ui.compose.ChaptersDialog import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.PlaybackSpeedFullDialog +import ac.mdiq.podcini.ui.compose.SkipDialog +import ac.mdiq.podcini.ui.compose.SkipDirection import ac.mdiq.podcini.ui.dialog.* import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.ui.view.ShownotesWebView @@ -771,14 +773,36 @@ class VideoplayerActivity : CastEnabledActivity() { binding.sbPosition.setOnSeekBarChangeListener(this) binding.rewindButton.setOnClickListener { onRewind() } binding.rewindButton.setOnLongClickListener { - SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND, null) + val composeView = ComposeView(requireContext()).apply { + setContent { + val showDialog = remember { mutableStateOf(true) } + CustomTheme(requireContext()) { + SkipDialog(SkipDirection.SKIP_REWIND, onDismissRequest = { + showDialog.value = false + (view as? ViewGroup)?.removeView(this@apply) + }) {} + } + } + } + (view as? ViewGroup)?.addView(composeView) true } binding.playButton.setIsVideoScreen(true) binding.playButton.setOnClickListener { onPlayPause() } binding.fastForwardButton.setOnClickListener { onFastForward() } binding.fastForwardButton.setOnLongClickListener { - SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD, null) + val composeView = ComposeView(requireContext()).apply { + setContent { + val showDialog = remember { mutableStateOf(true) } + CustomTheme(requireContext()) { + SkipDialog(SkipDirection.SKIP_FORWARD, onDismissRequest = { + showDialog.value = false + (view as? ViewGroup)?.removeView(this@apply) + }) {} + } + } + } + (view as? ViewGroup)?.addView(composeView) false } // To suppress touches directly below the slider diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt index 8adcb2b0..bd3e90d4 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt @@ -1112,11 +1112,11 @@ fun EpisodeSortDialog(initOrder: EpisodeSortOrder, showKeepSorted: Boolean = fal color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)) { val textColor = MaterialTheme.colorScheme.onSurface val scrollState = rememberScrollState() - var sortIndex by remember { mutableIntStateOf(initOrder.ordinal) } + var sortIndex by remember { mutableIntStateOf(initOrder.ordinal/2) } var keepSorted by remember { mutableStateOf(false) } Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp).verticalScroll(scrollState)) { NonlazyGrid(columns = 2, itemCount = orderList.size) { index -> - var dir by remember { mutableStateOf(true) } + var dir by remember { mutableStateOf(if (sortIndex == index) initOrder.ordinal % 2 == 0 else true) } OutlinedButton(modifier = Modifier.padding(2.dp), elevation = null, border = BorderStroke(2.dp, if (sortIndex != index) textColor else Color.Green), onClick = { sortIndex = index diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt index 43ae0782..4a5ad136 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt @@ -16,7 +16,9 @@ import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence import ac.mdiq.podcini.storage.database.Feeds.buildTags import ac.mdiq.podcini.storage.database.Feeds.createSynthetic import ac.mdiq.podcini.storage.database.Feeds.deleteFeedSync +import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.getTags +import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.upsert import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.* @@ -278,8 +280,9 @@ fun RenameOrCreateSyntheticFeed(feed_: Feed? = null, onDismissRequest: () -> Uni @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable -fun TagSettingDialog(feeds: List, onDismiss: () -> Unit) { +fun TagSettingDialog(feeds_: List, onDismiss: () -> Unit) { Dialog(onDismissRequest = onDismiss) { + val feeds = realm.query(Feed::class).query("id IN $0", feeds_.map {it.id}).find() val suggestions = remember { getTags() } val commonTags = remember { if (feeds.size == 1) feeds[0].preferences?.tags?.toMutableStateList()?: mutableStateListOf() @@ -344,13 +347,13 @@ fun TagSettingDialog(feeds: List, onDismiss: () -> Unit) { Button(onClick = { onDismiss() }) { Text("Cancel") } Spacer(Modifier.weight(1f)) Button(onClick = { - if ((tags.toSet() + commonTags.toSet()).isNotEmpty()) for (f in feeds) upsertBlk(f) { - Logd("TagsSettingDialog", "tags: [$tags] commonTags: [$commonTags]") -// if (feeds.size == 1) it.preferences?.tags?.clear() -// else if (commonTags.isNotEmpty()) it.preferences?.tags?.removeAll(commonTags) - if (commonTags.isNotEmpty()) it.preferences?.tags?.removeAll(commonTags) - if (tags.isNotEmpty()) it.preferences?.tags?.addAll(tags) - if (text.isNotBlank()) it.preferences?.tags?.add(text) + Logd("TagsSettingDialog", "tags: [${tags.joinToString()}] commonTags: [${commonTags.joinToString()}]") + if ((tags.toSet() + commonTags.toSet()).isNotEmpty() || text.isNotBlank()) { + for (f in feeds) upsertBlk(f) { + if (commonTags.isNotEmpty()) it.preferences?.tags?.removeAll(commonTags) + if (tags.isNotEmpty()) it.preferences?.tags?.addAll(tags) + if (text.isNotBlank()) it.preferences?.tags?.add(text) + } buildTags() } onDismiss() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Playback.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Playback.kt new file mode 100644 index 00000000..8ce9a056 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Playback.kt @@ -0,0 +1,54 @@ +package ac.mdiq.podcini.ui.compose + +import ac.mdiq.podcini.R +import ac.mdiq.podcini.preferences.UserPreferences +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp + +@Composable +fun SkipDialog(direction: SkipDirection, onDismissRequest: ()->Unit, callBack: (Int)->Unit) { + val titleRes = if (direction == SkipDirection.SKIP_FORWARD) R.string.pref_fast_forward else R.string.pref_rewind + var interval by remember { mutableStateOf((if (direction == SkipDirection.SKIP_FORWARD) UserPreferences.fastForwardSecs else UserPreferences.rewindSecs).toString()) } + AlertDialog(onDismissRequest = { onDismissRequest() }, + title = { Text(stringResource(titleRes), style = MaterialTheme.typography.titleLarge) }, + text = { + TextField(value = interval, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Companion.Number), label = { Text("seconds") }, singleLine = true, + onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) interval = it }) }, + confirmButton = { + TextButton(onClick = { + if (interval.isNotBlank()) { + val value = interval.toInt() + if (direction == SkipDirection.SKIP_FORWARD) UserPreferences.fastForwardSecs = value + else UserPreferences.rewindSecs = value + callBack(value) + onDismissRequest() + } + }) { Text(text = "OK") } + }, + dismissButton = { TextButton(onClick = { onDismissRequest() }) { Text(text = "Cancel") } } + ) +} + +enum class SkipDirection { + SKIP_FORWARD, SKIP_REWIND +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SkipPreferenceDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SkipPreferenceDialog.kt deleted file mode 100644 index 40b3c614..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SkipPreferenceDialog.kt +++ /dev/null @@ -1,46 +0,0 @@ -package ac.mdiq.podcini.ui.dialog - -import android.content.Context -import android.content.DialogInterface -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import ac.mdiq.podcini.R -import ac.mdiq.podcini.preferences.UserPreferences.fastForwardSecs -import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs -import java.text.NumberFormat -import java.util.* - -object SkipPreferenceDialog { - fun showSkipPreference(context: Context, direction: SkipDirection, textView: TextView? = null) { - var checked = 0 - val skipSecs = if (direction == SkipDirection.SKIP_FORWARD) fastForwardSecs else rewindSecs - - val values = context.resources.getIntArray(R.array.seek_delta_values) - val choices = arrayOfNulls(values.size) - for (i in values.indices) { - if (skipSecs == values[i]) checked = i - choices[i] = String.format(Locale.getDefault(), "%d %s", values[i], context.getString(R.string.time_seconds)) - } - - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(if (direction == SkipDirection.SKIP_FORWARD) R.string.pref_fast_forward else R.string.pref_rewind) - builder.setSingleChoiceItems(choices, checked) { dialog: DialogInterface, _: Int -> - val choice = (dialog as AlertDialog).listView.checkedItemPosition - if (choice < 0 || choice >= values.size) System.err.printf("Choice in showSkipPreference is out of bounds %d", choice) - else { - val seconds = values[choice] - if (direction == SkipDirection.SKIP_FORWARD) fastForwardSecs = seconds - else rewindSecs = seconds - if (textView != null) textView.text = NumberFormat.getInstance().format(seconds.toLong()) - dialog.dismiss() - } - } - builder.setNegativeButton(R.string.cancel_label, null) - builder.show() - } - - enum class SkipDirection { - SKIP_FORWARD, SKIP_REWIND - } -} 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 ca514a9f..04f3ff01 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 @@ -40,6 +40,8 @@ import ac.mdiq.podcini.ui.compose.ChaptersDialog import ac.mdiq.podcini.ui.compose.ChooseRatingDialog import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.PlaybackSpeedFullDialog +import ac.mdiq.podcini.ui.compose.SkipDialog +import ac.mdiq.podcini.ui.compose.SkipDirection import ac.mdiq.podcini.ui.dialog.* import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.ui.view.ShownotesWebView @@ -62,6 +64,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -97,7 +100,6 @@ import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest import net.dankito.readability4j.Readability4J -import org.apache.commons.lang3.StringUtils import java.text.DecimalFormat import java.text.NumberFormat import kotlin.math.cos @@ -259,16 +261,14 @@ class AudioPlayerFragment : Fragment() { } Spacer(Modifier.weight(0.1f)) Column(horizontalAlignment = Alignment.CenterHorizontally) { + var showSkipDialog by remember { mutableStateOf(false) } + var rewindSecs by remember { mutableStateOf(NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong())) } + if (showSkipDialog) SkipDialog(SkipDirection.SKIP_REWIND, onDismissRequest = { showSkipDialog = false }) { rewindSecs = it.toString() } Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_fast_rewind), tint = textColor, contentDescription = "rewind", - modifier = Modifier.width(43.dp).height(43.dp).combinedClickable(onClick = { - // TODO: the check appears not necessary and hurting cast -// if (controller != null && playbackService?.isServiceReady() == true) - playbackService?.mPlayer?.seekDelta(-UserPreferences.rewindSecs * 1000) - }, onLongClick = { - SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND) - })) - val rewindSecs = remember { NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong()) } + modifier = Modifier.width(43.dp).height(43.dp).combinedClickable( + onClick = { playbackService?.mPlayer?.seekDelta(-UserPreferences.rewindSecs * 1000) }, + onLongClick = { showSkipDialog = true })) Text(rewindSecs, color = textColor, style = MaterialTheme.typography.bodySmall) } Spacer(Modifier.weight(0.1f)) @@ -293,16 +293,14 @@ class AudioPlayerFragment : Fragment() { })) Spacer(Modifier.weight(0.1f)) Column(horizontalAlignment = Alignment.CenterHorizontally) { + var showSkipDialog by remember { mutableStateOf(false) } + var fastForwardSecs by remember { mutableStateOf(NumberFormat.getInstance().format(UserPreferences.fastForwardSecs.toLong())) } + if (showSkipDialog) SkipDialog(SkipDirection.SKIP_FORWARD, onDismissRequest = {showSkipDialog = false }) { fastForwardSecs = it.toString()} Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_fast_forward), tint = textColor, contentDescription = "forward", - modifier = Modifier.width(43.dp).height(43.dp).combinedClickable(onClick = { - // TODO: the check appears not necessary and hurting cast -// if (controller != null && playbackService?.isServiceReady() == true) - playbackService?.mPlayer?.seekDelta(UserPreferences.fastForwardSecs * 1000) - }, onLongClick = { - SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD) - })) - val fastForwardSecs = remember { NumberFormat.getInstance().format(UserPreferences.fastForwardSecs.toLong()) } + modifier = Modifier.width(43.dp).height(43.dp).combinedClickable( + onClick = { playbackService?.mPlayer?.seekDelta(UserPreferences.fastForwardSecs * 1000) }, + onLongClick = { showSkipDialog = true })) Text(fastForwardSecs, color = textColor, style = MaterialTheme.typography.bodySmall) } Spacer(Modifier.weight(0.1f)) 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 22337d42..3b4ff928 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 @@ -187,12 +187,13 @@ class FeedEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListener { } } if (showNewSynthetic) RenameOrCreateSyntheticFeed(feed) {showNewSynthetic = false} - if (showSortDialog) EpisodeSortDialog(initOrder = sortOrder, onDismissRequest = {showSortDialog = false}) { sortOrder, _ -> + if (showSortDialog) EpisodeSortDialog(initOrder = sortOrder, onDismissRequest = {showSortDialog = false}) { sortOrder_, _ -> if (feed != null) { - Logd(TAG, "persist Episode SortOrder") + Logd(TAG, "persist Episode SortOrder_") + sortOrder = sortOrder_ runOnIOScope { val feed_ = realm.query(Feed::class, "id == ${feed!!.id}").first().find() - if (feed_ != null) upsert(feed_) { it.sortOrder = sortOrder } + if (feed_ != null) feed = upsert(feed_) { it.sortOrder = sortOrder_ } } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt index 489738d0..576e17fa 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt @@ -272,7 +272,7 @@ class FeedSettingsFragment : Fragment() { // tags Column { var showDialog by remember { mutableStateOf(false) } - if (showDialog) TagSettingDialog(feeds = listOf(feed!!), onDismiss = { showDialog = false }) + if (showDialog) TagSettingDialog(feeds_ = listOf(feed!!), onDismiss = { showDialog = false }) Row(Modifier.fillMaxWidth()) { Icon(ImageVector.vectorResource(id = R.drawable.ic_tag), "", tint = textColor) Spacer(modifier = Modifier.width(20.dp)) 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 6e4af6af..4f1d9c85 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 @@ -4,10 +4,12 @@ import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.ComposeFragmentBinding import ac.mdiq.podcini.databinding.DialogSwitchPreferenceBinding import ac.mdiq.podcini.net.feed.FeedUpdateManager +import ac.mdiq.podcini.preferences.DocumentFileExportWorker +import ac.mdiq.podcini.preferences.ExportTypes +import ac.mdiq.podcini.preferences.ExportWorker import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlWriter import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.appPrefs -import ac.mdiq.podcini.preferences.fragments.ImportExportPreferencesFragment.* import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.getTags import ac.mdiq.podcini.storage.database.RealmDB.realm @@ -611,7 +613,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { isExpanded = false selectMode = false Logd(TAG, "baseline_import_export_24: ${selected.size}") - val exportType = Export.OPML_SELECTED + val exportType = ExportTypes.OPML_SELECTED val title = String.format(exportType.outputNameTemplate, SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())) val intentPickAction = Intent(Intent.ACTION_CREATE_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) diff --git a/app/src/main/res/layout/theme_preference.xml b/app/src/main/res/layout/theme_preference.xml deleted file mode 100644 index 27335fbd..00000000 --- a/app/src/main/res/layout/theme_preference.xml +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index e67fbbff..65004b9c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -110,8 +110,8 @@ ابدا - عند عدم التفضيل - إذا لم يكن في لائحة الاستماع + عند عدم التفضيل + إذا لم يكن في لائحة الاستماع بعد الانتهاء %d ساعة بعد الأنتهاء @@ -390,7 +390,7 @@ التنزيل عند عدم الشحن الجهاز السماح بالتنزيل التلقائي عندما لا يتم شحن البطارية تخزين الحلقات - العدد الإجمالي للحلقات التي تم تنزيلها والمخزنة مؤقتًا على الجهاز. سيتم تعليق التنزيل التلقائي إذا تم الوصول إلى هذا الرقم. + استخدم صورة غلاف الحلقة استخدم الغلاف المخصص للحلقة في القوائم إن وجد. إذا لم يتم تحديد هذا الاختيار ، فسيستخدم التطبيق صورة غلاف البودكاست. أظهر الوقت المتبقي diff --git a/app/src/main/res/values-ast/strings.xml b/app/src/main/res/values-ast/strings.xml index 7a5c7c97..7e47516f 100644 --- a/app/src/main/res/values-ast/strings.xml +++ b/app/src/main/res/values-ast/strings.xml @@ -56,8 +56,8 @@ Siempres Enxamás Enxamás - Al nun tar en Favoritos - Al nun tar na cola + Al nun tar en Favoritos + Al nun tar na cola Dempués d\'acabar 1 hora dempués d\'acabar @@ -182,7 +182,7 @@ Namás permite la descarga automática nes redes Wi-Fi esbillaes. Baxar al nun tar cargando la batería Permite la descarga automática cuando la batería nun tea cargando. - El númberu total d\'episodios baxaos y atroxaos na caché del preséu. La descarga automática va suspendese si s\'algama\'l númberu qu\'afites. + Nes llistes, úsase la portada de los episodios siempres que seya posible. Si nun se marca esta opción, l\'aplicación va usar siempres la portada de los podcasts. Al activar esta opción, amuesa\'l tiempu que falta de los episodios. Si se desactiva, amuesa la duración total de los episodios. Claridá diff --git a/app/src/main/res/values-br/strings.xml b/app/src/main/res/values-br/strings.xml index c35a964b..9759ce1a 100644 --- a/app/src/main/res/values-br/strings.xml +++ b/app/src/main/res/values-br/strings.xml @@ -99,8 +99,8 @@ Bepred Morse Morse - Pa n\'emañ ket er sinedoù - Pa n\'emañ ket el lost + Pa n\'emañ ket er sinedoù + Pa n\'emañ ket el lost Goude bezañ echuet %d eur goude bezañ selaouet @@ -308,7 +308,7 @@ Aotren ar pellgargañ emgefreek war ar rouedadoù Wi-Fi diuzet nemetken. Pellgargañ pa ne vez ket o kargañ Aotren ar pellgargañ emgefreek pa ne vez ket ar benveg o kargañ - Niver hollek a rannoù pellgarget lakaet e krubuilh ar benveg. Diweredekaet e vo ar pellgargañ emgefreek mard eo tizhet an niver-mañ. + Ober gant golo ar rann e listennoù pa vez dioutañ. Mard eo digevasket e vo graet gant golo ar podskignad. Diskouez amzer ar rannoù a chom mard eo gweredekaet. Ma n\'eo ket e tiskouez padelezh ar rannoù. Sklaer diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 598f61cc..2ea0533f 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -106,8 +106,8 @@ Mai - Quan no és favorit - Quan no està a la cua + Quan no és favorit + Quan no està a la cua Després d\'acabar 1 hora després d\'acabar @@ -366,7 +366,7 @@ Baixa mentre no es carrega Permet les baixades automàtiques mentre la bateria no es carrega Episodis baixats - Nombre total d\'episodis baixats al dispositiu. La baixada automàtica serà suspesa si s\'arriba a aquest nombre. + Usa la coberta de l\'episodi Utilitza la portada específica de l\'episodi quan sigui possible. Si es desactiva, l\'aplicació utilitzarà sempre la portada del podcast. Mostra el temps restant diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 63a9f391..25da4c72 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -115,8 +115,8 @@ Nikdy - Pokud není mezi oblíbenými - Pokud není ve frontě + Pokud není mezi oblíbenými + Pokud není ve frontě Po dokončení %d hodinu po dokončení @@ -398,7 +398,7 @@ Stahovat, pokud neprobíhá nabíjení Povolit automatické stahování i pokud není baterie nabíjena Odkládací prostor pro epizody - Celkový počet epizod stažených na zařízení. Automatické stahování se zastaví při dosažení této hodnoty. + Použít obrázek epizody Použít v seznamu obrázek přímo z epizody, pokud je k dispozici. Není-li tato možnost zaškrtnuta, tak se vždy použije obrázek podcastu. Zobrazit zbývající čas diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 6251e759..f727d51f 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -115,8 +115,8 @@ Aldrig - Hvis ikke en favorit - Når ikke i kø + Hvis ikke en favorit + Når ikke i kø Efter færdig afspilning 1 time efter afslutning @@ -383,7 +383,7 @@ Tillad overførsel ved batteridrift Tillad automatisk overførsel, når batteriet ikke oplades Mellemlager for afsnit - Totalt antal af overførte afsnit gemt på din enhed. Automatisk overførsel stilles i bero, når dette nummer nåes. + Brug afsnitbillede Brug det afsnitspecifikke cover i lister når muligt. Hvis dette slås fra, vil appen altid bruge podcastens coverbillede. Vis resterende tid diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a624b75f..40157900 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -119,8 +119,8 @@ Nie - Wenn nicht favorisiert - Wenn nicht in der Warteschlange + Wenn nicht favorisiert + Wenn nicht in der Warteschlange Wenn fertig gespielt 1 Stunde nachdem fertig gespielt @@ -387,7 +387,7 @@ Automatischer Download im Akkubetrieb Automatische Downloads auch erlauben, wenn der Akku nicht geladen wird Episodenspeicher - Gesamtzahl der heruntergeladenen Episoden, die auf dem Gerät gespeichert werden. Der automatische Download wird pausiert, wenn diese Anzahl erreicht ist. + Episoden-Bilder verwenden Falls verfügbar, episodenspezifische Titelbilder in Listen verwenden. Falls nicht ausgewählt, wird immer das Titelbild des Podcasts verwendet. Verbleibende Zeit anzeigen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 59512322..8973f355 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -118,8 +118,8 @@ Nunca - Cuando no esté en Favoritos - Cuando no esté en la cola + Cuando no esté en Favoritos + Cuando no esté en la cola Después de acabar 1 hora después de acabar @@ -389,7 +389,7 @@ Descargar cuando no se está cargando Permitir la descarga automática cuando la batería no se esté cargando Caché de episodios - Número total de episodios cacheados en el dispositivo. La descarga automática se suspenderá si se alcanza este número. + Usar portada del episodio Usar la portada de cada episodio en las listas cuando sea posible. Si se desactiva, la aplicación siempre usará la portada del podcast. Mostrar el tiempo restante diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 0ae7ab76..4905ad78 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -95,8 +95,8 @@ Alati Mitte kunagi Mitte kunagi - Kui pole lemmik - Kui pole järjekorras + Kui pole lemmik + Kui pole järjekorras Pärast lõpetamist 1 tund pärast lõpetamist @@ -274,7 +274,7 @@ Luba automaatne allalaadimine ainult valitud Wifi võrkudes. Allalaadimine, kui seade ei lae Luba automaatne allalaadimine ka siis, kui seade pole laadimas - Seadme puhvrisse allalaaditud saadete koguarv. Automaatne allalaadimine peatub, kui selle numbrini jõutakse. + Kui võimalik, kasutatakse nimekirjades saate kaanepilti. Kui märkimata, kasutab rakendus alati taskuhäälingu kaanepilti. Kui märgitud, kuvatakse saate alles jäänud aega. Kui märkimata, näidatakse saadete kogupikkust. Hele diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 62d30f8a..e605f007 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -109,8 +109,8 @@ Inoiz ez - Gogoko ez denean - Ilaran ez dagoenean + Gogoko ez denean + Ilaran ez dagoenean Bukatu ondoren 1 ordu bukatu ondoren @@ -345,7 +345,7 @@ Deskargatu kargatzen ari ez denean Onartu deskarga bateria kargatzen ari ez denean Saioen cache-a - Gailuan katxeatutako saioen zenbatekoa. Deskarga automatikoa bertan behera utziko da zenbaki honetara heltzean. + Erabili saioaren azala Erabili atalaren azal espezifikoa zerrendetan eskuragarri dagoenean. Desaktibatzen bada, aplikazioak podcastaren azala erabiliko du beti. Erakutsi geratzen den denbora diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index b61a6ac1..598f141e 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -112,8 +112,8 @@ هرگز - وقتی که جزو علاقه‌مندی‌ها نباشد - وقتی که در صف نیست + وقتی که جزو علاقه‌مندی‌ها نباشد + وقتی که در صف نیست بعد از تمام شدن ۱ ساعت پس از پایان @@ -367,7 +367,7 @@ بارگیری زمانی که سیستم شارژ نمیشود مجاز باشد وقتی باتری شارژ نمی شود ، امکان بارگیری خودکار وجود داشته باشد انبارهٔ قسمت - تعداد کل قسمت‌های بار گرفته‌ٔ انبار شده روی افزاره. بارگیری خودکار در صورت رسیدن به این عدد معلّق خواهد شد. + استفاده از جلد قسمت هر زمان که جلد مخصوص قسمت در دسترس بود از آن استفاده کن. در صورت لغو انتخاب ، برنامه همیشه از تصویر جلد پادکست استفاده می کند. نمایش زمان مانده diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index fae77d6f..7fbe40c7 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -105,8 +105,8 @@ Ei koskaan - Kun ei ole suosikeissa - Kun ei ole jonossa + Kun ei ole suosikeissa + Kun ei ole jonossa Lopetuksen jälkeen 1 hour after finishing @@ -355,7 +355,7 @@ Lataa, kun akkua ei ladata Salli automaattiset lataukset, kun akku ei ole latautumassa Jaksovälimuisti - Ladattuja jaksoja yhteensä välimuistissa tällä laitteella. Automaattinen lataaminen pysäytetään, jos tämä raja ylittyy. + Käytä jakson kansikuvaa Käytä jaksokohtaista kansikuvaa luetteloissa aina, kun se on saatavilla. Jos tätä ei ole valittu, sovellus käyttää aina podcastin kansikuvaa. Näytä jäljellä oleva aika diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c9907082..680a55ba 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -119,8 +119,8 @@ Jamais - Quand pas un favori - Quand pas dans la liste de lecture + Quand pas un favori + Quand pas dans la liste de lecture Après avoir été écouté 1 heure après avoir été écouté @@ -391,7 +391,7 @@ Télécharger lorsque l\'appareil n\'est pas en charge Autoriser le téléchargement automatique quand l\'appareil n\'est pas en train de charger Nombre d\'épisodes stockés - Nombre maximum d\'épisodes stockés sur l\'appareil. Le téléchargement automatique sera suspendu si ce nombre est atteint. + Image des épisodes Utiliser dans les listes les images des épisodes. Sinon l\'image du podcast sera utilisée. Afficher la durée restante diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index c2c3d303..9ba77a08 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -115,8 +115,8 @@ Nunca - Cando non favorito - Cando non esté na cola + Cando non favorito + Cando non esté na cola Tras rematar 1 hora tras rematar @@ -382,7 +382,7 @@ Descargar elementos cando non esté cargando Permitir a descarga automática cando a batería non está a cargar Caché de episodios - O número total de episodios descargados na caché do dispositivo. A descarga automática suspenderase se se alcanza este número. + Usar portada do episodio Usar a capa específica do episodio nas listas cando estivese dispoñible. Sen marcar, a app sempre usará a imaxe de cuberta do podcast. Mostrar tempo restante diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 0b7799a1..a50d6f04 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -98,8 +98,8 @@ Soha Soha - Ha nincs felvéve a kedvencek közé - Ha nincs sorbaállítva + Ha nincs felvéve a kedvencek közé + Ha nincs sorbaállítva Befejezés után befejezés után 1 órával @@ -285,7 +285,7 @@ Automatikus letöltés engedélyezése csak a kiválasztott Wi-Fi hálózatok esetén. Letöltés, ha nincs akkumulátortöltés Automatikus letöltés engedélyezése, ha az akkumulátor nem töltődik - Az eszközön tárolt letöltött epizódok száma. Az automatikus letöltés felfüggesztésre kerül, ha eléri ezt a számot. + Epizódspecifikus borító használata, ha lehetséges. Ha nincs bekapcsolva, akkor az alkalmazás mindig a podcast borítóját fogja használni. Ha be van jelölve, akkor megjeleníti az epizódból hátralévő időt. Ha nincs bejelölve, akkor az epizód hosszát jeleníti meg. Világos diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 1a68d403..89157809 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -92,8 +92,8 @@ Selalu Tidak pernah Tidak pernah - Saat tidak difavoritkan - Ketika tidak dalam antrian + Saat tidak difavoritkan + Ketika tidak dalam antrian Setelah menyelesaikan %d jam setelah menyelesaikan @@ -299,7 +299,7 @@ Izinkan unduh otomatis hanya untuk jaringan Wi-Fi yang terpilih. Unduh saat tidak mengisi daya Izinkan unduh otomatis saat baterai tidak mengisi daya - Jumlah unduhan episode yang tercached pada perangkat. Unduhan otomatis akan ditangguhkan saat mencapai angka yang ditetapkan. + Gunakan sampul episode Gunakan sampul spesifik episode dalam daftar jika bisa. Jika tidak dicentang, aplikasi akan tetap menggunakan sampul podcast. Tampilkan waktu yang tersisa diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 6473e2ce..f075aee5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -119,8 +119,8 @@ Mai - Quando non preferito - Quando non è in coda + Quando non preferito + Quando non è in coda Dopo il completamento 1 ora dal completamento @@ -391,7 +391,7 @@ Scarica episodi con batteria non in carica Permetti il download automatico quando la batteria non è in carica Cache degli episodi - Numero di episodi scaricati memorizzabili sul dispositivo. I download automatici vengono interrotti se si raggiunge questo valore. + Usa immagine episodio Usa l\'immagine dell\'episodio quando disponibile. Se disattivato, l\'app userà sempre l\'immagine di copertina del podcast. Mostra tempo residuo diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 659245f7..1d5172b2 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -115,8 +115,8 @@ אף פעם - כאשר לא במועדפים - כאשר לא בתור + כאשר לא במועדפים + כאשר לא בתור אחרי סיום שעה לאחר הסיום @@ -394,7 +394,7 @@ להוריד שלא בזמן טעינה לאפשר הורדה אוטומטית כאשר הסוללה אינה בטעינה מטמון פרקים - המספר הכולל של פרקים שהורדו ונשמרים במכשיר. הורדה אוטומטית תושבת אם הכמות הזאת הושגה. + להשתמש בעטיפת פרק להשתמש בעטיפת הפרק ברשימות כאשר ניתן. אם האפשרות לא סומנה היישומון ישתמש בתמונת העטיפה של הפודקאסט. הצגת הזמן שנותר diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 321c4e27..b7d9d2d8 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -85,8 +85,8 @@ 常に しない しない - お気に入りされていない - キューにない時 + お気に入りされていない + キューにない時 完了後 完了後 %d 時間 @@ -261,7 +261,7 @@ 選択したWi-Fiネットワークに対してのみ自動ダウンロードを許可します。 充電時以外にダウンロード バッテリーを充電していない時に自動ダウンロードを許可します - デバイスにキャッシュされるダウンロード済エピソードの合計数。この数に達すると自動ダウンロードが中断されます。 + エピソード固有のカバー画像が利用可能な場合は常にリストで使います。チェックを外すと、アプリは常にポッドキャストのカバー画像を使います。 チェックをすると、エピソードの残り時間を表示します。チェックを外すと、エピソードの合計時間を表示します。 ライト diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e0889bde..b241bdd3 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -105,8 +105,8 @@ 안 함 - 즐겨찾기 아닐 때 - 대기열에 없을 때 + 즐겨찾기 아닐 때 + 대기열에 없을 때 끝나고 나서 끝마치고 나서 %d시간 @@ -355,7 +355,7 @@ 충전하지 않을 때 다운로드 배터리 충전 중이 아닐 때 자동 다운로드 허용 에피소드 캐시 - 장치에 임시 저장한 다운로드한 에피소드의 전체 개수. 이 숫자에 도달하면 자동 다운로드가 지연됩니다. + 에피소드 커버 사용 에피소드마다 설정된 커버가 있으면 그 커버를 사용합니다. 사용하지 않으면, 앱에서는 항상 팟캐스트 커버 이미지를 사용합니다. 남은 시간 표시 diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 07ca2c3a..e7c240af 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -72,8 +72,8 @@ Visada Niekada Niekada - Kai nėra mėgstamas - Jei nėra eilėje + Kai nėra mėgstamas + Jei nėra eilėje Pabaigus klausyti Praėjus 1 valandai po perklausymo @@ -252,7 +252,7 @@ Leisti automatinį atsiuntimą tik prisijungus prie nurodytų belaidžių tinklų. Atsiuntimas ne įkrovimo metu Leisti automatinį atsiuntimą kai baterija nėra įkraunama - Bendras podėlyje atsiųstų epizodų skaičius šiame įrenginyje. Pasiekus šį skaičių automatinis atsiuntimas bus pristabdytas. + Naudoti epizodo viršelį, kai prieinama. Nepažymėjus, programėlė visada naudos tinklalaidės viršelio paveikslėlį. Kai pažymėta, atvaizduojamas likęs epizodų laikas. Jei nepažymėta, atvaizduojama bendra epizodų trukmė. Šviesi diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 0bc35247..30d284ac 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -105,8 +105,8 @@ Aldri - Hvis ikke favoritt - Når ikke i kø + Hvis ikke favoritt + Når ikke i kø Etter fullført avspilling 1 time etter fullført avspilling @@ -359,7 +359,7 @@ Last ned når enheten ikke lader Tillat automatisk nedlasting når enheten ikke står til lading Mellomlager for episoder - Totalt antall nedlastede episoder bufret på enheten. Automatisk nedlasting vil bli stoppet hvis dette nummeret er nådd. + Bruk episode-cover Bruk et episode-spesifikt cover hvis det er tilgjengelig. Hvis dette ikke er valgt vil appen alltid bruke podkastens cover-bilde. Vis gjenværende tid diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 46512e0e..b9173491 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -111,8 +111,8 @@ Nooit - Indien niet favoriet - Indien niet in wachtrij + Indien niet favoriet + Indien niet in wachtrij Als aflevering is beluisterd 1 uur na afronden @@ -344,7 +344,7 @@ Automatisch downloaden alleen toestaan via gekozen wifi-netwerken. Downloaden als toestel niet oplaadt Sta automatisch downloaden toe wanneer toestel niet oplaadt - Geef hier het maximum aantal in de cache van het toestel te downloaden afleveringen op. Downloaden stopt na dit aantal. + Cover van aflevering gebruiken De aflevering-specifieke omslag in lijsten gebruiken indien beschikbaar. Indien uitgevinkt wordt altijd de podcastomslag afbeelding gebruikt. Inschakelen om de resterende tijd te tonen. Anders wordt de totale duur van een aflevering getoond. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e916a237..b115e4e2 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -109,8 +109,8 @@ Nigdy - Gdy nie oznaczone jako ulubione - Kiedy nie są w kolejce + Gdy nie oznaczone jako ulubione + Kiedy nie są w kolejce Po odtworzeniu 1 godzinę po odtworzeniu @@ -363,7 +363,7 @@ Pobieraj, gdy nie ładuje Zezwól na automatyczne pobieranie, gdy bateria nie jest ładowana. Pamięć podręczna odcinków - Całkowita liczba odcinków zapisanych na urządzeniu. Automatyczne pobieranie zostanie przerwane, jeśli zostanie ona osiągnięta. + Użyj okładek odcinków Użyj okładek konkretnych odcinków na listach, kiedy to możliwe. Odznaczenie spowoduje, że aplikacja zawsze będzie używała okładki kanału. Pokaż pozostały czas diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 47790dff..6b5e0b84 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -108,8 +108,8 @@ Nunca - Quando não estiver entre os favoritos - Quando não está na fila + Quando não estiver entre os favoritos + Quando não está na fila Depois de concluído 1 hora após finalizar @@ -357,7 +357,7 @@ Baixar enquanto não está carregando Permitir download automático enquanto a bateria não está carregando Cache de episódios - Número total de episódios baixados em cache no dispositivo. O download automático será suspenso se esse número for atingido. + Usar capa do episódio Usa a capa específica do episódio em listas, sempre que disponível. Se desmarcada, o aplicativo sempre usará a imagem de capa do podcast. Exibe o tempo restante dos episódios, quando marcado. Se estiver desmarcado, exibe a duração total dos episódios. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 86d7fc7e..15f4c49d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -118,8 +118,8 @@ Nunca - Se não for favorito - Se não estiver na fila + Se não for favorito + Se não estiver na fila Ao terminar 1 hora depois de terminar @@ -389,7 +389,7 @@ Descarregar se não estiver a carregar Permitir descarga automática se a bateria não estiver a ser carregada Cache de episódios - Número máximo de episódios descarregados para colocar em cache. A descarga automática será suspensa se este número for atingido. + Utilizar imagem do episódio Utilizar imagem do episódio, se disponível. Se desativar esta opção, será utilizada a imagem do podcast. Mostrar tempo restante diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index a89a9543..8a0a21ed 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -115,8 +115,8 @@ Niciodată - Cănd nu este preferată - Când nu e în coadă + Cănd nu este preferată + Când nu e în coadă După terminare 1 oră după terminare @@ -388,7 +388,7 @@ Descarcă când nu se incarcă Permite descărcările automate când bateria nu se încarcă Cache de episoade - Numărul total de episoade descărcate pe dispozitiv. Dacă acest număr va fii atins atunci descărcările automate vor fii oprite. + Folosește coperta episodului Folosiți coperta specifică episodului în liste când acesta este disponibilă.Dacă opțiunea nu este activată, aplicația va folosi întodeauna coperta podcastului. Arată timpul rămas diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e4fd113e..57174e6a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -105,8 +105,8 @@ никогда - Когда не в избранном - Когда не в очереди + Когда не в избранном + Когда не в очереди После прослушивания 1 час после прослушивания @@ -371,7 +371,7 @@ Загружать без зарядки Разрешать автоматическую загрузку когда батарея не заряжается Кэш выпусков - Общее количество загруженных в кэш выпусков. По достижении этого количества автоматическая загрузка будет приостановлена. + Использовать обложку выпуска Если выпуск содержит свою обложку, показывать в списках её. Если не выбрано, всегда используется обложка подкаста. Показывать оставшееся время diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 3d614dd2..8e471fff 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -115,8 +115,8 @@ Nikdy - Ak nie je medzi obľúbenými - Ak nie je v poradí + Ak nie je medzi obľúbenými + Ak nie je v poradí Po dokončení 1 hodinu po dokončení @@ -393,7 +393,7 @@ Sťahovať keď neprebieha nabíjanie Povoliť automatické sťahovanie aj keď nie je batéria nabíjaná Vyrovnávacia pamäť epizód - Celkový počet epizód stiahnutých do zariadenia. Pri dosiahnutí tejto hodnoty sa automatické sťahovanie zastaví. + Použiť obrázok epizódy Ak je k dispozícií, použiť v zozname obrázok priamo z epizódy. Ak táto možnost nie je zaškrtnutá, tak sa vždy použije obrázok podcastu. Zobraziť zostávajúci čas diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 3ec570f4..e7e0dc96 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -71,8 +71,8 @@ Vedno Nikoli Nikoli - Ko ni dodan med priljubljene - Ko ne čaka v čakalni vrsti + Ko ni dodan med priljubljene + Ko ne čaka v čakalni vrsti Po končanem 1 uro po končanem @@ -248,7 +248,7 @@ Dovoli samodejni prenos samo za izbrana omrežja Wi-Fi. Prenesite, ko se ne polni Dovoli samodejni prenos, ko se baterija ne polni - Skupno število prenesenih epizod, shranjenih v predpomnilniku naprave. Samodejni prenos bo prekinjen, če bo dosežena ta številka. + Uporabite naslovnico za posamezno epizodo na seznamih, kadar je na voljo. Če ta možnost ni izbrana, bo aplikacija vedno uporabljala naslovno sliko podcasta. Prikaži preostali čas epizod, ko je izbrano. Če ni potrjeno, prikaže skupno trajanje epizod. Svetlo diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 918b2ff3..768fd047 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -115,8 +115,8 @@ Aldrig - När ej favorit - Om inte köad + När ej favorit + Om inte köad Efter färdigspelad 1 timma efter klar @@ -382,7 +382,7 @@ Nedladdning vid batteridrift Tillåt automatisk nedladdning när batteriet inte laddas Episodcache - Totalt antal nedladdade epidoder som ligger i enhetens cache. Automatisk nedladdning kommer att vänta om detta antal nås. + Använd episodomslag Använd episodspecifika omslag i listan när de är tillgängliga. Om urkryssat kommer appen alltid använda podcastens omslagsbild. Visa återstående tid diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index af776e49..98035f3f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -110,8 +110,8 @@ Hiçbir zaman - Favorilenmemişken - Sırada değilse + Favorilenmemişken + Sırada değilse Bittikten sonra Bittikten 1 saat sonra @@ -366,7 +366,7 @@ Şarj olmuyorken indir Pil şarj olmuyorken otomatik indirmeye izin ver Bölüm önbelleği - Cihazda önbelleğe alınan indirilmiş bölümlerin toplam sayısı. Bu sayıya ulaşılırsa otomatik indirme askıya alınacaktır. + Bölüm kapağını kullan Mümkün olduğunda listelerde bölüme özel kapağı kullanın. İşaretli değilse, uygulama her zaman Podcast kapak resmini kullanacaktır. Kalan süreyi göster diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index e252fc5e..61d7bb93 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -115,8 +115,8 @@ Ніколи - Коли не вибрано - Якщо не в черзі + Коли не вибрано + Якщо не в черзі Після закінчення 1 година після завершення @@ -394,7 +394,7 @@ Завантаження без зарядного пристрою Дозволити автозавантаження коли зарядний пристрій не підключений Кеш епізодів - Загальна кількість завантажених епізодів, кешованих на пристрої. Якщо цей номер буде досягнутий, автоматичне завантаження буде призупинено. + Показувати обкладинку епізоду Використовуйте обкладинку для епізоду у списках, коли це можливо. Якщо цей прапорець не встановлено, програма завжди буде використовувати зображення обкладинки подкасту. Показати залишок часу diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b80ea450..719c30d2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -119,8 +119,8 @@ 从不 - 当未收藏时 - 当不在队列中 + 当未收藏时 + 当不在队列中 结束后 结束后 %d 小时 @@ -382,7 +382,7 @@ 未充电时下载 未充电时允许自动下载 节目缓存 - 缓存在设备上的已下载节目总数 若达到此数目,自动下载将被暂停 + 使用节目封面 勾选后,在列表中使用一期节目特定的封面图。如果不勾选,应用程序将始终使用播客封面图像。 显示剩余时间 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c8e1f469..b229cbc7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -67,8 +67,8 @@ 總是 不予下載 不予刪除 - 若未標記為最愛 - 若未列入待播清單 + 若未標記為最愛 + 若未列入待播清單 聽完後 聽完後 %d 小時 @@ -210,7 +210,7 @@ 限定於特定 Wi-Fi 連線時自動下載 未充電時下載 允許未充電時也自動下載 - 在本機中可以暫存的集數,若達上限則將停止自動下載。 + 在單集有專屬封面的情況下使用該封面圖。如果取消,則一律使用 Podcast 的封面圖 勾選時顯示剩餘播放時間,不勾選時顯示單集的總時間。 淡色 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 8511f856..8c34b7a2 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -1,147 +1,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - 10 - 15 - 20 - 30 - 45 - 60 - - - - 5 - 10 - 25 - 50 - 100 - 500 - @string/pref_episode_cache_unlimited - - - 5 - 10 - 25 - 50 - 100 - 500 - -1 - - - - - - - - - - - - - - - - - - - - - images - - - - @string/episode_cleanup_except_favorite_removal - @string/episode_cleanup_queue_removal - 0 - 1 - 3 - 5 - 7 - @string/episode_cleanup_never - - - - @string/button_action_fast_forward - @string/button_action_rewind - @string/button_action_skip_episode - @string/button_action_restart_episode - - - @string/keycode_media_fast_forward - @string/keycode_media_rewind - @string/keycode_media_next - @string/keycode_media_previous - - - - @string/enqueue_location_back - @string/enqueue_location_front - @string/enqueue_location_after_current - @string/enqueue_location_random - - - - - BACK - FRONT - AFTER_CURRENTLY_PLAYING - RANDOM - - - - -3 - -1 - 0 - 12 - 24 - 72 - 120 - 168 - -2 - - - - - - - - - - - - - @string/pref_video_mode_small_window @string/pref_video_mode_full_screen @@ -158,25 +16,4 @@ @string/next_chapter @string/playback_speed - - - SubscriptionsFragment - QueuesFragment - AllEpisodesFragment - DownloadsFragment - PlaybackHistoryFragment - AddFeedFragment - StatisticsFragment - remember - - - @string/subscriptions_label - @string/queue_label - @string/episodes_label - @string/downloads_label - @string/playback_history_label - @string/add_feed_label - @string/statistics_label - @string/remember_last_page - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 56b5e863..772f37bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -169,9 +169,10 @@ Remove from current feed + Limit by Never - When not favorited - When not in queue + When not favorited + When not in queue After finishing 1 hour after finishing @@ -524,7 +525,7 @@ Download when not charging Allow automatic download when the battery is not charging Episode cache - Total number of downloaded episodes cached on the device. Automatic download will be suspended if this number is reached. + Total number of downloaded episodes cached on the device (0 means unlimited). Automatic download will be suspended if this number is reached. Counting played Set if downloaded episodes already played count into the episode cache Use episode cover @@ -742,6 +743,7 @@ Sleep timer enabled + Sync with device on Wifi You can connect two devices on the same wifi network to synchronize your episodes play states Choose synchronization provider You can choose from multiple providers to synchronize your subscriptions and episode play state with diff --git a/app/src/main/res/xml/preferences_autodownload.xml b/app/src/main/res/xml/preferences_autodownload.xml deleted file mode 100644 index 835658ad..00000000 --- a/app/src/main/res/xml/preferences_autodownload.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/xml/preferences_notifications.xml b/app/src/main/res/xml/preferences_notifications.xml deleted file mode 100644 index 34d32734..00000000 --- a/app/src/main/res/xml/preferences_notifications.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_playback.xml b/app/src/main/res/xml/preferences_playback.xml deleted file mode 100644 index 25a21ea2..00000000 --- a/app/src/main/res/xml/preferences_playback.xml +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/xml/preferences_synchronization.xml b/app/src/main/res/xml/preferences_synchronization.xml deleted file mode 100644 index d17e0334..00000000 --- a/app/src/main/res/xml/preferences_synchronization.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/xml/preferences_user_interface.xml b/app/src/main/res/xml/preferences_user_interface.xml deleted file mode 100644 index 48abedaf..00000000 --- a/app/src/main/res/xml/preferences_user_interface.xml +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/changelog.md b/changelog.md index 79753f16..3c5a47c4 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,13 @@ +# 6.14.8 + +* fixed issues in tags setting +* fixed initial sort order not taking correctly in FeedEpisodes +* fixed Compose switches not settable in Preferences +* skip dialog is in Compose and features number input rather than multiple choice +* made the numbers under rewind and forward buttons in PlayerUI react to changes +* all preferences are in Compose except some dialogs +* some class restructuring + # 6.14.7 * corrected some deeplinks in manifest file on OPMLActivity diff --git a/fastlane/metadata/android/en-US/changelogs/3020306.txt b/fastlane/metadata/android/en-US/changelogs/3020306.txt index abafb0cc..2863502a 100644 --- a/fastlane/metadata/android/en-US/changelogs/3020306.txt +++ b/fastlane/metadata/android/en-US/changelogs/3020306.txt @@ -1,4 +1,4 @@ - Version 6.14.6 + Version 6.14.7 * corrected some deeplinks in manifest file on OPMLActivity * added commentTime in Episode and Feed to record the time of comment/opinion added diff --git a/fastlane/metadata/android/en-US/changelogs/3020307.txt b/fastlane/metadata/android/en-US/changelogs/3020307.txt new file mode 100644 index 00000000..15db271e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020307.txt @@ -0,0 +1,9 @@ + Version 6.14.8 + +* fixed issues in tags setting +* fixed initial sort order not taking correctly in FeedEpisodes +* fixed Compose switches not settable in Preferences +* skip dialog is in Compose and features number input rather than multiple choice +* made the numbers under rewind and forward buttons in PlayerUI react to changes +* all preferences are in Compose except some dialogs +* some class restructuring