5.1.0 commit
This commit is contained in:
parent
399b4d16eb
commit
6fc3eb58ca
|
@ -97,9 +97,10 @@ The project aims to improve efficiency and provide more useful and user-friendly
|
|||
* It syncs the play states (position and played) of episodes that exist in both devices (ensure to refresh first) and that have been played (completed or not)
|
||||
* So far, every sync is a full sync, no sync for subscriptions and media files
|
||||
|
||||
### Security
|
||||
### Security and reliability
|
||||
|
||||
* Disabled `usesCleartextTraffic`, so that all content transmission is more private and secure
|
||||
* Settings/Preferences can now to exported and imported
|
||||
|
||||
For more details of the changes, see the [Changelog](changelog.md)
|
||||
|
||||
|
|
|
@ -159,8 +159,8 @@ android {
|
|||
// Version code schema (not used):
|
||||
// "1.2.3-beta4" -> 1020304
|
||||
// "1.2.3" -> 1020395
|
||||
versionCode 3020141
|
||||
versionName "5.0.1"
|
||||
versionCode 3020142
|
||||
versionName "5.1.0"
|
||||
|
||||
def commit = ""
|
||||
try {
|
||||
|
|
|
@ -2,10 +2,10 @@ package de.test.podcini.service.playback
|
|||
|
||||
import ac.mdiq.podcini.storage.model.playback.MediaType
|
||||
import ac.mdiq.podcini.storage.model.playback.Playable
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.PSMPCallback
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerCallback
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo
|
||||
|
||||
class CancelablePSMPCallback(private val originalCallback: PSMPCallback) : PSMPCallback {
|
||||
class CancelableMediaPlayerCallback(private val originalCallback: MediaPlayerCallback) : MediaPlayerCallback {
|
||||
private var isCancelled = false
|
||||
|
||||
fun cancel() {
|
|
@ -2,10 +2,10 @@ package de.test.podcini.service.playback
|
|||
|
||||
import ac.mdiq.podcini.storage.model.playback.MediaType
|
||||
import ac.mdiq.podcini.storage.model.playback.Playable
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.PSMPCallback
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerCallback
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo
|
||||
|
||||
open class DefaultPSMPCallback : PSMPCallback {
|
||||
open class DefaultMediaPlayerCallback : MediaPlayerCallback {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
}
|
||||
|
|
@ -97,7 +97,7 @@ class MediaPlayerBaseTest {
|
|||
@UiThreadTest
|
||||
fun testInit() {
|
||||
val c = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val psmp: MediaPlayerBase = LocalMediaPlayer(c, DefaultPSMPCallback())
|
||||
val psmp: MediaPlayerBase = LocalMediaPlayer(c, DefaultMediaPlayerCallback())
|
||||
psmp.shutdown()
|
||||
}
|
||||
|
||||
|
@ -125,7 +125,7 @@ class MediaPlayerBaseTest {
|
|||
fun testPlayMediaObjectStreamNoStartNoPrepare() {
|
||||
val c = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val countDownLatch = CountDownLatch(2)
|
||||
val callback = CancelablePSMPCallback(object : DefaultPSMPCallback() {
|
||||
val callback = CancelableMediaPlayerCallback(object : DefaultMediaPlayerCallback() {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
try {
|
||||
checkPSMPInfo(newInfo)
|
||||
|
@ -155,7 +155,7 @@ class MediaPlayerBaseTest {
|
|||
if (assertionError != null) throw assertionError!!
|
||||
Assert.assertTrue(res)
|
||||
|
||||
Assert.assertSame(PlayerStatus.INITIALIZED, psmp.pSMPInfo.playerStatus)
|
||||
Assert.assertSame(PlayerStatus.INITIALIZED, psmp.playerInfo.playerStatus)
|
||||
Assert.assertFalse(psmp.isStartWhenPrepared())
|
||||
callback.cancel()
|
||||
psmp.shutdown()
|
||||
|
@ -167,7 +167,7 @@ class MediaPlayerBaseTest {
|
|||
fun testPlayMediaObjectStreamStartNoPrepare() {
|
||||
val c = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val countDownLatch = CountDownLatch(2)
|
||||
val callback = CancelablePSMPCallback(object : DefaultPSMPCallback() {
|
||||
val callback = CancelableMediaPlayerCallback(object : DefaultMediaPlayerCallback() {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
try {
|
||||
checkPSMPInfo(newInfo)
|
||||
|
@ -198,7 +198,7 @@ class MediaPlayerBaseTest {
|
|||
if (assertionError != null) throw assertionError!!
|
||||
Assert.assertTrue(res)
|
||||
|
||||
Assert.assertSame(PlayerStatus.INITIALIZED, psmp.pSMPInfo.playerStatus)
|
||||
Assert.assertSame(PlayerStatus.INITIALIZED, psmp.playerInfo.playerStatus)
|
||||
Assert.assertTrue(psmp.isStartWhenPrepared())
|
||||
callback.cancel()
|
||||
psmp.shutdown()
|
||||
|
@ -210,7 +210,7 @@ class MediaPlayerBaseTest {
|
|||
fun testPlayMediaObjectStreamNoStartPrepare() {
|
||||
val c = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val countDownLatch = CountDownLatch(4)
|
||||
val callback = CancelablePSMPCallback(object : DefaultPSMPCallback() {
|
||||
val callback = CancelableMediaPlayerCallback(object : DefaultMediaPlayerCallback() {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
try {
|
||||
checkPSMPInfo(newInfo)
|
||||
|
@ -244,7 +244,7 @@ class MediaPlayerBaseTest {
|
|||
val res = countDownLatch.await(LATCH_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS)
|
||||
if (assertionError != null) throw assertionError!!
|
||||
Assert.assertTrue(res)
|
||||
Assert.assertSame(PlayerStatus.PREPARED, psmp.pSMPInfo.playerStatus)
|
||||
Assert.assertSame(PlayerStatus.PREPARED, psmp.playerInfo.playerStatus)
|
||||
callback.cancel()
|
||||
|
||||
psmp.shutdown()
|
||||
|
@ -256,7 +256,7 @@ class MediaPlayerBaseTest {
|
|||
fun testPlayMediaObjectStreamStartPrepare() {
|
||||
val c = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val countDownLatch = CountDownLatch(5)
|
||||
val callback = CancelablePSMPCallback(object : DefaultPSMPCallback() {
|
||||
val callback = CancelableMediaPlayerCallback(object : DefaultMediaPlayerCallback() {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
try {
|
||||
checkPSMPInfo(newInfo)
|
||||
|
@ -293,7 +293,7 @@ class MediaPlayerBaseTest {
|
|||
val res = countDownLatch.await(LATCH_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS)
|
||||
if (assertionError != null) throw assertionError!!
|
||||
Assert.assertTrue(res)
|
||||
Assert.assertSame(PlayerStatus.PLAYING, psmp.pSMPInfo.playerStatus)
|
||||
Assert.assertSame(PlayerStatus.PLAYING, psmp.playerInfo.playerStatus)
|
||||
callback.cancel()
|
||||
psmp.shutdown()
|
||||
}
|
||||
|
@ -304,7 +304,7 @@ class MediaPlayerBaseTest {
|
|||
fun testPlayMediaObjectLocalNoStartNoPrepare() {
|
||||
val c = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val countDownLatch = CountDownLatch(2)
|
||||
val callback = CancelablePSMPCallback(object : DefaultPSMPCallback() {
|
||||
val callback = CancelableMediaPlayerCallback(object : DefaultMediaPlayerCallback() {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
try {
|
||||
checkPSMPInfo(newInfo)
|
||||
|
@ -333,7 +333,7 @@ class MediaPlayerBaseTest {
|
|||
val res = countDownLatch.await(LATCH_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS)
|
||||
if (assertionError != null) throw assertionError!!
|
||||
Assert.assertTrue(res)
|
||||
Assert.assertSame(PlayerStatus.INITIALIZED, psmp.pSMPInfo.playerStatus)
|
||||
Assert.assertSame(PlayerStatus.INITIALIZED, psmp.playerInfo.playerStatus)
|
||||
Assert.assertFalse(psmp.isStartWhenPrepared())
|
||||
callback.cancel()
|
||||
psmp.shutdown()
|
||||
|
@ -345,7 +345,7 @@ class MediaPlayerBaseTest {
|
|||
fun testPlayMediaObjectLocalStartNoPrepare() {
|
||||
val c = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val countDownLatch = CountDownLatch(2)
|
||||
val callback = CancelablePSMPCallback(object : DefaultPSMPCallback() {
|
||||
val callback = CancelableMediaPlayerCallback(object : DefaultMediaPlayerCallback() {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
try {
|
||||
checkPSMPInfo(newInfo)
|
||||
|
@ -374,7 +374,7 @@ class MediaPlayerBaseTest {
|
|||
val res = countDownLatch.await(LATCH_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS)
|
||||
if (assertionError != null) throw assertionError!!
|
||||
Assert.assertTrue(res)
|
||||
Assert.assertSame(PlayerStatus.INITIALIZED, psmp.pSMPInfo.playerStatus)
|
||||
Assert.assertSame(PlayerStatus.INITIALIZED, psmp.playerInfo.playerStatus)
|
||||
Assert.assertTrue(psmp.isStartWhenPrepared())
|
||||
callback.cancel()
|
||||
psmp.shutdown()
|
||||
|
@ -386,7 +386,7 @@ class MediaPlayerBaseTest {
|
|||
fun testPlayMediaObjectLocalNoStartPrepare() {
|
||||
val c = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val countDownLatch = CountDownLatch(4)
|
||||
val callback = CancelablePSMPCallback(object : DefaultPSMPCallback() {
|
||||
val callback = CancelableMediaPlayerCallback(object : DefaultMediaPlayerCallback() {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
try {
|
||||
checkPSMPInfo(newInfo)
|
||||
|
@ -420,7 +420,7 @@ class MediaPlayerBaseTest {
|
|||
val res = countDownLatch.await(LATCH_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS)
|
||||
if (assertionError != null) throw assertionError!!
|
||||
Assert.assertTrue(res)
|
||||
Assert.assertSame(PlayerStatus.PREPARED, psmp.pSMPInfo.playerStatus)
|
||||
Assert.assertSame(PlayerStatus.PREPARED, psmp.playerInfo.playerStatus)
|
||||
callback.cancel()
|
||||
psmp.shutdown()
|
||||
}
|
||||
|
@ -431,7 +431,7 @@ class MediaPlayerBaseTest {
|
|||
fun testPlayMediaObjectLocalStartPrepare() {
|
||||
val c = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val countDownLatch = CountDownLatch(5)
|
||||
val callback = CancelablePSMPCallback(object : DefaultPSMPCallback() {
|
||||
val callback = CancelableMediaPlayerCallback(object : DefaultMediaPlayerCallback() {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
try {
|
||||
checkPSMPInfo(newInfo)
|
||||
|
@ -469,7 +469,7 @@ class MediaPlayerBaseTest {
|
|||
val res = countDownLatch.await(LATCH_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS)
|
||||
if (assertionError != null) throw assertionError!!
|
||||
Assert.assertTrue(res)
|
||||
Assert.assertSame(PlayerStatus.PLAYING, psmp.pSMPInfo.playerStatus)
|
||||
Assert.assertSame(PlayerStatus.PLAYING, psmp.playerInfo.playerStatus)
|
||||
callback.cancel()
|
||||
psmp.shutdown()
|
||||
}
|
||||
|
@ -485,7 +485,7 @@ class MediaPlayerBaseTest {
|
|||
val latchCount = if ((stream && reinit)) 2 else 1
|
||||
val countDownLatch = CountDownLatch(latchCount)
|
||||
|
||||
val callback = CancelablePSMPCallback(object : DefaultPSMPCallback() {
|
||||
val callback = CancelableMediaPlayerCallback(object : DefaultMediaPlayerCallback() {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
checkPSMPInfo(newInfo)
|
||||
when {
|
||||
|
@ -606,7 +606,7 @@ class MediaPlayerBaseTest {
|
|||
}
|
||||
val countDownLatch = CountDownLatch(latchCount)
|
||||
|
||||
val callback = CancelablePSMPCallback(object : DefaultPSMPCallback() {
|
||||
val callback = CancelableMediaPlayerCallback(object : DefaultMediaPlayerCallback() {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
checkPSMPInfo(newInfo)
|
||||
when {
|
||||
|
@ -665,7 +665,7 @@ class MediaPlayerBaseTest {
|
|||
val c = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val latchCount = 1
|
||||
val countDownLatch = CountDownLatch(latchCount)
|
||||
val callback = CancelablePSMPCallback(object : DefaultPSMPCallback() {
|
||||
val callback = CancelableMediaPlayerCallback(object : DefaultMediaPlayerCallback() {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
checkPSMPInfo(newInfo)
|
||||
if (newInfo!!.playerStatus == PlayerStatus.ERROR) {
|
||||
|
@ -696,7 +696,7 @@ class MediaPlayerBaseTest {
|
|||
|
||||
val res = countDownLatch.await(timeoutSeconds, TimeUnit.SECONDS)
|
||||
if (initialState != PlayerStatus.INITIALIZED) {
|
||||
Assert.assertEquals(initialState, psmp.pSMPInfo.playerStatus)
|
||||
Assert.assertEquals(initialState, psmp.playerInfo.playerStatus)
|
||||
}
|
||||
|
||||
if (assertionError != null) throw assertionError!!
|
||||
|
@ -738,7 +738,7 @@ class MediaPlayerBaseTest {
|
|||
val c = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val latchCount = 2
|
||||
val countDownLatch = CountDownLatch(latchCount)
|
||||
val callback = CancelablePSMPCallback(object : DefaultPSMPCallback() {
|
||||
val callback = CancelableMediaPlayerCallback(object : DefaultMediaPlayerCallback() {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
checkPSMPInfo(newInfo)
|
||||
if (newInfo!!.playerStatus == PlayerStatus.ERROR) {
|
||||
|
|
|
@ -2,14 +2,14 @@ package ac.mdiq.podcini.playback.cast
|
|||
|
||||
import android.content.Context
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.PSMPCallback
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerCallback
|
||||
|
||||
/**
|
||||
* Stub implementation of CastPsmp for Free build flavour
|
||||
*/
|
||||
object CastPsmp {
|
||||
@JvmStatic
|
||||
fun getInstanceIfConnected(context: Context, callback: PSMPCallback): MediaPlayerBase? {
|
||||
fun getInstanceIfConnected(context: Context, callback: MediaPlayerCallback): MediaPlayerBase? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -227,7 +227,7 @@ import kotlin.math.min
|
|||
return null
|
||||
}
|
||||
|
||||
@Throws(WifiSynchronizationServiceException::class)
|
||||
@Throws(SyncServiceException::class)
|
||||
override fun uploadSubscriptionChanges(added: List<String>, removed: List<String>): UploadChangesResponse? {
|
||||
Log.d(TAG, "uploadSubscriptionChanges does nothing")
|
||||
return null
|
||||
|
@ -280,7 +280,7 @@ import kotlin.math.min
|
|||
return newTimeStamp
|
||||
}
|
||||
|
||||
@Throws(WifiSynchronizationServiceException::class)
|
||||
@Throws(SyncServiceException::class)
|
||||
override fun uploadEpisodeActions(queuedEpisodeActions: List<EpisodeAction>): UploadChangesResponse {
|
||||
// Log.d(TAG, "uploadEpisodeActions called")
|
||||
var i = 0
|
||||
|
@ -292,7 +292,7 @@ import kotlin.math.min
|
|||
return WifiEpisodeActionPostResponse(System.currentTimeMillis() / 1000)
|
||||
}
|
||||
|
||||
@Throws(WifiSynchronizationServiceException::class)
|
||||
@Throws(SyncServiceException::class)
|
||||
private fun uploadEpisodeActionsPartial(queuedEpisodeActions: List<EpisodeAction>, from: Int, to: Int) {
|
||||
// Log.d(TAG, "uploadEpisodeActionsPartial called")
|
||||
try {
|
||||
|
@ -308,7 +308,7 @@ import kotlin.math.min
|
|||
sendToPeer("EpisodeActions", list.toString())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw WifiSynchronizationServiceException(e)
|
||||
throw SyncServiceException(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
package ac.mdiq.podcini.net.sync.wifi
|
||||
|
||||
import ac.mdiq.podcini.net.sync.model.SyncServiceException
|
||||
|
||||
class WifiSynchronizationServiceException(e: Throwable?) : SyncServiceException(e)
|
|
@ -20,7 +20,7 @@ import kotlin.concurrent.Volatile
|
|||
* Abstract class that allows for different implementations of the PlaybackServiceMediaPlayer for local
|
||||
* and remote (cast devices) playback.
|
||||
*/
|
||||
abstract class MediaPlayerBase protected constructor(protected val context: Context, protected val callback: PSMPCallback) {
|
||||
abstract class MediaPlayerBase protected constructor(protected val context: Context, protected val callback: MediaPlayerCallback) {
|
||||
|
||||
@Volatile
|
||||
private var oldPlayerStatus: PlayerStatus? = null
|
||||
|
@ -211,7 +211,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
|
|||
*/
|
||||
|
||||
@get:Synchronized
|
||||
val pSMPInfo: MediaPlayerInfo
|
||||
val playerInfo: MediaPlayerInfo
|
||||
/**
|
||||
* Returns a PSMInfo object that contains information about the current state of the PSMP object.
|
||||
*
|
||||
|
@ -312,7 +312,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
|
|||
* as the old one).
|
||||
*
|
||||
*
|
||||
* It will also call [PSMPCallback.onPlaybackPause] or [PSMPCallback.onPlaybackStart]
|
||||
* It will also call [MediaPlayerCallback.onPlaybackPause] or [MediaPlayerCallback.onPlaybackStart]
|
||||
* depending on the status change.
|
||||
*
|
||||
* @param newStatus The new PlayerStatus. This must not be null.
|
||||
|
@ -351,7 +351,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
|
|||
setPlayerStatus(newStatus, newMedia, Playable.INVALID_TIME)
|
||||
}
|
||||
|
||||
interface PSMPCallback {
|
||||
interface MediaPlayerCallback {
|
||||
fun statusChanged(newInfo: MediaPlayerInfo?)
|
||||
|
||||
fun shouldStop()
|
||||
|
|
|
@ -63,9 +63,7 @@ import kotlin.concurrent.Volatile
|
|||
* Manages the MediaPlayer object of the PlaybackService.
|
||||
*/
|
||||
@UnstableApi
|
||||
class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBase(context, callback) {
|
||||
|
||||
// private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPlayerBase(context, callback) {
|
||||
|
||||
@Volatile
|
||||
private var statusBeforeSeeking: PlayerStatus? = null
|
||||
|
@ -80,17 +78,11 @@ class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBa
|
|||
private var mediaType: MediaType
|
||||
private val startWhenPrepared = AtomicBoolean(false)
|
||||
|
||||
// @Volatile
|
||||
// private var pausedBecauseOfTransientAudiofocusLoss = false
|
||||
|
||||
@Volatile
|
||||
private var videoSize: Pair<Int, Int>? = null
|
||||
// private val audioFocusRequest: AudioFocusRequestCompat
|
||||
// private val audioFocusCanceller = Handler(Looper.getMainLooper())
|
||||
private var isShutDown = false
|
||||
private var seekLatch: CountDownLatch? = null
|
||||
|
||||
// from wrapper
|
||||
private val bufferUpdateInterval = 5L
|
||||
private val bufferingUpdateDisposable: Disposable
|
||||
private var mediaSource: MediaSource? = null
|
||||
|
@ -323,24 +315,18 @@ class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBa
|
|||
*/
|
||||
override fun resume() {
|
||||
if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) {
|
||||
// val focusGained = AudioManagerCompat.requestAudioFocus(audioManager, audioFocusRequest)
|
||||
Logd(TAG, "Audiofocus successfully requested")
|
||||
Logd(TAG, "Resuming/Starting playback")
|
||||
acquireWifiLockIfNecessary()
|
||||
setPlaybackParams(PlaybackSpeedUtils.getCurrentPlaybackSpeed(playable), UserPreferences.isSkipSilence)
|
||||
setVolume(1.0f, 1.0f)
|
||||
|
||||
// if (focusGained == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
Logd(TAG, "Audiofocus successfully requested")
|
||||
Logd(TAG, "Resuming/Starting playback")
|
||||
acquireWifiLockIfNecessary()
|
||||
setPlaybackParams(PlaybackSpeedUtils.getCurrentPlaybackSpeed(playable), UserPreferences.isSkipSilence)
|
||||
setVolume(1.0f, 1.0f)
|
||||
|
||||
if (playable != null && status == PlayerStatus.PREPARED && playable!!.getPosition() > 0) {
|
||||
val newPosition = RewindAfterPauseUtils.calculatePositionWithRewind(playable!!.getPosition(), playable!!.getLastPlayedTime())
|
||||
seekTo(newPosition)
|
||||
}
|
||||
play()
|
||||
|
||||
setPlayerStatus(PlayerStatus.PLAYING, playable)
|
||||
// pausedBecauseOfTransientAudiofocusLoss = false
|
||||
// } else Log.e(TAG, "Failed to request audio focus")
|
||||
if (playable != null && status == PlayerStatus.PREPARED && playable!!.getPosition() > 0) {
|
||||
val newPosition = RewindAfterPauseUtils.calculatePositionWithRewind(playable!!.getPosition(), playable!!.getLastPlayedTime())
|
||||
seekTo(newPosition)
|
||||
}
|
||||
play()
|
||||
setPlayerStatus(PlayerStatus.PLAYING, playable)
|
||||
} else Logd(TAG, "Call to resume() was ignored because current state of PSMP object is $status")
|
||||
}
|
||||
|
||||
|
@ -361,20 +347,12 @@ class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBa
|
|||
Logd(TAG, "Pausing playback.")
|
||||
exoPlayer?.pause()
|
||||
setPlayerStatus(PlayerStatus.PAUSED, playable, getPosition())
|
||||
// if (abandonFocus) {
|
||||
// abandonAudioFocus()
|
||||
// pausedBecauseOfTransientAudiofocusLoss = false
|
||||
// }
|
||||
if (isStreaming && reinit) reinit()
|
||||
} else {
|
||||
Logd(TAG, "Ignoring call to pause: Player is in $status state")
|
||||
}
|
||||
}
|
||||
|
||||
// private fun abandonAudioFocus() {
|
||||
// AudioManagerCompat.abandonAudioFocusRequest(audioManager, audioFocusRequest)
|
||||
// }
|
||||
|
||||
/**
|
||||
* Prepares media player for playback if the service is in the INITALIZED
|
||||
* state.
|
||||
|
@ -497,7 +475,10 @@ class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBa
|
|||
if (status == PlayerStatus.PLAYING || status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED)
|
||||
retVal = if (exoPlayer?.duration == C.TIME_UNSET) Playable.INVALID_TIME else exoPlayer!!.duration.toInt()
|
||||
|
||||
if (retVal <= 0 && playable != null && playable!!.getDuration() > 0) retVal = playable!!.getDuration()
|
||||
if (retVal <= 0) {
|
||||
val playableDur = playable?.getDuration() ?: -1
|
||||
if (playableDur > 0) retVal = playableDur
|
||||
}
|
||||
return retVal
|
||||
}
|
||||
|
||||
|
@ -509,8 +490,10 @@ class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBa
|
|||
// Log.d(TAG, "getPosition() ${playable?.getIdentifier()} $status")
|
||||
if (status.isAtLeast(PlayerStatus.PREPARED)) retVal = exoPlayer!!.currentPosition.toInt()
|
||||
|
||||
val playablePos = playable?.getPosition() ?: -1
|
||||
if (retVal <= 0 && playablePos >= 0) retVal = playablePos
|
||||
if (retVal <= 0) {
|
||||
val playablePos = playable?.getPosition() ?: -1
|
||||
if (playablePos >= 0) retVal = playablePos
|
||||
}
|
||||
return retVal
|
||||
}
|
||||
|
||||
|
@ -562,7 +545,6 @@ class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBa
|
|||
volumeRight *= adaptionFactor
|
||||
}
|
||||
}
|
||||
// playerWrapper?.setVolume(volumeLeft, volumeRight)
|
||||
|
||||
if (volumeLeft > 1) {
|
||||
exoPlayer!!.volume = 1f
|
||||
|
@ -599,7 +581,6 @@ class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBa
|
|||
status = PlayerStatus.STOPPED
|
||||
|
||||
isShutDown = true
|
||||
// abandonAudioFocus()
|
||||
releaseWifiLockIfNecessary()
|
||||
}
|
||||
|
||||
|
@ -681,61 +662,9 @@ class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBa
|
|||
setMediaPlayerListeners()
|
||||
}
|
||||
|
||||
// private val audioFocusChangeListener = OnAudioFocusChangeListener { focusChange ->
|
||||
// if (isShutDown) return@OnAudioFocusChangeListener
|
||||
//
|
||||
// when {
|
||||
// !PlaybackService.isRunning -> {
|
||||
// abandonAudioFocus()
|
||||
// Log.d(TAG, "onAudioFocusChange: PlaybackService is no longer running")
|
||||
// return@OnAudioFocusChangeListener
|
||||
// }
|
||||
// focusChange == AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
// Log.d(TAG, "Lost audio focus")
|
||||
// pause(true, reinit = false)
|
||||
//// callback.shouldStop()
|
||||
// }
|
||||
// focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK && !UserPreferences.shouldPauseForFocusLoss() -> {
|
||||
// if (playerStatus == PlayerStatus.PLAYING) {
|
||||
// Log.d(TAG, "Lost audio focus temporarily. Ducking...")
|
||||
// setVolume(0.25f, 0.25f)
|
||||
// pausedBecauseOfTransientAudiofocusLoss = false
|
||||
// }
|
||||
// }
|
||||
// focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
// if (playerStatus == PlayerStatus.PLAYING) {
|
||||
// Log.d(TAG, "Lost audio focus temporarily. Pausing...")
|
||||
// exoPlayer?.pause() // Pause without telling the PlaybackService
|
||||
// pausedBecauseOfTransientAudiofocusLoss = true
|
||||
// audioFocusCanceller.removeCallbacksAndMessages(null)
|
||||
// // Still did not get back the audio focus. Now actually pause.
|
||||
// audioFocusCanceller.postDelayed({ if (pausedBecauseOfTransientAudiofocusLoss) pause(abandonFocus = true, reinit = false) },
|
||||
// 30000)
|
||||
// }
|
||||
// }
|
||||
// focusChange == AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
// Log.d(TAG, "Gained audio focus")
|
||||
// audioFocusCanceller.removeCallbacksAndMessages(null)
|
||||
// if (pausedBecauseOfTransientAudiofocusLoss) play() // we paused => play now
|
||||
// else setVolume(1.0f, 1.0f) // we ducked => raise audio level back
|
||||
// pausedBecauseOfTransientAudiofocusLoss = false
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
init {
|
||||
mediaType = MediaType.UNKNOWN
|
||||
|
||||
// val audioAttributes = AudioAttributesCompat.Builder()
|
||||
// .setUsage(AudioAttributesCompat.USAGE_MEDIA)
|
||||
// .setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH)
|
||||
// .build()
|
||||
// audioFocusRequest = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
|
||||
// .setAudioAttributes(audioAttributes)
|
||||
// .setOnAudioFocusChangeListener(audioFocusChangeListener)
|
||||
// .setWillPauseWhenDucked(true)
|
||||
// .build()
|
||||
|
||||
if (exoPlayer == null) {
|
||||
setupPlayerListener()
|
||||
createStaticPlayer(context)
|
||||
|
@ -756,9 +685,6 @@ class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBa
|
|||
val position = getPosition()
|
||||
if (position >= 0) playable?.setPosition(position)
|
||||
|
||||
// if (exoPlayer == null) createStaticPlayer(context)
|
||||
// abandonAudioFocus()
|
||||
|
||||
Logd(TAG, "endPlayback $hasEnded $wasSkipped $shouldContinue $toStoppedState")
|
||||
// printStackTrace()
|
||||
|
||||
|
@ -896,7 +822,6 @@ class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBa
|
|||
companion object {
|
||||
private const val TAG = "LocalMediaPlayer"
|
||||
|
||||
// from wrapper
|
||||
const val BUFFERING_STARTED: Int = -1
|
||||
const val BUFFERING_ENDED: Int = -2
|
||||
const val ERROR_CODE_OFFSET: Int = 1000
|
||||
|
@ -935,44 +860,6 @@ class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBa
|
|||
.setAudioOffloadPreferences(audioOffloadPreferences)
|
||||
.build()
|
||||
|
||||
// exoplayerListener = object : Listener {
|
||||
// override fun onPlaybackStateChanged(playbackState: @State Int) {
|
||||
// Log.d(TAG, "onPlaybackStateChanged $playbackState")
|
||||
// when (playbackState) {
|
||||
// STATE_ENDED -> {
|
||||
// exoPlayer?.seekTo(C.TIME_UNSET)
|
||||
// if (audioCompletionListener != null) audioCompletionListener?.run()
|
||||
// }
|
||||
// STATE_BUFFERING -> bufferingUpdateListener?.accept(BUFFERING_STARTED)
|
||||
// else -> bufferingUpdateListener?.accept(BUFFERING_ENDED)
|
||||
// }
|
||||
// }
|
||||
// override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
// val status = if (isPlaying) PlayerStatus.PLAYING else PlayerStatus.PAUSED
|
||||
// setPlayerStatus(status, )
|
||||
// Log.d(TAG, "onIsPlayingChanged $isPlaying")
|
||||
//// if (!isPlaying) context.sendBroadcast(createIntent(context, KeyEvent.KEYCODE_MEDIA_PAUSE))
|
||||
// }
|
||||
// override fun onPlayerError(error: PlaybackException) {
|
||||
// Log.d(TAG, "onPlayerError ${error.message}")
|
||||
// if (wasDownloadBlocked(error)) audioErrorListener?.accept(context.getString(R.string.download_error_blocked))
|
||||
// else {
|
||||
// var cause = error.cause
|
||||
// if (cause is HttpDataSourceException && cause.cause != null) cause = cause.cause
|
||||
// if (cause != null && "Source error" == cause.message) cause = cause.cause
|
||||
// audioErrorListener?.accept(if (cause != null) cause.message else error.message)
|
||||
// }
|
||||
// }
|
||||
// override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, reason: @DiscontinuityReason Int) {
|
||||
// Log.d(TAG, "onPositionDiscontinuity $oldPosition $newPosition $reason")
|
||||
// if (reason == DISCONTINUITY_REASON_SEEK) audioSeekCompleteListener?.run()
|
||||
// }
|
||||
// override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||
// Log.d(TAG, "onAudioSessionIdChanged $audioSessionId")
|
||||
// initLoudnessEnhancer(audioSessionId)
|
||||
// }
|
||||
// }
|
||||
|
||||
if (exoplayerListener != null) {
|
||||
exoPlayer?.removeListener(exoplayerListener!!)
|
||||
exoPlayer?.addListener(exoplayerListener!!)
|
||||
|
@ -988,7 +875,6 @@ class LocalMediaPlayer(context: Context, callback: PSMPCallback) : MediaPlayerBa
|
|||
if (oldEnhancer.enabled) newEnhancer.setTargetGain(oldEnhancer.targetGain.toInt())
|
||||
oldEnhancer.release()
|
||||
}
|
||||
|
||||
loudnessEnhancer = newEnhancer
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
|
|||
import ac.mdiq.podcini.playback.PlaybackServiceStarter
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.PSMPCallback
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerCallback
|
||||
import ac.mdiq.podcini.playback.base.PlayerStatus
|
||||
import ac.mdiq.podcini.playback.cast.CastPsmp
|
||||
import ac.mdiq.podcini.playback.cast.CastStateListener
|
||||
|
@ -129,7 +129,7 @@ class PlaybackService : MediaSessionService() {
|
|||
private val mBinder: IBinder = LocalBinder()
|
||||
|
||||
val mPlayerInfo: MediaPlayerInfo
|
||||
get() = mediaPlayer!!.pSMPInfo
|
||||
get() = mediaPlayer!!.playerInfo
|
||||
|
||||
val status: PlayerStatus
|
||||
get() = MediaPlayerBase.status
|
||||
|
@ -361,14 +361,6 @@ class PlaybackService : MediaSessionService() {
|
|||
// }
|
||||
return settable
|
||||
}
|
||||
|
||||
// this is just for testing
|
||||
// override fun onAddMediaItems(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList<MediaItem>): ListenableFuture<List<MediaItem>> {
|
||||
// val updatedMediaItems = mediaItems.map { mediaItem ->
|
||||
// mediaItem.buildUpon().setUri(mediaItem.requestMetadata.mediaUri).build()
|
||||
// }
|
||||
// return Futures.immediateFuture(updatedMediaItems)
|
||||
// }
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
|
@ -640,7 +632,7 @@ class PlaybackService : MediaSessionService() {
|
|||
*/
|
||||
private fun handleKeycode(keycode: Int, notificationButton: Boolean): Boolean {
|
||||
Logd(TAG, "Handling keycode: $keycode")
|
||||
val info = mediaPlayer?.pSMPInfo
|
||||
val info = mediaPlayer?.playerInfo
|
||||
val status = info?.playerStatus
|
||||
when (keycode) {
|
||||
KeyEvent.KEYCODE_HEADSETHOOK, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
|
@ -801,7 +793,7 @@ class PlaybackService : MediaSessionService() {
|
|||
}
|
||||
}
|
||||
|
||||
private val mediaPlayerCallback: PSMPCallback = object : PSMPCallback {
|
||||
private val mediaPlayerCallback: MediaPlayerCallback = object : MediaPlayerCallback {
|
||||
override fun statusChanged(newInfo: MediaPlayerInfo?) {
|
||||
currentMediaType = mediaPlayer?.getCurrentMediaType() ?: MediaType.UNKNOWN
|
||||
Logd(TAG, "statusChanged called ${newInfo?.playerStatus}")
|
||||
|
@ -809,11 +801,11 @@ class PlaybackService : MediaSessionService() {
|
|||
if (newInfo != null) {
|
||||
when (newInfo.playerStatus) {
|
||||
PlayerStatus.INITIALIZED -> {
|
||||
if (mediaPlayer != null) writeMediaPlaying(mediaPlayer!!.pSMPInfo.playable, mediaPlayer!!.pSMPInfo.playerStatus, currentitem)
|
||||
if (mediaPlayer != null) writeMediaPlaying(mediaPlayer!!.playerInfo.playable, mediaPlayer!!.playerInfo.playerStatus, currentitem)
|
||||
// updateNotificationAndMediaSession(newInfo.playable)
|
||||
}
|
||||
PlayerStatus.PREPARED -> {
|
||||
if (mediaPlayer != null) writeMediaPlaying(mediaPlayer!!.pSMPInfo.playable, mediaPlayer!!.pSMPInfo.playerStatus, currentitem)
|
||||
if (mediaPlayer != null) writeMediaPlaying(mediaPlayer!!.playerInfo.playable, mediaPlayer!!.playerInfo.playerStatus, currentitem)
|
||||
taskManager.startChapterLoader(newInfo.playable!!)
|
||||
}
|
||||
PlayerStatus.PAUSED -> {
|
||||
|
|
|
@ -306,12 +306,6 @@ class PlaybackServiceTaskManager(private val context: Context, private val callb
|
|||
|
||||
EventBus.getDefault().post(SleepTimerUpdatedEvent.cancelled())
|
||||
}
|
||||
|
||||
// companion object {
|
||||
// private const val TAG = "SleepTimer"
|
||||
// private const val UPDATE_INTERVAL = 1000L
|
||||
// const val NOTIFICATION_THRESHOLD: Long = 10000
|
||||
// }
|
||||
}
|
||||
|
||||
interface PSTMCallback {
|
||||
|
|
|
@ -7,9 +7,9 @@ import androidx.preference.ListPreference
|
|||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
class MaterialListPreference : ListPreference {
|
||||
constructor(context: Context) : super(context!!)
|
||||
constructor(context: Context) : super(context)
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context!!, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
|
||||
override fun onClick() {
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
|
|
|
@ -7,9 +7,10 @@ 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)
|
||||
constructor(context: Context) : super(context)
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
|
||||
override fun onClick() {
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
|
@ -22,8 +23,8 @@ class MaterialMultiSelectListPreference : MultiSelectListPreference {
|
|||
for (i in values.indices) {
|
||||
selected[i] = getValues().contains(values[i].toString())
|
||||
}
|
||||
builder.setMultiChoiceItems(entries, selected) { dialog: DialogInterface?, which: Int, isChecked: Boolean -> selected[which] = isChecked }
|
||||
builder.setPositiveButton("OK") { dialog: DialogInterface?, which: Int ->
|
||||
builder.setMultiChoiceItems(entries, selected) { _: DialogInterface?, which: Int, isChecked: Boolean -> selected[which] = isChecked }
|
||||
builder.setPositiveButton("OK") { _: DialogInterface?, _: Int ->
|
||||
val selectedValues: MutableSet<String> = HashSet()
|
||||
for (i in values.indices) {
|
||||
if (selected[i]) selectedValues.add(entryValues[i].toString())
|
||||
|
|
|
@ -21,6 +21,7 @@ import org.greenrobot.eventbus.EventBus
|
|||
* otherwise every public method will throw an Exception when called.
|
||||
*/
|
||||
class PlaybackPreferences private constructor() : OnSharedPreferenceChangeListener {
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
|
||||
if (PREF_CURRENT_PLAYER_STATUS == key) EventBus.getDefault().post(PlayerStatusEvent())
|
||||
}
|
||||
|
|
|
@ -103,7 +103,7 @@ object SleepTimerPreferences {
|
|||
@JvmStatic
|
||||
fun isInTimeRange(from: Int, to: Int, current: Int): Boolean {
|
||||
// Range covers one day
|
||||
if (from < to) return from <= current && current < to
|
||||
if (from < to) return current in from..<to
|
||||
|
||||
// Range covers two days
|
||||
if (from <= current) return true
|
||||
|
|
|
@ -154,7 +154,7 @@ object UserPreferences {
|
|||
}
|
||||
|
||||
@JvmStatic
|
||||
var theme: ThemePreference?
|
||||
var theme: ThemePreference
|
||||
get() = when (prefs.getString(PREF_THEME, "system")) {
|
||||
"0" -> ThemePreference.LIGHT
|
||||
"1" -> ThemePreference.DARK
|
||||
|
@ -175,7 +175,7 @@ object UserPreferences {
|
|||
get() = Build.VERSION.SDK_INT >= 31 && prefs.getBoolean(PREF_TINTED_COLORS, false)
|
||||
|
||||
@JvmStatic
|
||||
var hiddenDrawerItems: List<String?>
|
||||
var hiddenDrawerItems: List<String>
|
||||
get() {
|
||||
val hiddenItems = prefs.getString(PREF_HIDDEN_DRAWER_ITEMS, "")
|
||||
return ArrayList(listOf(*TextUtils.split(hiddenItems, ",")))
|
||||
|
@ -188,10 +188,9 @@ object UserPreferences {
|
|||
}
|
||||
|
||||
@JvmStatic
|
||||
var fullNotificationButtons: List<Int>?
|
||||
var fullNotificationButtons: List<Int>
|
||||
get() {
|
||||
val buttons = TextUtils.split(prefs.getString(PREF_FULL_NOTIFICATION_BUTTONS,
|
||||
"$NOTIFICATION_BUTTON_SKIP,$NOTIFICATION_BUTTON_PLAYBACK_SPEED"), ",")
|
||||
val buttons = TextUtils.split(prefs.getString(PREF_FULL_NOTIFICATION_BUTTONS, "$NOTIFICATION_BUTTON_SKIP,$NOTIFICATION_BUTTON_PLAYBACK_SPEED"), ",")
|
||||
val notificationButtons: MutableList<Int> = ArrayList()
|
||||
for (button in buttons) {
|
||||
notificationButtons.add(button.toInt())
|
||||
|
@ -509,7 +508,7 @@ object UserPreferences {
|
|||
val defaultValue = HashSet<String>()
|
||||
defaultValue.add("images")
|
||||
val getValueStringSet = prefs.getStringSet(PREF_MOBILE_UPDATE, defaultValue)
|
||||
val allowed: MutableSet<String> = HashSet(getValueStringSet)
|
||||
val allowed: MutableSet<String> = HashSet(getValueStringSet!!)
|
||||
if (allow) allowed.add(type)
|
||||
else allowed.remove(type)
|
||||
|
||||
|
|
|
@ -1,6 +1,18 @@
|
|||
package ac.mdiq.podcini.preferences.fragments
|
||||
|
||||
import android.app.Activity
|
||||
import ac.mdiq.podcini.PodciniApp.Companion.forceRestart
|
||||
import ac.mdiq.podcini.R
|
||||
import ac.mdiq.podcini.storage.DatabaseTransporter
|
||||
import ac.mdiq.podcini.storage.PreferencesTransporter
|
||||
import ac.mdiq.podcini.storage.asynctask.DocumentFileExportWorker
|
||||
import ac.mdiq.podcini.storage.asynctask.ExportWorker
|
||||
import ac.mdiq.podcini.storage.export.ExportWriter
|
||||
import ac.mdiq.podcini.storage.export.favorites.FavoritesWriter
|
||||
import ac.mdiq.podcini.storage.export.html.HtmlWriter
|
||||
import ac.mdiq.podcini.storage.export.opml.OpmlWriter
|
||||
import ac.mdiq.podcini.ui.activity.OpmlImportActivity
|
||||
import ac.mdiq.podcini.ui.activity.PreferenceActivity
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.app.ProgressDialog
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
|
@ -21,17 +33,6 @@ import androidx.preference.Preference
|
|||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import ac.mdiq.podcini.PodciniApp.Companion.forceRestart
|
||||
import ac.mdiq.podcini.R
|
||||
import ac.mdiq.podcini.ui.activity.OpmlImportActivity
|
||||
import ac.mdiq.podcini.ui.activity.PreferenceActivity
|
||||
import ac.mdiq.podcini.storage.asynctask.DocumentFileExportWorker
|
||||
import ac.mdiq.podcini.storage.asynctask.ExportWorker
|
||||
import ac.mdiq.podcini.storage.export.ExportWriter
|
||||
import ac.mdiq.podcini.storage.export.favorites.FavoritesWriter
|
||||
import ac.mdiq.podcini.storage.export.html.HtmlWriter
|
||||
import ac.mdiq.podcini.storage.export.opml.OpmlWriter
|
||||
import ac.mdiq.podcini.storage.DatabaseTransporter
|
||||
import io.reactivex.Completable
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.disposables.Disposable
|
||||
|
@ -41,6 +42,7 @@ import java.text.SimpleDateFormat
|
|||
import java.util.*
|
||||
|
||||
class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
|
||||
|
||||
private val chooseOpmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
|
||||
this.chooseOpmlExportPathResult(result) }
|
||||
private val chooseHtmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
|
||||
|
@ -53,10 +55,15 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
|
|||
private val chooseOpmlImportPathLauncher = registerForActivityResult<String, Uri>(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
this.chooseOpmlImportPathResult(uri) }
|
||||
|
||||
// TODO: implement
|
||||
private val restorePreferencesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
|
||||
this.restorePreferencesResult(result) }
|
||||
private val backupPreferencesLauncher = registerForActivityResult<String, Uri>(BackupPreferences()) { uri: Uri? -> this.backupPreferencesResult(uri) }
|
||||
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 var disposable: Disposable? = null
|
||||
private var progressDialog: ProgressDialog? = null
|
||||
|
@ -145,14 +152,29 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: implement
|
||||
private fun exportPreferences() {
|
||||
// backupDatabaseLauncher.launch(dateStampFilename(DATABASE_EXPORT_FILENAME))
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
intent.addCategory(Intent.CATEGORY_DEFAULT)
|
||||
backupPreferencesLauncher.launch(intent)
|
||||
}
|
||||
|
||||
// TODO: implement
|
||||
private fun importPreferences() {
|
||||
// backupDatabaseLauncher.launch(dateStampFilename(DATABASE_EXPORT_FILENAME))
|
||||
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 exportDatabase() {
|
||||
|
@ -186,7 +208,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
|
|||
builder.show()
|
||||
}
|
||||
|
||||
fun showExportSuccessSnackbar(uri: Uri?, mimeType: String?) {
|
||||
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()
|
||||
|
@ -202,25 +224,25 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
|
||||
private fun chooseOpmlExportPathResult(result: ActivityResult) {
|
||||
if (result.resultCode != Activity.RESULT_OK || result.data == null) return
|
||||
if (result.resultCode != RESULT_OK || result.data == null) return
|
||||
val uri = result.data!!.data
|
||||
exportWithWriter(OpmlWriter(), uri, Export.OPML)
|
||||
}
|
||||
|
||||
private fun chooseHtmlExportPathResult(result: ActivityResult) {
|
||||
if (result.resultCode != Activity.RESULT_OK || result.data == null) return
|
||||
if (result.resultCode != RESULT_OK || result.data == null) return
|
||||
val uri = result.data!!.data
|
||||
exportWithWriter(HtmlWriter(), uri, Export.HTML)
|
||||
}
|
||||
|
||||
private fun chooseFavoritesExportPathResult(result: ActivityResult) {
|
||||
if (result.resultCode != Activity.RESULT_OK || result.data == null) return
|
||||
if (result.resultCode != RESULT_OK || result.data == null) return
|
||||
val uri = result.data!!.data
|
||||
exportWithWriter(FavoritesWriter(), uri, Export.FAVORITES)
|
||||
}
|
||||
|
||||
private fun restoreDatabaseResult(result: ActivityResult) {
|
||||
if (result.resultCode != Activity.RESULT_OK || result.data == null) return
|
||||
if (result.resultCode != RESULT_OK || result.data == null) return
|
||||
val uri = result.data!!.data
|
||||
progressDialog!!.show()
|
||||
disposable = Completable.fromAction { DatabaseTransporter.importBackup(uri, requireContext()) }
|
||||
|
@ -232,6 +254,19 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
|
|||
}, { error: Throwable -> this.showExportErrorDialog(error) })
|
||||
}
|
||||
|
||||
private fun restorePreferencesResult(result: ActivityResult) {
|
||||
if (result.resultCode != RESULT_OK || result.data?.data == null) return
|
||||
val uri = result.data!!.data!!
|
||||
progressDialog!!.show()
|
||||
disposable = Completable.fromAction { PreferencesTransporter.importBackup(uri, requireContext()) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({
|
||||
showDatabaseImportSuccessDialog()
|
||||
progressDialog!!.dismiss()
|
||||
}, { error: Throwable -> this.showExportErrorDialog(error) })
|
||||
}
|
||||
|
||||
private fun backupDatabaseResult(uri: Uri?) {
|
||||
if (uri == null) return
|
||||
progressDialog!!.show()
|
||||
|
@ -244,31 +279,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
|
|||
}, { error: Throwable -> this.showExportErrorDialog(error) })
|
||||
}
|
||||
|
||||
private fun restorePreferencesResult(result: ActivityResult) {
|
||||
if (result.resultCode != Activity.RESULT_OK || result.data == null) return
|
||||
// val uri = result.data!!.data
|
||||
// progressDialog!!.show()
|
||||
// disposable = Completable.fromAction { DatabaseTransporter.importBackup(uri, requireContext()) }
|
||||
// .subscribeOn(Schedulers.io())
|
||||
// .observeOn(AndroidSchedulers.mainThread())
|
||||
// .subscribe({
|
||||
// showDatabaseImportSuccessDialog()
|
||||
// progressDialog!!.dismiss()
|
||||
// }, { error: Throwable -> this.showExportErrorDialog(error) })
|
||||
}
|
||||
|
||||
private fun backupPreferencesResult(uri: Uri?) {
|
||||
if (uri == null) return
|
||||
// progressDialog!!.show()
|
||||
// disposable = Completable.fromAction { DatabaseTransporter.exportToDocument(uri, requireContext()) }
|
||||
// .subscribeOn(Schedulers.io())
|
||||
// .observeOn(AndroidSchedulers.mainThread())
|
||||
// .subscribe({
|
||||
// showExportSuccessSnackbar(uri, "application/x-sqlite3")
|
||||
// progressDialog!!.dismiss()
|
||||
// }, { error: Throwable -> this.showExportErrorDialog(error) })
|
||||
}
|
||||
|
||||
private fun chooseOpmlImportPathResult(uri: Uri?) {
|
||||
if (uri == null) return
|
||||
val intent = Intent(context, OpmlImportActivity::class.java)
|
||||
|
@ -306,19 +316,10 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
private class BackupPreferences : CreateDocument() {
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
return super.createIntent(context, input)
|
||||
// .addCategory(Intent.CATEGORY_OPENABLE)
|
||||
// .setType("application/x-sqlite3")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private enum class Export(val contentType: String, val outputNameTemplate: String, @field:StringRes val labelResId: Int) {
|
||||
OPML(CONTENT_TYPE_OPML, DEFAULT_OPML_OUTPUT_NAME, R.string.opml_export_label),
|
||||
HTML(CONTENT_TYPE_HTML, DEFAULT_HTML_OUTPUT_NAME, R.string.html_export_label),
|
||||
FAVORITES(CONTENT_TYPE_HTML, DEFAULT_FAVORITES_OUTPUT_NAME, R.string.favorites_export_label)
|
||||
FAVORITES(CONTENT_TYPE_HTML, DEFAULT_FAVORITES_OUTPUT_NAME, R.string.favorites_export_label),
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -6,6 +6,7 @@ import ac.mdiq.podcini.ui.activity.PreferenceActivity
|
|||
import ac.mdiq.podcini.ui.activity.PreferenceActivity.Companion.getTitleOfPage
|
||||
import ac.mdiq.podcini.preferences.fragments.about.AboutFragment
|
||||
import ac.mdiq.podcini.util.IntentUtils.openInBrowser
|
||||
import ac.mdiq.podcini.util.Logd
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.graphics.PorterDuff
|
||||
|
@ -18,6 +19,8 @@ import com.bytehamster.lib.preferencesearch.SearchPreference
|
|||
|
||||
class MainPreferencesFragment : PreferenceFragmentCompat() {
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
Logd("MainPreferencesFragment", "onCreatePreferences")
|
||||
|
||||
addPreferencesFromResource(R.xml.preferences)
|
||||
setupMainScreen()
|
||||
setupSearch()
|
||||
|
@ -128,7 +131,7 @@ class MainPreferencesFragment : PreferenceFragmentCompat() {
|
|||
private fun setupSearch() {
|
||||
val searchPreference = findPreference<SearchPreference>("searchPreference")
|
||||
val config = searchPreference!!.searchConfiguration
|
||||
config.setActivity((activity as AppCompatActivity?)!!)
|
||||
config.setActivity((activity as AppCompatActivity))
|
||||
config.setFragmentContainerViewId(R.id.settingsContainer)
|
||||
config.setBreadcrumbsEnabled(true)
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ import com.google.android.material.snackbar.Snackbar
|
|||
import org.greenrobot.eventbus.EventBus
|
||||
|
||||
class UserInterfacePreferencesFragment : PreferenceFragmentCompat() {
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.preferences_user_interface)
|
||||
setupInterfaceScreen()
|
||||
|
|
|
@ -21,7 +21,7 @@ class AboutFragment : PreferenceFragmentCompat() {
|
|||
|
||||
findPreference<Preference>("about_version")!!.summary = String.format("%s (%s)", BuildConfig.VERSION_NAME, BuildConfig.COMMIT_HASH)
|
||||
findPreference<Preference>("about_version")!!.onPreferenceClickListener =
|
||||
Preference.OnPreferenceClickListener { preference: Preference? ->
|
||||
Preference.OnPreferenceClickListener {
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText(getString(R.string.bug_report_title), findPreference<Preference>("about_version")!!.summary)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
|
@ -29,7 +29,7 @@ class AboutFragment : PreferenceFragmentCompat() {
|
|||
true
|
||||
}
|
||||
findPreference<Preference>("about_contributors")!!.onPreferenceClickListener =
|
||||
Preference.OnPreferenceClickListener { preference: Preference? ->
|
||||
Preference.OnPreferenceClickListener {
|
||||
parentFragmentManager.beginTransaction()
|
||||
.replace(R.id.settingsContainer, ContributorsPagerFragment())
|
||||
.addToBackStack(getString(R.string.contributors))
|
||||
|
@ -37,12 +37,12 @@ class AboutFragment : PreferenceFragmentCompat() {
|
|||
true
|
||||
}
|
||||
findPreference<Preference>("about_privacy_policy")!!.onPreferenceClickListener =
|
||||
Preference.OnPreferenceClickListener { preference: Preference? ->
|
||||
Preference.OnPreferenceClickListener {
|
||||
openInBrowser(requireContext(), "https://github.com/XilinJia/Podcini/blob/main/PrivacyPolicy.md")
|
||||
true
|
||||
}
|
||||
findPreference<Preference>("about_licenses")!!.onPreferenceClickListener =
|
||||
Preference.OnPreferenceClickListener { preference: Preference? ->
|
||||
Preference.OnPreferenceClickListener {
|
||||
parentFragmentManager.beginTransaction()
|
||||
.replace(R.id.settingsContainer, LicensesFragment())
|
||||
.addToBackStack(getString(R.string.translators))
|
||||
|
|
|
@ -60,7 +60,7 @@ class LicensesFragment : ListFragment() {
|
|||
val items = arrayOf<CharSequence>("View website", "View license")
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(item.title)
|
||||
.setItems(items) { dialog: DialogInterface?, which: Int ->
|
||||
.setItems(items) { _: DialogInterface?, which: Int ->
|
||||
when (which) {
|
||||
0 -> openInBrowser(requireContext(), item.licenseUrl)
|
||||
1 -> showLicenseText(item.licenseTextFile)
|
||||
|
|
|
@ -2,14 +2,13 @@ package ac.mdiq.podcini.preferences.fragments.synchronization
|
|||
|
||||
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.setSelectedSyncProvider
|
||||
import ac.mdiq.podcini.net.sync.gpoddernet.GpodnetService
|
||||
import ac.mdiq.podcini.net.sync.gpoddernet.model.GpodnetDevice
|
||||
import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient
|
||||
import ac.mdiq.podcini.net.sync.SynchronizationSettings.setSelectedSyncProvider
|
||||
import ac.mdiq.podcini.util.FileNameGenerator.generateFileName
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
|
|
|
@ -1,20 +1,19 @@
|
|||
package ac.mdiq.podcini.preferences.fragments.synchronization
|
||||
|
||||
import ac.mdiq.podcini.R
|
||||
import ac.mdiq.podcini.databinding.NextcloudAuthDialogBinding
|
||||
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.setSelectedSyncProvider
|
||||
import ac.mdiq.podcini.net.sync.nextcloud.NextcloudLoginFlow
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import ac.mdiq.podcini.R
|
||||
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.databinding.NextcloudAuthDialogBinding
|
||||
import ac.mdiq.podcini.net.sync.SynchronizationSettings.setSelectedSyncProvider
|
||||
import ac.mdiq.podcini.net.sync.nextcloud.NextcloudLoginFlow
|
||||
|
||||
/**
|
||||
* Guides the user through the authentication process.
|
||||
|
|
|
@ -98,7 +98,7 @@ class SynchronizationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
true
|
||||
}
|
||||
|
||||
val loggedIn = SynchronizationSettings.isProviderConnected
|
||||
val loggedIn = isProviderConnected
|
||||
val preferenceHeader = findPreference<Preference>(PREFERENCE_SYNCHRONIZATION_DESCRIPTION)
|
||||
if (loggedIn) {
|
||||
val selectedProvider = SynchronizationProviderViewData.fromIdentifier(selectedSyncProviderKey)
|
||||
|
|
|
@ -636,7 +636,7 @@ object DBReader {
|
|||
* @param item The FeedItem
|
||||
*/
|
||||
fun loadTextDetailsOfFeedItem(item: FeedItem) {
|
||||
Logd(TAG, "loadTextOfFeedItem() called with: item = [$item]")
|
||||
Logd(TAG, "loadTextDetailsOfFeedItem() called with: item = [${item.title}]")
|
||||
// TODO: need to find out who are often calling this
|
||||
// printStackTrace()
|
||||
val adapter = getInstance()
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
package ac.mdiq.podcini.storage
|
||||
|
||||
import ac.mdiq.podcini.util.Logd
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import java.io.*
|
||||
|
||||
object PreferencesTransporter {
|
||||
private const val TAG = "PreferencesTransporter"
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun exportToDocument(uri: Uri, context: Context) {
|
||||
try {
|
||||
val chosenDir = DocumentFile.fromTreeUri(context, uri) ?: throw IOException("Destination directory is not valid")
|
||||
val exportSubDir = chosenDir.createDirectory("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()
|
||||
for (file in files) {
|
||||
if (file?.isFile == true && file.name?.endsWith(".xml") == true) {
|
||||
val destFile = File(sharedPreferencesDir, file.name!!)
|
||||
copyFile(file, destFile, context)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, Log.getStackTraceString(e))
|
||||
throw e
|
||||
} finally { }
|
||||
|
||||
}
|
||||
}
|
|
@ -38,6 +38,7 @@ class PreferenceActivity : AppCompatActivity(), SearchPreferenceResultListener {
|
|||
setTheme(getTheme(this))
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
Logd("PreferenceActivity", "onCreate")
|
||||
val ab = supportActionBar
|
||||
ab?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
|
|
|
@ -118,6 +118,7 @@ class SelectSubscriptionActivity : AppCompatActivity() {
|
|||
|
||||
val request = ImageRequest.Builder(this)
|
||||
.data(feed.imageUrl)
|
||||
.setHeader("User-Agent", "Mozilla/5.0")
|
||||
.placeholder(R.color.light_gray)
|
||||
.listener(object : ImageRequest.Listener {
|
||||
@OptIn(UnstableApi::class) override fun onError(request: ImageRequest, throwable: ErrorResult) {
|
||||
|
|
|
@ -2,10 +2,12 @@ package ac.mdiq.podcini.ui.adapter
|
|||
|
||||
import ac.mdiq.podcini.R
|
||||
import ac.mdiq.podcini.ui.activity.MainActivity
|
||||
import ac.mdiq.podcini.util.Logd
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.imageLoader
|
||||
import coil.request.ErrorResult
|
||||
|
@ -60,43 +62,24 @@ class CoverLoader(private val activity: MainActivity) {
|
|||
fun load() {
|
||||
if (imgvCover == null) return
|
||||
|
||||
// val coverTarget = CoverTarget(fallbackTitle, imgvCover!!, textAndImageCombined)
|
||||
val coverTargetCoil = CoilCoverTarget(fallbackTitle, imgvCover!!, textAndImageCombined)
|
||||
|
||||
if (resource != 0) {
|
||||
// Glide.with(imgvCover!!).clear(coverTarget)
|
||||
val imageLoader = ImageLoader.Builder(activity).build()
|
||||
imageLoader.enqueue(ImageRequest.Builder(activity).data(null).target(coverTargetCoil).build())
|
||||
imgvCover!!.setImageResource(resource)
|
||||
// CoverTarget.setTitleVisibility(fallbackTitle, textAndImageCombined)
|
||||
CoilCoverTarget.setTitleVisibility(fallbackTitle, textAndImageCombined)
|
||||
return
|
||||
}
|
||||
|
||||
// val options: RequestOptions = RequestOptions()
|
||||
// .fitCenter()
|
||||
// .dontAnimate()
|
||||
//
|
||||
// var builder: RequestBuilder<Drawable?> = Glide.with(imgvCover!!)
|
||||
// .`as`(Drawable::class.java)
|
||||
// .load(uri)
|
||||
// .apply(options)
|
||||
//
|
||||
// if (!fallbackUri.isNullOrBlank()) {
|
||||
// builder = builder.error(Glide.with(imgvCover!!)
|
||||
// .`as`(Drawable::class.java)
|
||||
// .load(fallbackUri)
|
||||
// .apply(options))
|
||||
// }
|
||||
//
|
||||
// builder.into<CoverTarget>(coverTarget)
|
||||
|
||||
val request = ImageRequest.Builder(activity)
|
||||
.data(uri)
|
||||
.setHeader("User-Agent", "Mozilla/5.0")
|
||||
.listener(object : ImageRequest.Listener {
|
||||
override fun onError(request: ImageRequest, throwable: ErrorResult) {
|
||||
val fallbackImageRequest = ImageRequest.Builder(activity)
|
||||
.data(fallbackUri)
|
||||
.setHeader("User-Agent", "Mozilla/5.0")
|
||||
.error(R.mipmap.ic_launcher)
|
||||
.target(coverTargetCoil)
|
||||
.build()
|
||||
|
@ -105,39 +88,11 @@ class CoverLoader(private val activity: MainActivity) {
|
|||
})
|
||||
.target(coverTargetCoil)
|
||||
.build()
|
||||
activity.imageLoader.enqueue(request)
|
||||
activity.imageLoader
|
||||
.enqueue(request)
|
||||
|
||||
}
|
||||
|
||||
// internal class CoverTarget(fallbackTitle: TextView?, coverImage: ImageView, private val textAndImageCombined: Boolean)
|
||||
// : CustomViewTarget<ImageView, Drawable>(coverImage) {
|
||||
//
|
||||
// private val fallbackTitle: WeakReference<TextView?> = WeakReference<TextView?>(fallbackTitle)
|
||||
// private val cover: WeakReference<ImageView> = WeakReference(coverImage)
|
||||
//
|
||||
// override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
// setTitleVisibility(fallbackTitle.get(), true)
|
||||
// }
|
||||
//
|
||||
// override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable?>?) {
|
||||
// val ivCover = cover.get()
|
||||
// ivCover!!.setImageDrawable(resource)
|
||||
// setTitleVisibility(fallbackTitle.get(), textAndImageCombined)
|
||||
// }
|
||||
//
|
||||
// override fun onResourceCleared(placeholder: Drawable?) {
|
||||
// val ivCover = cover.get()
|
||||
// ivCover!!.setImageDrawable(placeholder)
|
||||
// setTitleVisibility(fallbackTitle.get(), textAndImageCombined)
|
||||
// }
|
||||
//
|
||||
// companion object {
|
||||
// fun setTitleVisibility(fallbackTitle: TextView?, textAndImageCombined: Boolean) {
|
||||
// fallbackTitle?.visibility = if (textAndImageCombined) View.VISIBLE else View.GONE
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
internal class CoilCoverTarget(fallbackTitle: TextView?, coverImage: ImageView, private val textAndImageCombined: Boolean) : Target {
|
||||
|
||||
private val fallbackTitle: WeakReference<TextView?> = WeakReference<TextView?>(fallbackTitle)
|
||||
|
@ -156,12 +111,6 @@ class CoverLoader(private val activity: MainActivity) {
|
|||
setTitleVisibility(fallbackTitle.get(), textAndImageCombined)
|
||||
}
|
||||
|
||||
// override fun onResourceCleared(placeholder: Drawable?) {
|
||||
// val ivCover = cover.get()
|
||||
// ivCover!!.setImageDrawable(placeholder)
|
||||
// setTitleVisibility(fallbackTitle.get(), textAndImageCombined)
|
||||
// }
|
||||
|
||||
companion object {
|
||||
fun setTitleVisibility(fallbackTitle: TextView?, textAndImageCombined: Boolean) {
|
||||
fallbackTitle?.visibility = if (textAndImageCombined) View.VISIBLE else View.GONE
|
||||
|
|
|
@ -700,11 +700,13 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
|
|||
val imageLoader = imgvCover.context.imageLoader
|
||||
val imageRequest = ImageRequest.Builder(requireContext())
|
||||
.data(imgLoc)
|
||||
.setHeader("User-Agent", "Mozilla/5.0")
|
||||
.placeholder(R.color.light_gray)
|
||||
.listener(object : ImageRequest.Listener {
|
||||
override fun onError(request: ImageRequest, throwable: ErrorResult) {
|
||||
val fallbackImageRequest = ImageRequest.Builder(requireContext())
|
||||
.data(imgLocFB)
|
||||
.setHeader("User-Agent", "Mozilla/5.0")
|
||||
.error(R.mipmap.ic_launcher)
|
||||
.target(imgvCover)
|
||||
.build()
|
||||
|
|
|
@ -15,10 +15,8 @@ import android.webkit.WebView
|
|||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.view.MenuHost
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
|
@ -247,9 +245,19 @@ class EpisodeHomeFragment : Fragment() {
|
|||
updateAppearance()
|
||||
}
|
||||
|
||||
private fun cleatWebview(webview: WebView) {
|
||||
binding.root.removeView(webview)
|
||||
webview.clearHistory()
|
||||
webview.clearCache(true)
|
||||
webview.clearView()
|
||||
webview.destroy()
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class) override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
Log.d(TAG, "onDestroyView")
|
||||
cleatWebview(binding.webView)
|
||||
cleatWebview(binding.readerView)
|
||||
_binding = null
|
||||
disposable?.dispose()
|
||||
tts?.stop()
|
||||
|
|
|
@ -99,8 +99,8 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
item = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) requireArguments().getSerializable(ARG_FEEDITEM, FeedItem::class.java)
|
||||
else requireArguments().getSerializable(ARG_FEEDITEM) as? FeedItem
|
||||
// item = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) requireArguments().getSerializable(ARG_FEEDITEM, FeedItem::class.java)
|
||||
// else requireArguments().getSerializable(ARG_FEEDITEM) as? FeedItem
|
||||
}
|
||||
|
||||
@UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
|
@ -260,6 +260,9 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
|
|||
controller?.release()
|
||||
disposable?.dispose()
|
||||
root.removeView(webvDescription)
|
||||
webvDescription.clearHistory()
|
||||
webvDescription.clearCache(true)
|
||||
webvDescription.clearView()
|
||||
webvDescription.destroy()
|
||||
}
|
||||
|
||||
|
@ -300,6 +303,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
|
|||
override fun onError(request: ImageRequest, throwable: ErrorResult) {
|
||||
val fallbackImageRequest = ImageRequest.Builder(requireContext())
|
||||
.data(imgLocFB)
|
||||
.setHeader("User-Agent", "Mozilla/5.0")
|
||||
.error(R.mipmap.ic_launcher)
|
||||
.target(imgvCover)
|
||||
.build()
|
||||
|
@ -411,6 +415,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
|
|||
disposable?.dispose()
|
||||
if (!itemsLoaded) progbarLoading.visibility = View.VISIBLE
|
||||
|
||||
Logd(TAG, "load() called")
|
||||
disposable = Observable.fromCallable<FeedItem?> { this.loadInBackground() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
|
@ -435,6 +440,10 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
|
|||
return feedItem
|
||||
}
|
||||
|
||||
fun setItem(item_: FeedItem) {
|
||||
item = item_
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EpisodeInfoFragment"
|
||||
private const val ARG_FEEDITEM = "feeditem"
|
||||
|
@ -442,9 +451,10 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
|
|||
@JvmStatic
|
||||
fun newInstance(item: FeedItem): EpisodeInfoFragment {
|
||||
val fragment = EpisodeInfoFragment()
|
||||
val args = Bundle()
|
||||
args.putSerializable(ARG_FEEDITEM, item)
|
||||
fragment.arguments = args
|
||||
fragment.setItem(item)
|
||||
// val args = Bundle()
|
||||
// args.putSerializable(ARG_FEEDITEM, item)
|
||||
// fragment.arguments = args
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
|
|
|
@ -316,11 +316,13 @@ class PlayerDetailsFragment : Fragment() {
|
|||
val imageLoader = binding.imgvCover.context.imageLoader
|
||||
val imageRequest = ImageRequest.Builder(requireContext())
|
||||
.data(media!!.getImageLocation())
|
||||
.setHeader("User-Agent", "Mozilla/5.0")
|
||||
.placeholder(R.color.light_gray)
|
||||
.listener(object : ImageRequest.Listener {
|
||||
override fun onError(request: ImageRequest, throwable: ErrorResult) {
|
||||
val fallbackImageRequest = ImageRequest.Builder(requireContext())
|
||||
.data(ImageResourceUtils.getFallbackImageLocation(media!!))
|
||||
.setHeader("User-Agent", "Mozilla/5.0")
|
||||
.error(R.mipmap.ic_launcher)
|
||||
.target(binding.imgvCover)
|
||||
.build()
|
||||
|
@ -342,11 +344,13 @@ class PlayerDetailsFragment : Fragment() {
|
|||
val imageLoader = binding.imgvCover.context.imageLoader
|
||||
val imageRequest = ImageRequest.Builder(requireContext())
|
||||
.data(imgLoc)
|
||||
.setHeader("User-Agent", "Mozilla/5.0")
|
||||
.placeholder(R.color.light_gray)
|
||||
.listener(object : ImageRequest.Listener {
|
||||
override fun onError(request: ImageRequest, throwable: ErrorResult) {
|
||||
val fallbackImageRequest = ImageRequest.Builder(requireContext())
|
||||
.data(ImageResourceUtils.getFallbackImageLocation(media!!))
|
||||
.setHeader("User-Agent", "Mozilla/5.0")
|
||||
.error(R.mipmap.ic_launcher)
|
||||
.target(binding.imgvCover)
|
||||
.build()
|
||||
|
|
|
@ -81,7 +81,6 @@ class ShownotesCleaner(context: Context, private val rawShownotes: String, priva
|
|||
if (elementsWithTimeCodes.size == 0) return // No elements with timecodes
|
||||
|
||||
var useHourFormat = true
|
||||
|
||||
if (playableDuration != Int.MAX_VALUE) {
|
||||
// We need to decide if we are going to treat short timecodes as HH:MM or MM:SS. To do
|
||||
// so we will parse all the short timecodes and see if they fit in the duration. If one
|
||||
|
@ -91,10 +90,8 @@ class ShownotesCleaner(context: Context, private val rawShownotes: String, priva
|
|||
val matcherForElement = TIMECODE_REGEX.matcher(element.html())
|
||||
while (matcherForElement.find()) {
|
||||
// We only want short timecodes right now.
|
||||
|
||||
if (matcherForElement.group(1) == null) {
|
||||
val time = durationStringShortToMs(matcherForElement.group(0)!!, true)
|
||||
|
||||
// If the parsed timecode is greater then the duration then we know we need to
|
||||
// use the minute format so we are done.
|
||||
if (time > playableDuration) {
|
||||
|
@ -103,7 +100,6 @@ class ShownotesCleaner(context: Context, private val rawShownotes: String, priva
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!useHourFormat) break
|
||||
}
|
||||
}
|
||||
|
@ -114,13 +110,10 @@ class ShownotesCleaner(context: Context, private val rawShownotes: String, priva
|
|||
|
||||
while (matcherForElement.find()) {
|
||||
val group = matcherForElement.group(0) ?: continue
|
||||
|
||||
val time = if (matcherForElement.group(1) != null) durationStringLongToMs(group)
|
||||
else durationStringShortToMs(group, useHourFormat)
|
||||
|
||||
var replacementText = group
|
||||
if (time < playableDuration) replacementText = String.format(Locale.US, TIMECODE_LINK, time, group)
|
||||
|
||||
matcherForElement.appendReplacement(buffer, replacementText)
|
||||
}
|
||||
|
||||
|
|
|
@ -20,9 +20,6 @@ import ac.mdiq.podcini.R
|
|||
import ac.mdiq.podcini.databinding.FeeditemlistItemBinding
|
||||
import ac.mdiq.podcini.ui.adapter.CoverLoader
|
||||
import ac.mdiq.podcini.feed.util.ImageResourceUtils
|
||||
import ac.mdiq.podcini.util.DateFormatter
|
||||
import ac.mdiq.podcini.util.NetworkUtils
|
||||
import ac.mdiq.podcini.util.PlaybackStatus
|
||||
import ac.mdiq.podcini.net.download.MediaSizeLoader
|
||||
import ac.mdiq.podcini.util.event.playback.PlaybackPositionEvent
|
||||
import ac.mdiq.podcini.storage.model.feed.FeedItem
|
||||
|
@ -37,8 +34,10 @@ import ac.mdiq.podcini.ui.actions.actionbutton.ItemActionButton
|
|||
import ac.mdiq.podcini.ui.actions.actionbutton.TTSActionButton
|
||||
import ac.mdiq.podcini.ui.view.CircularProgressBar
|
||||
import ac.mdiq.podcini.ui.utils.ThemeUtils
|
||||
import ac.mdiq.podcini.util.Converter
|
||||
import ac.mdiq.podcini.util.*
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.ContextCompat.getDrawable
|
||||
import io.reactivex.functions.Consumer
|
||||
import kotlin.math.max
|
||||
|
||||
|
@ -117,32 +116,39 @@ class EpisodeItemViewHolder(private val activity: MainActivity, parent: ViewGrou
|
|||
}
|
||||
|
||||
// Log.d(TAG, "bind called ${item.media}")
|
||||
if (item.media != null) {
|
||||
bind(item.media!!)
|
||||
} else if (item.playState == BUILDING) {
|
||||
// for generating TTS files for episode without media
|
||||
secondaryActionProgress.setPercentage(actionButton!!.processing, item)
|
||||
secondaryActionProgress.setIndeterminate(false)
|
||||
} else {
|
||||
secondaryActionProgress.setPercentage(0f, item)
|
||||
secondaryActionProgress.setIndeterminate(false)
|
||||
isVideo.visibility = View.GONE
|
||||
progressBar.visibility = View.GONE
|
||||
duration.visibility = View.GONE
|
||||
position.visibility = View.GONE
|
||||
itemView.setBackgroundResource(ThemeUtils.getDrawableFromAttr(activity, R.attr.selectableItemBackground))
|
||||
when {
|
||||
item.media != null -> {
|
||||
bind(item.media!!)
|
||||
}
|
||||
item.playState == BUILDING -> {
|
||||
// for generating TTS files for episode without media
|
||||
secondaryActionProgress.setPercentage(actionButton!!.processing, item)
|
||||
secondaryActionProgress.setIndeterminate(false)
|
||||
}
|
||||
else -> {
|
||||
secondaryActionProgress.setPercentage(0f, item)
|
||||
secondaryActionProgress.setIndeterminate(false)
|
||||
isVideo.visibility = View.GONE
|
||||
progressBar.visibility = View.GONE
|
||||
duration.visibility = View.GONE
|
||||
position.visibility = View.GONE
|
||||
itemView.setBackgroundResource(ThemeUtils.getDrawableFromAttr(activity, R.attr.selectableItemBackground))
|
||||
}
|
||||
}
|
||||
|
||||
if (coverHolder.visibility == View.VISIBLE) {
|
||||
val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(item)
|
||||
// Log.d(TAG, "imgLoc $imgLoc")
|
||||
Logd(TAG, "imgLoc $imgLoc ${item.feed?.imageUrl} ${item.title}")
|
||||
if (!imgLoc.isNullOrBlank() && !imgLoc.contains(PREFIX_GENERATIVE_COVER)) CoverLoader(activity)
|
||||
.withUri(imgLoc)
|
||||
.withFallbackUri(item.feed?.imageUrl)
|
||||
.withPlaceholderView(placeholder)
|
||||
.withCoverView(cover)
|
||||
.load()
|
||||
else cover.setImageResource(R.mipmap.ic_launcher)
|
||||
else {
|
||||
Logd(TAG, "setting to ic_launcher")
|
||||
cover.setImageDrawable(AppCompatResources.getDrawable(activity, R.drawable.ic_launcher_foreground))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -81,12 +81,14 @@ object WidgetUpdater {
|
|||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(imgLoc)
|
||||
.setHeader("User-Agent", "Mozilla/5.0")
|
||||
.placeholder(R.color.light_gray)
|
||||
.listener(object : ImageRequest.Listener {
|
||||
override fun onError(request: ImageRequest, throwable: ErrorResult) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val fallbackImageRequest = ImageRequest.Builder(context)
|
||||
.data(imgLoc1)
|
||||
.setHeader("User-Agent", "Mozilla/5.0")
|
||||
.error(R.mipmap.ic_launcher)
|
||||
.size(iconSize, iconSize)
|
||||
.build()
|
||||
|
|
|
@ -609,6 +609,7 @@
|
|||
<string name="html_export_label">HTML export</string>
|
||||
<string name="preferences_export_label">Preferences export</string>
|
||||
<string name="preferences_import_label">Preferences import</string>
|
||||
<string name="preferences_import_warning">Importing preferences will replace all of your current preferences. If confirmed, choose a previously exported directory with name containing \"Podcini-Prefs\"</string>
|
||||
<string name="database_export_label">Database export</string>
|
||||
<string name="database_import_label">Database import</string>
|
||||
<string name="database_import_warning">Importing a database will replace all of your current subscriptions and playing history. You should export your current database as a backup. Do you want to replace\?</string>
|
||||
|
|
|
@ -16,18 +16,18 @@
|
|||
android:summary="@string/database_import_summary"/>
|
||||
</PreferenceCategory>
|
||||
|
||||
<!-- <PreferenceCategory android:title="@string/preferences">-->
|
||||
<!-- <Preference-->
|
||||
<!-- android:key="prefPrefExport"-->
|
||||
<!-- search:keywords="@string/import_export_search_keywords"-->
|
||||
<!-- android:title="@string/preferences_export_label"-->
|
||||
<!-- android:summary="@string/preferences_export_summary"/>-->
|
||||
<!-- <Preference-->
|
||||
<!-- android:key="prefPrefImport"-->
|
||||
<!-- search:keywords="@string/import_export_search_keywords"-->
|
||||
<!-- android:title="@string/preferences_import_label"-->
|
||||
<!-- android:summary="@string/preferences_import_summary"/>-->
|
||||
<!-- </PreferenceCategory>-->
|
||||
<PreferenceCategory android:title="@string/preferences">
|
||||
<Preference
|
||||
android:key="prefPrefExport"
|
||||
search:keywords="@string/import_export_search_keywords"
|
||||
android:title="@string/preferences_export_label"
|
||||
android:summary="@string/preferences_export_summary"/>
|
||||
<Preference
|
||||
android:key="prefPrefImport"
|
||||
search:keywords="@string/import_export_search_keywords"
|
||||
android:title="@string/preferences_import_label"
|
||||
android:summary="@string/preferences_import_summary"/>
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory android:title="@string/opml">
|
||||
<Preference
|
||||
|
|
|
@ -1,3 +1,9 @@
|
|||
## 5.1.0
|
||||
|
||||
* properly destroys WebView objects
|
||||
* fixed issue loading image lacking a header
|
||||
* preferences now can be exported/imported
|
||||
|
||||
## 5.0.1
|
||||
|
||||
* fixed crash when opening Import/Export in settings
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
Version 5.1.0 brings several changes:
|
||||
|
||||
* properly destroys WebView objects
|
||||
* fixed issue loading image lacking a header
|
||||
* preferences now can be exported/imported
|
Loading…
Reference in New Issue