drastic project restructuring

This commit is contained in:
Xilin Jia 2024-02-25 11:28:34 +01:00
parent 09dc565fe9
commit 62a6288740
1345 changed files with 57600 additions and 61024 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

View File

@ -6,11 +6,15 @@ This is based on a fork from the popular project AntennaPod (<https://github.com
Differing from the forked project, this project is purely Kotlin based, relies on the most recent dependencies, and most importantly has migrated the media player to androidx.media3, and added mechanism of AudioOffloadMode which is supposed to be kind to device battery. Efficiencies are also sought on running the app. App build is also upgraded to target Android 14.
### Screenshots
## Version 4.0
Some drastic changes are made in the project since version 4.0. There is now a whole new interface of the Subscriptions page showing only the feeds with tags as filters, no longer having tags as folders in the page. And the default page of the app is changed to the Subscriptions page. Alongside, the Home and Echo pages are removed from the project. Also, the project becomes mono-module, with only the app module.
## Screenshots
<img src="./images/1_drawer.jpg" width="238" /> <img src="./images/2_setting.jpg" width="238" /> <img src="./images/3_setting.jpg" width="238" />
<img src="./images/4_home.jpg" width="238" /> <img src="./images/5_queue.jpg" width="238" />
<img src="./images/4_subscriptions.jpg" width="238" /> <img src="./images/5_queue.jpg" width="238" />
<img src="./images/6_podcast.jpg" width="238" /> <img src="./images/7_podcast.jpg" width="238" /> <img src="./images/8_episode.jpg" width="238" />
@ -28,4 +32,4 @@ Podcini, same as its forked project AntennaPod, is licensed under the GNU Genera
New files and modifications in the project is copyrighted in 2024 by Xilin Jia.
Original files from the forked project maintains copyrights of the AntennaPod team.
Original contents from the forked project maintains copyrights of the AntennaPod team.

View File

@ -22,8 +22,8 @@ android {
// Version code schema:
// "1.2.3-beta4" -> 1020304
// "1.2.3" -> 1020395
versionCode 3020100
versionName "3.2.5"
versionCode 3020101
versionName "4.0.1"
def commit = ""
try {
@ -37,11 +37,20 @@ android {
}
buildConfigField "String", "COMMIT_HASH", ('"' + (commit.isEmpty() ? "Unknown commit" : commit) + '"')
javaCompileOptions {
annotationProcessorOptions {
arguments = [eventBusIndex: 'ac.mdiq.podcini.ApEventBusIndex']
}
// javaCompileOptions {
// annotationProcessorOptions {
// arguments = [eventBusIndex: 'ac.mdiq.podcini.ApEventBusIndex']
// }
// }
if (project.hasProperty("podcastindexApiKey")) {
buildConfigField "String", "PODCASTINDEX_API_KEY", '"' + podcastindexApiKey + '"'
buildConfigField "String", "PODCASTINDEX_API_SECRET", '"' + podcastindexApiSecret + '"'
} else {
buildConfigField "String", "PODCASTINDEX_API_KEY", '"XTMMQGA2YZ4WJUBYY4HK"'
buildConfigField "String", "PODCASTINDEX_API_SECRET", '"XAaAhk4^2YBsTE33vdbwbZNj82ZRLABDDqFdKe7x"'
}
}
signingConfigs {
releaseConfig {
@ -91,46 +100,35 @@ dependencies {
}
}
implementation project(":core")
implementation project(":event")
implementation project(':model')
implementation project(':net:common')
implementation project(':net:discovery')
implementation project(':net:download:service-interface')
implementation project(':net:sync:gpoddernet')
implementation project(':net:sync:model')
implementation project(':parser:feed')
implementation project(':playback:base')
implementation project(':playback:cast')
implementation project(':storage:database')
implementation project(':storage:preferences')
implementation project(':ui:app-start-intent')
implementation project(':ui:common')
implementation project(':ui:echo')
implementation project(':ui:glide')
implementation project(':ui:i18n')
implementation project(':ui:statistics')
kapt "androidx.annotation:annotation:$annotationVersion"
kapt "androidx.annotation:annotation:1.7.1"
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0'
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.fragment:fragment-ktx:$fragmentVersion"
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation "androidx.media:media:$mediaVersion"
implementation "androidx.media3:media3-exoplayer:$media3Version"
implementation "androidx.media3:media3-ui:$media3Version"
implementation "androidx.media3:media3-datasource-okhttp:$media3Version"
implementation "androidx.media3:media3-common:$media3Version"
implementation "androidx.palette:palette-ktx:$paletteVersion"
implementation "androidx.preference:preference-ktx:$preferenceVersion"
implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion"
implementation "androidx.viewpager2:viewpager2:$viewPager2Version"
implementation "androidx.work:work-runtime:$workManagerVersion"
implementation "androidx.core:core-splashscreen:1.0.1"
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation "com.google.android.material:material:$googleMaterialVersion"
implementation "org.apache.commons:commons-lang3:$commonslangVersion"
implementation "commons-io:commons-io:$commonsioVersion"
implementation "org.jsoup:jsoup:$jsoupVersion"
implementation "com.github.bumptech.glide:glide:$glideVersion"
implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion@aar"
annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "com.squareup.okhttp3:okhttp-urlconnection:$okhttpVersion"
implementation "com.squareup.okio:okio:$okioVersion"
@ -152,7 +150,6 @@ dependencies {
playImplementation 'com.google.android.play:core-ktx:1.8.0'
compileOnly "com.google.android.wearable:wearable:$wearableSupportVersion"
androidTestImplementation "org.awaitility:awaitility:$awaitilityVersion"
androidTestImplementation 'com.nanohttpd:nanohttpd:2.1.1'
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"
@ -160,6 +157,33 @@ dependencies {
androidTestImplementation "androidx.test:runner:$runnerVersion"
androidTestImplementation "androidx.test:rules:$rulesVersion"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation "org.awaitility:awaitility:$awaitilityVersion"
implementation "com.annimon:stream:$annimonStreamVersion"
implementation 'com.github.mfietz:fyydlin:v0.5.0'
// Non-free dependencies:
testImplementation "androidx.test:core:$testCoreVersion"
testImplementation "org.awaitility:awaitility:$awaitilityVersion"
testImplementation "junit:junit:$junitVersion"
testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation "org.robolectric:robolectric:$robolectricVersion"
testImplementation 'javax.inject:javax.inject:1'
playImplementation 'com.google.android.gms:play-services-base:17.5.0'
freeImplementation 'org.conscrypt:conscrypt-android:2.5.2'
playApi 'androidx.mediarouter:mediarouter:1.6.0'
playApi "com.google.android.support:wearable:$wearableSupportVersion"
playApi 'com.google.android.gms:play-services-cast-framework:21.2.0'
}
kapt {
arguments {
arg('eventBusIndex', 'ac.mdiq.podcini.ApEventBusIndex')
}
}
if (project.hasProperty("podciniPlayPublisherCredentials")) {

View File

@ -17,15 +17,15 @@ import androidx.test.espresso.util.HumanReadables
import androidx.test.espresso.util.TreeIterables
import androidx.test.platform.app.InstrumentationRegistry
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.MainActivity
import ac.mdiq.podcini.core.service.playback.PlaybackService
import ac.mdiq.podcini.dialog.RatingDialog
import ac.mdiq.podcini.dialog.RatingDialog.saveRated
import ac.mdiq.podcini.fragment.NavDrawerFragment
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.service.playback.PlaybackService
import ac.mdiq.podcini.ui.dialog.RatingDialog
import ac.mdiq.podcini.ui.dialog.RatingDialog.saveRated
import ac.mdiq.podcini.ui.fragment.NavDrawerFragment
import ac.mdiq.podcini.storage.database.PodDBAdapter.Companion.deleteDatabase
import ac.mdiq.podcini.storage.database.PodDBAdapter.Companion.getInstance
import ac.mdiq.podcini.storage.database.PodDBAdapter.Companion.init
import ac.mdiq.podcini.storage.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences
import junit.framework.AssertionFailedError
import org.awaitility.Awaitility
import org.awaitility.core.ConditionTimeoutException

View File

@ -11,8 +11,8 @@ import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.MainActivity
import ac.mdiq.podcini.fragment.AllEpisodesFragment
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.fragment.AllEpisodesFragment
import de.test.podcini.EspressoTestUtils
import de.test.podcini.NthMatcher
import de.test.podcini.ui.UITestUtils

View File

@ -13,19 +13,19 @@ import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.MainActivity
import ac.mdiq.podcini.core.preferences.PlaybackPreferences.Companion.currentlyPlayingFeedMediaId
import ac.mdiq.podcini.core.receiver.MediaButtonReceiver.Companion.createIntent
import ac.mdiq.podcini.core.storage.DBReader.getEpisodes
import ac.mdiq.podcini.core.storage.DBReader.getFeedItem
import ac.mdiq.podcini.core.storage.DBReader.getQueue
import ac.mdiq.podcini.core.storage.DBReader.getQueueIDList
import ac.mdiq.podcini.core.storage.DBWriter.clearQueue
import ac.mdiq.podcini.core.util.playback.PlaybackController
import ac.mdiq.podcini.model.feed.FeedItemFilter.Companion.unfiltered
import ac.mdiq.podcini.model.feed.SortOrder
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.currentlyPlayingFeedMediaId
import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createIntent
import ac.mdiq.podcini.storage.DBReader.getEpisodes
import ac.mdiq.podcini.storage.DBReader.getFeedItem
import ac.mdiq.podcini.storage.DBReader.getQueue
import ac.mdiq.podcini.storage.DBReader.getQueueIDList
import ac.mdiq.podcini.storage.DBWriter.clearQueue
import ac.mdiq.podcini.playback.PlaybackController
import ac.mdiq.podcini.storage.model.feed.FeedItemFilter.Companion.unfiltered
import ac.mdiq.podcini.storage.model.feed.SortOrder
import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.storage.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences
import de.test.podcini.EspressoTestUtils
import de.test.podcini.IgnoreOnCi
import de.test.podcini.ui.UITestUtils
@ -44,7 +44,7 @@ class PlaybackTest {
var activityTestRule: ActivityTestRule<MainActivity> = ActivityTestRule(MainActivity::class.java, false, false)
private var uiTestUtils: UITestUtils? = null
protected var context: Context? = null
protected lateinit var context: Context
private var controller: PlaybackController? = null
@Before
@ -54,7 +54,7 @@ class PlaybackTest {
EspressoTestUtils.clearPreferences()
EspressoTestUtils.clearDatabase()
uiTestUtils = UITestUtils(context!!)
uiTestUtils = UITestUtils(context)
uiTestUtils!!.setup()
}
@ -192,28 +192,28 @@ class PlaybackTest {
}
protected fun setContinuousPlaybackPreference(value: Boolean) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context!!)
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.edit().putBoolean(UserPreferences.PREF_FOLLOW_QUEUE, value).commit()
}
protected fun setSkipKeepsEpisodePreference(value: Boolean) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context!!)
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.edit().putBoolean(UserPreferences.PREF_SKIP_KEEPS_EPISODE, value).commit()
}
protected fun setSmartMarkAsPlayedPreference(smartMarkAsPlayedSecs: Int) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context!!)
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.edit().putString(UserPreferences.PREF_SMART_MARK_AS_PLAYED_SECS,
smartMarkAsPlayedSecs.toString(10))
.commit()
}
private fun skipEpisode() {
context!!.sendBroadcast(createIntent(context, KeyEvent.KEYCODE_MEDIA_NEXT))
context.sendBroadcast(createIntent(context, KeyEvent.KEYCODE_MEDIA_NEXT))
}
protected fun pauseEpisode() {
context!!.sendBroadcast(createIntent(context, KeyEvent.KEYCODE_MEDIA_PAUSE))
context.sendBroadcast(createIntent(context, KeyEvent.KEYCODE_MEDIA_PAUSE))
}
protected fun startLocalPlayback() {

View File

@ -3,12 +3,12 @@ package de.test.podcini.service.download
import android.util.Log
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import ac.mdiq.podcini.core.service.download.Downloader
import ac.mdiq.podcini.core.service.download.HttpDownloader
import ac.mdiq.podcini.model.download.DownloadError
import ac.mdiq.podcini.model.feed.FeedFile
import ac.mdiq.podcini.service.download.Downloader
import ac.mdiq.podcini.service.download.HttpDownloader
import ac.mdiq.podcini.storage.model.download.DownloadError
import ac.mdiq.podcini.storage.model.feed.FeedFile
import ac.mdiq.podcini.net.download.serviceinterface.DownloadRequest
import ac.mdiq.podcini.storage.preferences.UserPreferences.init
import ac.mdiq.podcini.preferences.UserPreferences.init
import de.test.podcini.util.service.download.HTTPBin
import org.junit.After
import org.junit.Assert
@ -28,10 +28,11 @@ class HttpDownloaderTest {
@Throws(Exception::class)
fun tearDown() {
val contents = destDir!!.listFiles()
for (f in contents) {
Assert.assertTrue(f.delete())
if (contents != null) {
for (f in contents) {
Assert.assertTrue(f.delete())
}
}
httpServer!!.stop()
}
@ -61,18 +62,18 @@ class HttpDownloaderTest {
private fun download(url: String?, title: String, expectedResult: Boolean, deleteExisting: Boolean = true,
username: String? = null, password: String? = null
): Downloader {
): ac.mdiq.podcini.service.download.Downloader {
val feedFile: FeedFile = setupFeedFile(url, title, deleteExisting)
val request = DownloadRequest(
feedFile.getFile_url()!!, url!!, title, 0, feedFile.getTypeAsInt(),
username, password, null, false)
val downloader: Downloader = HttpDownloader(request)
val downloader: ac.mdiq.podcini.service.download.Downloader = HttpDownloader(request)
downloader.call()
val status = downloader.result
Assert.assertNotNull(status)
Assert.assertEquals(expectedResult, status.isSuccessful)
// the file should not exist if the download has failed and deleteExisting was true
Assert.assertTrue(!deleteExisting || File(feedFile.getFile_url()).exists() == expectedResult)
Assert.assertTrue(!deleteExisting || File(feedFile.getFile_url()!!).exists() == expectedResult)
return downloader
}
@ -100,7 +101,7 @@ class HttpDownloaderTest {
fun testCancel() {
val url = httpServer!!.baseUrl + "/delay/3"
val feedFile = setupFeedFile(url, "delay", true)
val downloader: Downloader = HttpDownloader(DownloadRequest(
val downloader: ac.mdiq.podcini.service.download.Downloader = HttpDownloader(DownloadRequest(
feedFile.getFile_url()!!, url, "delay", 0,
feedFile.getTypeAsInt(), null, null, null, false))
val t: Thread = object : Thread() {
@ -122,7 +123,7 @@ class HttpDownloaderTest {
@Test
fun testDeleteOnFailShouldDelete() {
val downloader = download(url404, "testDeleteOnFailShouldDelete", false, true, null, null)
Assert.assertFalse(File(downloader.downloadRequest.destination).exists())
Assert.assertFalse(File(downloader.downloadRequest.destination!!).exists())
}
@Test
@ -133,7 +134,7 @@ class HttpDownloaderTest {
dest.delete()
Assert.assertTrue(dest.createNewFile())
val downloader = download(url404, filename, false, false, null, null)
Assert.assertTrue(File(downloader.downloadRequest.destination).exists())
Assert.assertTrue(File(downloader.downloadRequest.destination!!).exists())
}
@Test

View File

@ -1,7 +1,7 @@
package de.test.podcini.service.playback
import ac.mdiq.podcini.model.playback.MediaType
import ac.mdiq.podcini.model.playback.Playable
import ac.mdiq.podcini.storage.model.playback.MediaType
import ac.mdiq.podcini.storage.model.playback.Playable
import ac.mdiq.podcini.playback.base.PlaybackServiceMediaPlayer.PSMPCallback
import ac.mdiq.podcini.playback.base.PlaybackServiceMediaPlayer.PSMPInfo

View File

@ -1,7 +1,7 @@
package de.test.podcini.service.playback
import ac.mdiq.podcini.model.playback.MediaType
import ac.mdiq.podcini.model.playback.Playable
import ac.mdiq.podcini.storage.model.playback.MediaType
import ac.mdiq.podcini.storage.model.playback.Playable
import ac.mdiq.podcini.playback.base.PlaybackServiceMediaPlayer.PSMPCallback
import ac.mdiq.podcini.playback.base.PlaybackServiceMediaPlayer.PSMPInfo

View File

@ -3,9 +3,9 @@ package de.test.podcini.service.playback
import androidx.test.annotation.UiThreadTest
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import ac.mdiq.podcini.core.service.playback.LocalPSMP
import ac.mdiq.podcini.model.feed.*
import ac.mdiq.podcini.model.playback.Playable
import ac.mdiq.podcini.service.playback.LocalPSMP
import ac.mdiq.podcini.storage.model.feed.*
import ac.mdiq.podcini.storage.model.playback.Playable
import ac.mdiq.podcini.playback.base.PlaybackServiceMediaPlayer
import ac.mdiq.podcini.playback.base.PlaybackServiceMediaPlayer.PSMPInfo
import ac.mdiq.podcini.playback.base.PlayerStatus

View File

@ -3,15 +3,15 @@ package de.test.podcini.service.playback
import androidx.test.annotation.UiThreadTest
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.setShakeToReset
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.setVibrate
import ac.mdiq.podcini.core.service.playback.PlaybackServiceTaskManager
import ac.mdiq.podcini.core.service.playback.PlaybackServiceTaskManager.PSTMCallback
import ac.mdiq.podcini.core.widget.WidgetUpdater.WidgetState
import ac.mdiq.podcini.event.playback.SleepTimerUpdatedEvent
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.model.playback.Playable
import ac.mdiq.podcini.preferences.SleepTimerPreferences.setShakeToReset
import ac.mdiq.podcini.preferences.SleepTimerPreferences.setVibrate
import ac.mdiq.podcini.service.playback.PlaybackServiceTaskManager
import ac.mdiq.podcini.service.playback.PlaybackServiceTaskManager.PSTMCallback
import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState
import ac.mdiq.podcini.playback.event.SleepTimerUpdatedEvent
import ac.mdiq.podcini.storage.model.feed.Feed
import ac.mdiq.podcini.storage.model.feed.FeedItem
import ac.mdiq.podcini.storage.model.playback.Playable
import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.storage.database.PodDBAdapter.Companion.deleteDatabase
import ac.mdiq.podcini.storage.database.PodDBAdapter.Companion.getInstance

View File

@ -1,6 +1,6 @@
package de.test.podcini.service.playback
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.isInTimeRange
import ac.mdiq.podcini.preferences.SleepTimerPreferences.isInTimeRange
import org.junit.Assert
import org.junit.Test

View File

@ -2,14 +2,14 @@ package de.test.podcini.storage
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import ac.mdiq.podcini.core.preferences.PlaybackPreferences.Companion.currentlyPlayingFeedMediaId
import ac.mdiq.podcini.core.storage.AutomaticDownloadAlgorithm
import ac.mdiq.podcini.core.storage.DBReader.getQueue
import ac.mdiq.podcini.core.storage.DBTasks.setDownloadAlgorithm
import ac.mdiq.podcini.core.util.playback.PlaybackServiceStarter
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.storage.preferences.UserPreferences.isAllowMobileStreaming
import ac.mdiq.podcini.storage.preferences.UserPreferences.isFollowQueue
import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.currentlyPlayingFeedMediaId
import ac.mdiq.podcini.storage.AutomaticDownloadAlgorithm
import ac.mdiq.podcini.storage.DBReader.getQueue
import ac.mdiq.podcini.storage.DBTasks.setDownloadAlgorithm
import ac.mdiq.podcini.playback.PlaybackServiceStarter
import ac.mdiq.podcini.storage.model.feed.FeedItem
import ac.mdiq.podcini.preferences.UserPreferences.isAllowMobileStreaming
import ac.mdiq.podcini.preferences.UserPreferences.isFollowQueue
import de.test.podcini.EspressoTestUtils
import de.test.podcini.ui.UITestUtils
import org.awaitility.Awaitility
@ -106,7 +106,7 @@ class AutoDownloadTest {
var currentlyPlayingAtDownload: Long = -1
private set
override fun autoDownloadUndownloadedItems(context: Context?): Runnable? {
override fun autoDownloadUndownloadedItems(context: Context): Runnable? {
return Runnable {
if (currentlyPlayingAtDownload == -1L) {
currentlyPlayingAtDownload = currentlyPlayingFeedMediaId

View File

@ -8,8 +8,8 @@ import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.MainActivity
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.storage.model.feed.Feed
import de.test.podcini.EspressoTestUtils
import org.hamcrest.Matchers
import org.junit.After

View File

@ -8,7 +8,7 @@ import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.MainActivity
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.storage.database.PodDBAdapter.Companion.deleteDatabase
import de.test.podcini.EspressoTestUtils
import org.junit.After

View File

@ -11,10 +11,10 @@ import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.MainActivity
import ac.mdiq.podcini.activity.PreferenceActivity
import ac.mdiq.podcini.fragment.*
import ac.mdiq.podcini.storage.preferences.UserPreferences.hiddenDrawerItems
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.ui.fragment.*
import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems
import de.test.podcini.EspressoTestUtils
import de.test.podcini.NthMatcher
import org.hamcrest.Matchers
@ -63,13 +63,6 @@ class NavigationDrawerTest {
hiddenDrawerItems = ArrayList()
activityRule.launchActivity(Intent())
// home
openNavDrawer()
EspressoTestUtils.onDrawerItem(ViewMatchers.withText(R.string.home_label)).perform(ViewActions.click())
Espresso.onView(ViewMatchers.isRoot())
.perform(EspressoTestUtils.waitForView(Matchers.allOf(ViewMatchers.isDescendantOfA(ViewMatchers.withId(R.id.toolbar)),
ViewMatchers.withText(R.string.home_label)), 1000))
// queue
openNavDrawer()
EspressoTestUtils.onDrawerItem(ViewMatchers.withText(R.string.queue_label)).perform(ViewActions.click())
@ -159,7 +152,7 @@ class NavigationDrawerTest {
@Test
fun testDrawerPreferencesUnhideSomeElements() {
var hidden = listOf<String>(PlaybackHistoryFragment.TAG, CompletedDownloadsFragment.TAG)
var hidden = listOf(PlaybackHistoryFragment.TAG, CompletedDownloadsFragment.TAG)
hiddenDrawerItems = hidden
activityRule.launchActivity(Intent())
openNavDrawer()
@ -170,7 +163,7 @@ class NavigationDrawerTest {
Espresso.onView(ViewMatchers.withText(R.string.downloads_label)).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withText(R.string.confirm_label)).perform(ViewActions.click())
hidden = hiddenDrawerItems as List<String>
hidden = hiddenDrawerItems?.filterNotNull()?: listOf()
Assert.assertEquals(2, hidden.size.toLong())
Assert.assertTrue(hidden.contains(QueueFragment.TAG))
Assert.assertTrue(hidden.contains(PlaybackHistoryFragment.TAG))

View File

@ -11,33 +11,33 @@ import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.PreferenceActivity
import ac.mdiq.podcini.core.storage.APCleanupAlgorithm
import ac.mdiq.podcini.core.storage.APNullCleanupAlgorithm
import ac.mdiq.podcini.core.storage.APQueueCleanupAlgorithm
import ac.mdiq.podcini.core.storage.EpisodeCleanupAlgorithmFactory.build
import ac.mdiq.podcini.core.storage.ExceptFavoriteCleanupAlgorithm
import ac.mdiq.podcini.storage.preferences.UserPreferences
import ac.mdiq.podcini.storage.preferences.UserPreferences.EnqueueLocation
import ac.mdiq.podcini.storage.preferences.UserPreferences.enqueueLocation
import ac.mdiq.podcini.storage.preferences.UserPreferences.episodeCacheSize
import ac.mdiq.podcini.storage.preferences.UserPreferences.fastForwardSecs
import ac.mdiq.podcini.storage.preferences.UserPreferences.init
import ac.mdiq.podcini.storage.preferences.UserPreferences.isAutoDelete
import ac.mdiq.podcini.storage.preferences.UserPreferences.isAutoDeleteLocal
import ac.mdiq.podcini.storage.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.storage.preferences.UserPreferences.isEnableAutodownloadOnBattery
import ac.mdiq.podcini.storage.preferences.UserPreferences.isFollowQueue
import ac.mdiq.podcini.storage.preferences.UserPreferences.isPauseOnHeadsetDisconnect
import ac.mdiq.podcini.storage.preferences.UserPreferences.isPersistNotify
import ac.mdiq.podcini.storage.preferences.UserPreferences.isUnpauseOnBluetoothReconnect
import ac.mdiq.podcini.storage.preferences.UserPreferences.isUnpauseOnHeadsetReconnect
import ac.mdiq.podcini.storage.preferences.UserPreferences.rewindSecs
import ac.mdiq.podcini.storage.preferences.UserPreferences.shouldDeleteRemoveFromQueue
import ac.mdiq.podcini.storage.preferences.UserPreferences.shouldPauseForFocusLoss
import ac.mdiq.podcini.storage.preferences.UserPreferences.showNextChapterOnFullNotification
import ac.mdiq.podcini.storage.preferences.UserPreferences.showPlaybackSpeedOnFullNotification
import ac.mdiq.podcini.storage.preferences.UserPreferences.showSkipOnFullNotification
import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.storage.APCleanupAlgorithm
import ac.mdiq.podcini.storage.APNullCleanupAlgorithm
import ac.mdiq.podcini.storage.APQueueCleanupAlgorithm
import ac.mdiq.podcini.storage.EpisodeCleanupAlgorithmFactory.build
import ac.mdiq.podcini.storage.ExceptFavoriteCleanupAlgorithm
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.EnqueueLocation
import ac.mdiq.podcini.preferences.UserPreferences.enqueueLocation
import ac.mdiq.podcini.preferences.UserPreferences.episodeCacheSize
import ac.mdiq.podcini.preferences.UserPreferences.fastForwardSecs
import ac.mdiq.podcini.preferences.UserPreferences.init
import ac.mdiq.podcini.preferences.UserPreferences.isAutoDelete
import ac.mdiq.podcini.preferences.UserPreferences.isAutoDeleteLocal
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownloadOnBattery
import ac.mdiq.podcini.preferences.UserPreferences.isFollowQueue
import ac.mdiq.podcini.preferences.UserPreferences.isPauseOnHeadsetDisconnect
import ac.mdiq.podcini.preferences.UserPreferences.isPersistNotify
import ac.mdiq.podcini.preferences.UserPreferences.isUnpauseOnBluetoothReconnect
import ac.mdiq.podcini.preferences.UserPreferences.isUnpauseOnHeadsetReconnect
import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs
import ac.mdiq.podcini.preferences.UserPreferences.shouldDeleteRemoveFromQueue
import ac.mdiq.podcini.preferences.UserPreferences.shouldPauseForFocusLoss
import ac.mdiq.podcini.preferences.UserPreferences.showNextChapterOnFullNotification
import ac.mdiq.podcini.preferences.UserPreferences.showPlaybackSpeedOnFullNotification
import ac.mdiq.podcini.preferences.UserPreferences.showSkipOnFullNotification
import de.test.podcini.EspressoTestUtils
import org.awaitility.Awaitility
import org.junit.Assert

View File

@ -7,8 +7,8 @@ import androidx.test.espresso.intent.rule.IntentsTestRule
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.MainActivity
import ac.mdiq.podcini.fragment.QueueFragment
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.fragment.QueueFragment
import de.test.podcini.EspressoTestUtils
import de.test.podcini.NthMatcher
import org.hamcrest.CoreMatchers

View File

@ -8,7 +8,7 @@ import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.MainActivity
import ac.mdiq.podcini.ui.activity.MainActivity
import de.test.podcini.EspressoTestUtils
import org.hamcrest.CoreMatchers
import org.junit.After

View File

@ -3,11 +3,11 @@ package de.test.podcini.ui
import android.content.Context
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import ac.mdiq.podcini.event.FeedListUpdateEvent
import ac.mdiq.podcini.event.QueueEvent.Companion.setQueue
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.model.feed.FeedMedia
import ac.mdiq.podcini.util.event.FeedListUpdateEvent
import ac.mdiq.podcini.util.event.QueueEvent.Companion.setQueue
import ac.mdiq.podcini.storage.model.feed.Feed
import ac.mdiq.podcini.storage.model.feed.FeedItem
import ac.mdiq.podcini.storage.model.feed.FeedMedia
import ac.mdiq.podcini.storage.database.PodDBAdapter.Companion.deleteDatabase
import ac.mdiq.podcini.storage.database.PodDBAdapter.Companion.getInstance
import de.test.podcini.util.service.download.HTTPBin
@ -193,7 +193,7 @@ class UITestUtils(private val context: Context) {
adapter.setCompleteFeed(*hostedFeeds.toTypedArray<Feed>())
adapter.setQueue(queue)
adapter.close()
EventBus.getDefault().post(FeedListUpdateEvent(hostedFeeds))
EventBus.getDefault().post(ac.mdiq.podcini.util.event.FeedListUpdateEvent(hostedFeeds))
EventBus.getDefault().post(setQueue(queue))
}

View File

@ -2,7 +2,7 @@ package de.test.podcini.ui
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.storage.model.feed.Feed
import org.junit.After
import org.junit.Assert
import org.junit.Before
@ -41,7 +41,7 @@ class UITestUtilsTest {
for (feed in feeds) {
testUrlReachable(feed.download_url)
for (item in feed.items!!) {
for (item in feed.items) {
if (item.hasMedia()) {
testUrlReachable(item.media!!.download_url)
}
@ -68,14 +68,14 @@ class UITestUtilsTest {
for (feed in uiTestUtils!!.hostedFeeds) {
Assert.assertTrue(feed.id != 0L)
for (item in feed.items!!) {
for (item in feed.items) {
Assert.assertTrue(item.id != 0L)
if (item.hasMedia()) {
Assert.assertTrue(item.media!!.id != 0L)
if (downloadEpisodes) {
Assert.assertTrue(item.media!!.isDownloaded())
Assert.assertNotNull(item.media!!.getFile_url())
val file = File(item.media!!.getFile_url())
val file = File(item.media!!.getFile_url()!!)
Assert.assertTrue(file.exists())
}
}

View File

@ -1,6 +1,6 @@
package de.test.podcini.util.event
import ac.mdiq.podcini.event.FeedItemEvent
import ac.mdiq.podcini.util.event.FeedItemEvent
import io.reactivex.functions.Consumer
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
@ -10,14 +10,14 @@ import org.greenrobot.eventbus.Subscribe
*
*/
class FeedItemEventListener {
private val events: MutableList<FeedItemEvent> = ArrayList()
private val events: MutableList<ac.mdiq.podcini.util.event.FeedItemEvent> = ArrayList()
@Subscribe
fun onEvent(event: FeedItemEvent) {
fun onEvent(event: ac.mdiq.podcini.util.event.FeedItemEvent) {
events.add(event)
}
fun getEvents(): List<FeedItemEvent> {
fun getEvents(): List<ac.mdiq.podcini.util.event.FeedItemEvent> {
return events
}

View File

@ -1,6 +1,6 @@
package de.test.podcini.util.syndication.feedgenerator
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.storage.model.feed.Feed
import java.io.IOException
import java.io.OutputStream

View File

@ -1,9 +1,9 @@
package de.test.podcini.util.syndication.feedgenerator
import android.util.Xml
import ac.mdiq.podcini.core.util.DateFormatter.formatRfc822Date
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.parser.feed.namespace.PodcastIndex
import ac.mdiq.podcini.util.DateFormatter.formatRfc822Date
import ac.mdiq.podcini.storage.model.feed.Feed
import ac.mdiq.podcini.feed.parser.namespace.PodcastIndex
import de.test.podcini.util.syndication.feedgenerator.GeneratorUtil.addPaymentLink
import java.io.IOException
import java.io.OutputStream
@ -56,15 +56,15 @@ class Rss2Generator : FeedGenerator {
}
val fundingList = feed.paymentLinks
if (fundingList != null) {
if (fundingList.isNotEmpty()) {
for (funding in fundingList) {
addPaymentLink(xml, funding.url, true)
}
}
// Write FeedItem data
if (feed.items != null) {
for (item in feed.items!!) {
if (feed.items.isNotEmpty()) {
for (item in feed.items) {
xml.startTag(null, "item")
if (item.title != null) {
@ -99,7 +99,7 @@ class Rss2Generator : FeedGenerator {
xml.attribute(null, "type", item.media!!.mime_type)
xml.endTag(null, "enclosure")
}
if (fundingList != null) {
if (fundingList.isNotEmpty()) {
for (funding in fundingList) {
xml.startTag(PodcastIndex.NSTAG, "funding")
xml.attribute(PodcastIndex.NSTAG, "url", funding.url)

View File

@ -1,4 +1,4 @@
package ac.mdiq.podcini.dialog
package ac.mdiq.podcini.ui.dialog
import android.content.Context
import androidx.annotation.VisibleForTesting

View File

@ -0,0 +1,11 @@
package ac.mdiq.podcini.playback.cast
import android.content.Context
open class CastStateListener(context: Context) {
fun destroy() {
}
open fun onSessionStartedOrEnded() {
}
}

View File

@ -0,0 +1,17 @@
package ac.mdiq.podcini.service.playback
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
internal object WearMediaSession {
/**
* Take a custom action builder and add no extras, because this is not the Play version of the app.
*/
fun addWearExtrasToAction(actionBuilder: PlaybackStateCompat.CustomAction.Builder?) {
// no-op
}
fun mediaSessionSetExtraForWear(mediaSession: MediaSessionCompat?) {
// no-op
}
}

View File

@ -3,6 +3,19 @@
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
@ -32,7 +45,7 @@
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:backupAgent=".core.backup.OpmlBackupAgent"
android:backupAgent=".storage.backup.OpmlBackupAgent"
android:restoreAnyVersion="true"
android:theme="@style/Theme.Podcini.Splash"
android:usesCleartextTraffic="true"
@ -42,15 +55,51 @@
android:allowAudioPlaybackCapture="true"
android:networkSecurityConfig="@xml/network_security_config">
<!-- <service-->
<!-- android:name=".core.service.playback.PlaybackService"-->
<!-- android:foregroundServiceType="mediaPlayback"-->
<!-- android:exported="false"-->
<!-- tools:replace="android:exported">-->
<!-- </service>-->
<service android:name=".service.playback.PlaybackService"
android:foregroundServiceType="mediaPlayback"
android:label="@string/app_name"
android:enabled="true"
android:exported="false"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
<action android:name="ac.mdiq.podcini.intents.PLAYBACK_SERVICE" />
</intent-filter>
</service>
<receiver
android:name=".receiver.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
<intent-filter>
<action android:name="ac.mdiq.podcini.NOTIFY_BUTTON_RECEIVER" />
</intent-filter>
</receiver>
<receiver android:name=".receiver.FeedUpdateReceiver"
android:label="@string/feed_update_receiver_name"
android:exported="true"
tools:ignore="ExportedReceiver" /> <!-- allow feeds update to be triggered by external apps -->
<service
android:name=".service.QuickSettingsTileService"
android:enabled="true"
android:exported="true"
android:label="@string/app_name"
android:icon="@drawable/ic_notification"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data android:name="android.service.quicksettings.ACTIVE_TILE" android:value="true" />
<meta-data android:name="android.service.quicksettings.TOGGLEABLE_TILE" android:value="true" />
</service>
<activity
android:name=".activity.PlaybackSpeedDialogActivity"
android:name=".ui.activity.PlaybackSpeedDialogActivity"
android:noHistory="true"
android:exported="false"
android:excludeFromRecents="true"
@ -81,7 +130,7 @@
<meta-data android:name="com.samsung.android.multidisplay.keep_process_alive" android:value="true"/>
<activity
android:name=".activity.SplashActivity"
android:name=".ui.activity.SplashActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:exported="true">
<intent-filter>
@ -100,7 +149,7 @@
</activity>
<activity
android:name=".activity.MainActivity"
android:name=".ui.activity.MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|density|uiMode|keyboard|navigation"
android:windowSoftInputMode="stateAlwaysHidden"
android:launchMode="singleTask"
@ -135,20 +184,20 @@
</activity>
<activity
android:name=".activity.PreferenceActivity"
android:name=".ui.activity.PreferenceActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:exported="false"
android:label="@string/settings_label">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="ac.mdiq.podcini.activity.MainActivity"/>
android:value="ac.mdiq.podcini.ui.activity.MainActivity"/>
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES" />
</intent-filter>
</activity>
<activity
android:name=".activity.WidgetConfigActivity"
android:name=".ui.activity.WidgetConfigActivity"
android:label="@string/widget_settings"
android:exported="true">
<intent-filter>
@ -157,7 +206,7 @@
</activity>
<receiver
android:name=".core.receiver.PlayerWidget"
android:name=".receiver.PlayerWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
@ -170,7 +219,7 @@
</receiver>
<activity
android:name=".activity.OpmlImportActivity"
android:name=".ui.activity.OpmlImportActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:label="@string/opml_import_label"
android:exported="true">
@ -196,22 +245,22 @@
</intent-filter>
</activity>
<activity
android:name=".activity.BugReportActivity"
android:name=".ui.activity.BugReportActivity"
android:label="@string/bug_report_title">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="ac.mdiq.podcini.activity.PreferenceActivity"/>
android:value="ac.mdiq.podcini.ui.activity.PreferenceActivity"/>
</activity>
<activity
android:name=".activity.VideoplayerActivity"
android:name=".ui.activity.VideoplayerActivity"
android:configChanges="keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize"
android:supportsPictureInPicture="true"
android:screenOrientation="sensorLandscape"
android:exported="false">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="ac.mdiq.podcini.activity.MainActivity"/>
android:value="ac.mdiq.podcini.ui.activity.MainActivity"/>
<intent-filter>
<action android:name="ac.mdiq.podcini.intents.VIDEO_PLAYER" />
<category android:name="android.intent.category.DEFAULT" />
@ -219,14 +268,14 @@
</activity>
<activity
android:name=".activity.OnlineFeedViewActivity"
android:name=".ui.activity.OnlineFeedViewActivity"
android:configChanges="orientation|screenSize"
android:theme="@style/Theme.Podcini.Dark.Translucent"
android:label="@string/add_feed_label"
android:exported="true">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="ac.mdiq.podcini.activity.MainActivity"/>
android:value="ac.mdiq.podcini.ui.activity.MainActivity"/>
<!-- URLs ending with '.xml' or '.rss' -->
<intent-filter>
@ -336,7 +385,7 @@
</activity>
<activity android:name=".activity.SelectSubscriptionActivity"
<activity android:name=".ui.activity.SelectSubscriptionActivity"
android:label="@string/shortcut_subscription_label"
android:icon="@drawable/ic_subscriptions_shortcut"
android:theme="@style/Theme.Podcini.Dark.Translucent"
@ -387,4 +436,11 @@
android:resource="@xml/actions" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
</manifest>

View File

@ -9,15 +9,14 @@ import com.google.android.material.color.DynamicColors
import com.joanzapata.iconify.Iconify
import com.joanzapata.iconify.fonts.FontAwesomeModule
import com.joanzapata.iconify.fonts.MaterialModule
import ac.mdiq.podcini.activity.SplashActivity
import ac.mdiq.podcini.config.ApplicationCallbacksImpl
import ac.mdiq.podcini.core.ApCoreEventBusIndex
import ac.mdiq.podcini.core.ClientConfig
import ac.mdiq.podcini.core.ClientConfigurator
import ac.mdiq.podcini.error.CrashReportWriter
import ac.mdiq.podcini.error.RxJavaErrorHandlerSetup
import ac.mdiq.podcini.ui.activity.SplashActivity
import ac.mdiq.podcini.util.config.ApplicationCallbacksImpl
import ac.mdiq.podcini.util.config.ClientConfig
import ac.mdiq.podcini.util.config.ClientConfigurator
import ac.mdiq.podcini.util.error.CrashReportWriter
import ac.mdiq.podcini.util.error.RxJavaErrorHandlerSetup
import ac.mdiq.podcini.preferences.PreferenceUpgrader
import ac.mdiq.podcini.spa.SPAUtil
import ac.mdiq.podcini.util.SPAUtil
import org.greenrobot.eventbus.EventBus
/** Main application class. */
@ -26,7 +25,8 @@ class PodciniApp : Application() {
override fun onCreate() {
super.onCreate()
ClientConfig.USER_AGENT = "Podcini/" + BuildConfig.VERSION_NAME
ClientConfig.applicationCallbacks = ApplicationCallbacksImpl()
ClientConfig.applicationCallbacks =
ApplicationCallbacksImpl()
Thread.setDefaultUncaughtExceptionHandler(CrashReportWriter())
RxJavaErrorHandlerSetup.setupRxJavaErrorHandler()
@ -53,7 +53,7 @@ class PodciniApp : Application() {
SPAUtil.sendSPAppsQueryFeedsIntent(this)
EventBus.builder()
.addIndex(ApEventBusIndex())
.addIndex(ApCoreEventBusIndex())
// .addIndex(ApCoreEventBusIndex())
.logNoSubscriberMessages(false)
.sendNoSubscriberEvent(false)
.installDefaultEventBus()

View File

@ -1,125 +0,0 @@
package ac.mdiq.podcini.activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.DialogInterface
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ShareCompat.IntentBuilder
import androidx.core.content.FileProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.preferences.ThemeSwitcher.getTheme
import ac.mdiq.podcini.core.util.IntentUtils.openInBrowser
import ac.mdiq.podcini.error.CrashReportWriter
import ac.mdiq.podcini.storage.preferences.UserPreferences.getDataFolder
import org.apache.commons.io.IOUtils
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.nio.charset.Charset
/**
* Displays the 'crash report' screen
*/
class BugReportActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(getTheme(this))
super.onCreate(savedInstanceState)
supportActionBar!!.setDisplayShowHomeEnabled(true)
setContentView(R.layout.bug_report)
var stacktrace = "No crash report recorded"
try {
val crashFile = CrashReportWriter.file
if (crashFile.exists()) {
stacktrace = IOUtils.toString(FileInputStream(crashFile), Charset.forName("UTF-8"))
} else {
Log.d(TAG, stacktrace)
}
} catch (e: IOException) {
e.printStackTrace()
}
val crashDetailsTextView = findViewById<TextView>(R.id.crash_report_logs)
crashDetailsTextView.text = """
${CrashReportWriter.systemInfo}
$stacktrace
""".trimIndent()
findViewById<View>(R.id.btn_open_bug_tracker).setOnClickListener { v: View? ->
openInBrowser(
this@BugReportActivity, "https://github.com/XilinJia/Podcini/issues")
}
findViewById<View>(R.id.btn_copy_log).setOnClickListener { v: View? ->
val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(getString(R.string.bug_report_title), crashDetailsTextView.text)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT < 32) {
Snackbar.make(findViewById(android.R.id.content), R.string.copied_to_clipboard,
Snackbar.LENGTH_SHORT).show()
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.bug_report_options, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.export_logcat) {
val alertBuilder = MaterialAlertDialogBuilder(this)
alertBuilder.setMessage(R.string.confirm_export_log_dialog_message)
alertBuilder.setPositiveButton(R.string.confirm_label) { dialog: DialogInterface, which: Int ->
exportLog()
dialog.dismiss()
}
alertBuilder.setNegativeButton(R.string.cancel_label, null)
alertBuilder.show()
return true
}
return super.onOptionsItemSelected(item)
}
private fun exportLog() {
try {
val filename = File(getDataFolder(null), "full-logs.txt")
val cmd = "logcat -d -f " + filename.absolutePath
Runtime.getRuntime().exec(cmd)
//share file
try {
val authority = getString(R.string.provider_authority)
val fileUri = FileProvider.getUriForFile(this, authority, filename)
IntentBuilder(this)
.setType("text/*")
.addStream(fileUri)
.setChooserTitle(R.string.share_file_label)
.startChooser()
} catch (e: Exception) {
e.printStackTrace()
val strResId = R.string.log_file_share_exception
Snackbar.make(findViewById(android.R.id.content), strResId, Snackbar.LENGTH_LONG)
.show()
}
} catch (e: IOException) {
e.printStackTrace()
Snackbar.make(findViewById(android.R.id.content), e.message!!, Snackbar.LENGTH_LONG).show()
}
}
companion object {
private const val TAG = "BugReportActivity"
}
}

View File

@ -1,682 +0,0 @@
package ac.mdiq.podcini.activity
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.DisplayMetrics
import android.util.Log
import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.widget.EditText
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.bumptech.glide.Glide
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.snackbar.Snackbar
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.preferences.ThemeSwitcher.getNoTitleTheme
import ac.mdiq.podcini.core.receiver.MediaButtonReceiver.Companion.createIntent
import ac.mdiq.podcini.core.storage.DBReader
import ac.mdiq.podcini.core.sync.queue.SynchronizationQueueSink
import ac.mdiq.podcini.core.util.download.FeedUpdateManager
import ac.mdiq.podcini.core.util.download.FeedUpdateManager.restartUpdateAlarm
import ac.mdiq.podcini.core.util.download.FeedUpdateManager.runOnceOrAsk
import ac.mdiq.podcini.dialog.RatingDialog
import ac.mdiq.podcini.event.EpisodeDownloadEvent
import ac.mdiq.podcini.event.FeedUpdateRunningEvent
import ac.mdiq.podcini.event.MessageEvent
import ac.mdiq.podcini.fragment.*
import ac.mdiq.podcini.model.download.DownloadStatus
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.playback.cast.CastEnabledActivity
import ac.mdiq.podcini.storage.preferences.UserPreferences
import ac.mdiq.podcini.storage.preferences.UserPreferences.backButtonOpensDrawer
import ac.mdiq.podcini.storage.preferences.UserPreferences.defaultPage
import ac.mdiq.podcini.storage.preferences.UserPreferences.hiddenDrawerItems
import ac.mdiq.podcini.ui.appstartintent.MainActivityStarter
import ac.mdiq.podcini.ui.common.ThemeUtils.getDrawableFromAttr
import ac.mdiq.podcini.ui.home.HomeFragment
import ac.mdiq.podcini.view.LockableBottomSheetBehavior
import org.apache.commons.lang3.ArrayUtils
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import kotlin.math.min
/**
* The activity that is shown when the user launches the app.
*/
@UnstableApi
class MainActivity : CastEnabledActivity() {
// some device doesn't have a drawer
private var drawerLayout: DrawerLayout? = null
private lateinit var navDrawer: View
private lateinit var dummyView : View
lateinit var bottomSheet: LockableBottomSheetBehavior<*>
private set
private var drawerToggle: ActionBarDrawerToggle? = null
@JvmField
val recycledViewPool: RecyclerView.RecycledViewPool = RecyclerView.RecycledViewPool()
private var lastTheme = 0
private var navigationBarInsets = Insets.NONE
@UnstableApi public override fun onCreate(savedInstanceState: Bundle?) {
lastTheme = getNoTitleTheme(this)
setTheme(lastTheme)
DBReader.updateFeedList()
if (savedInstanceState != null) {
ensureGeneratedViewIdGreaterThan(savedInstanceState.getInt(KEY_GENERATED_VIEW_ID, 0))
}
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState)
setContentView(R.layout.main)
recycledViewPool.setMaxRecycledViews(R.id.view_type_episode_item, 25)
dummyView = object : View(this) {}
drawerLayout = findViewById(R.id.drawer_layout)
navDrawer = findViewById(R.id.navDrawerFragment)
setNavDrawerSize()
// Consume navigation bar insets - we apply them in setPlayerVisible()
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main_view)) { v: View?, insets: WindowInsetsCompat ->
navigationBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
updateInsets()
WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.NONE)
.build()
}
val fm = supportFragmentManager
if (fm.findFragmentByTag(MAIN_FRAGMENT_TAG) == null) {
if (UserPreferences.DEFAULT_PAGE_REMEMBER != defaultPage) {
loadFragment(defaultPage, null)
} else {
val lastFragment = NavDrawerFragment.getLastNavFragment(this)
if (ArrayUtils.contains(NavDrawerFragment.NAV_DRAWER_TAGS, lastFragment)) {
loadFragment(lastFragment, null)
} else {
try {
loadFeedFragmentById(lastFragment.toInt().toLong(), null)
} catch (e: NumberFormatException) {
// it's not a number, this happens if we removed
// a label from the NAV_DRAWER_TAGS
// give them a nice default...
loadFragment(HomeFragment.TAG, null)
}
}
}
}
val transaction = fm.beginTransaction()
val navDrawerFragment = NavDrawerFragment()
transaction.replace(R.id.navDrawerFragment, navDrawerFragment, NavDrawerFragment.TAG)
val audioPlayerFragment = AudioPlayerFragment()
transaction.replace(R.id.audioplayerFragment, audioPlayerFragment, AudioPlayerFragment.TAG)
transaction.commit()
checkFirstLaunch()
val bottomSheet = findViewById<View>(R.id.audioplayerFragment)
this.bottomSheet = BottomSheetBehavior.from(bottomSheet) as LockableBottomSheetBehavior<*>
this.bottomSheet.isHideable = false
this.bottomSheet.setBottomSheetCallback(bottomSheetCallback)
restartUpdateAlarm(this, false)
SynchronizationQueueSink.syncNowIfNotSyncedRecently()
WorkManager.getInstance(this)
.getWorkInfosByTagLiveData(FeedUpdateManager.WORK_TAG_FEED_UPDATE)
.observe(this) { workInfos: List<WorkInfo> ->
var isRefreshingFeeds = false
for (workInfo in workInfos) {
if (workInfo.state == WorkInfo.State.RUNNING) {
isRefreshingFeeds = true
} else if (workInfo.state == WorkInfo.State.ENQUEUED) {
isRefreshingFeeds = true
}
}
EventBus.getDefault().postSticky(FeedUpdateRunningEvent(isRefreshingFeeds))
}
WorkManager.getInstance(this)
.getWorkInfosByTagLiveData(DownloadServiceInterface.WORK_TAG)
.observe(this) { workInfos: List<WorkInfo> ->
val updatedEpisodes: MutableMap<String, DownloadStatus> = HashMap()
for (workInfo in workInfos) {
var downloadUrl: String? = null
for (tag in workInfo.tags) {
if (tag.startsWith(DownloadServiceInterface.WORK_TAG_EPISODE_URL)) {
downloadUrl = tag.substring(DownloadServiceInterface.WORK_TAG_EPISODE_URL.length)
}
}
if (downloadUrl == null) {
continue
}
var status: Int
status = when (workInfo.state) {
WorkInfo.State.RUNNING -> {
DownloadStatus.STATE_RUNNING
}
WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> {
DownloadStatus.STATE_QUEUED
}
else -> {
DownloadStatus.STATE_COMPLETED
}
}
var progress = workInfo.progress.getInt(DownloadServiceInterface.WORK_DATA_PROGRESS, -1)
if (progress == -1 && status != DownloadStatus.STATE_COMPLETED) {
status = DownloadStatus.STATE_QUEUED
progress = 0
}
updatedEpisodes[downloadUrl] = DownloadStatus(status, progress)
}
DownloadServiceInterface.get()?.setCurrentDownloads(updatedEpisodes)
EventBus.getDefault().postSticky(EpisodeDownloadEvent(updatedEpisodes))
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
updateInsets()
}
/**
* View.generateViewId stores the current ID in a static variable.
* When the process is killed, the variable gets reset.
* This makes sure that we do not get ID collisions
* and therefore errors when trying to restore state from another view.
*/
private fun ensureGeneratedViewIdGreaterThan(minimum: Int) {
while (View.generateViewId() <= minimum) {
// Generate new IDs
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(KEY_GENERATED_VIEW_ID, View.generateViewId())
}
private val bottomSheetCallback: BottomSheetCallback = @UnstableApi object : BottomSheetCallback() {
override fun onStateChanged(view: View, state: Int) {
if (state == BottomSheetBehavior.STATE_COLLAPSED) {
onSlide(view,0.0f)
} else if (state == BottomSheetBehavior.STATE_EXPANDED) {
onSlide(view, 1.0f)
}
}
override fun onSlide(view: View, slideOffset: Float) {
val audioPlayer = supportFragmentManager
.findFragmentByTag(AudioPlayerFragment.TAG) as AudioPlayerFragment?
if (audioPlayer == null) {
return
}
if (slideOffset == 0.0f) { //STATE_COLLAPSED
audioPlayer.scrollToPage(AudioPlayerFragment.POS_COVER)
}
audioPlayer.fadePlayerToToolbar(slideOffset)
}
}
fun setupToolbarToggle(toolbar: MaterialToolbar, displayUpArrow: Boolean) {
if (drawerLayout != null) { // Tablet layout does not have a drawer
if (drawerToggle != null) {
drawerLayout!!.removeDrawerListener(drawerToggle!!)
}
drawerToggle = ActionBarDrawerToggle(this, drawerLayout, toolbar,
R.string.drawer_open, R.string.drawer_close)
drawerLayout!!.addDrawerListener(drawerToggle!!)
drawerToggle!!.syncState()
drawerToggle!!.isDrawerIndicatorEnabled = !displayUpArrow
drawerToggle!!.toolbarNavigationClickListener = View.OnClickListener { v: View? -> supportFragmentManager.popBackStack() }
} else if (!displayUpArrow) {
toolbar.navigationIcon = null
} else {
toolbar.setNavigationIcon(getDrawableFromAttr(this, R.attr.homeAsUpIndicator))
toolbar.setNavigationOnClickListener { v: View? -> supportFragmentManager.popBackStack() }
}
}
override fun onDestroy() {
super.onDestroy()
drawerLayout?.removeDrawerListener(drawerToggle!!)
}
private fun checkFirstLaunch() {
val prefs = getSharedPreferences(PREF_NAME, MODE_PRIVATE)
if (prefs.getBoolean(PREF_IS_FIRST_LAUNCH, true)) {
restartUpdateAlarm(this, true)
val edit = prefs.edit()
edit.putBoolean(PREF_IS_FIRST_LAUNCH, false)
edit.apply()
}
}
val isDrawerOpen: Boolean
get() = drawerLayout?.isDrawerOpen(navDrawer)?:false
private fun updateInsets() {
setPlayerVisible(findViewById<View>(R.id.audioplayerFragment).visibility == View.VISIBLE)
val playerHeight = resources.getDimension(R.dimen.external_player_height).toInt()
bottomSheet.peekHeight = playerHeight + navigationBarInsets.bottom
}
fun setPlayerVisible(visible: Boolean) {
bottomSheet.setLocked(!visible)
if (visible) {
bottomSheetCallback.onStateChanged(dummyView, bottomSheet.state) // Update toolbar visibility
} else {
bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED)
}
val mainView = findViewById<FragmentContainerView>(R.id.main_view)
val params = mainView.layoutParams as MarginLayoutParams
val externalPlayerHeight = resources.getDimension(R.dimen.external_player_height).toInt()
params.setMargins(navigationBarInsets.left, 0, navigationBarInsets.right,
navigationBarInsets.bottom + (if (visible) externalPlayerHeight else 0))
mainView.layoutParams = params
val playerView = findViewById<FragmentContainerView>(R.id.playerFragment)
val playerParams = playerView.layoutParams as MarginLayoutParams
playerParams.setMargins(navigationBarInsets.left, 0, navigationBarInsets.right, 0)
playerView.layoutParams = playerParams
findViewById<View>(R.id.audioplayerFragment).visibility = if (visible) View.VISIBLE else View.GONE
}
fun loadFragment(tag: String?, args: Bundle?) {
var tag = tag
var args = args
Log.d(TAG, "loadFragment(tag: $tag, args: $args)")
val fragment: Fragment
when (tag) {
HomeFragment.TAG -> fragment = HomeFragment()
QueueFragment.TAG -> fragment = QueueFragment()
InboxFragment.TAG -> fragment = InboxFragment()
AllEpisodesFragment.TAG -> fragment = AllEpisodesFragment()
CompletedDownloadsFragment.TAG -> fragment = CompletedDownloadsFragment()
PlaybackHistoryFragment.TAG -> fragment = PlaybackHistoryFragment()
AddFeedFragment.TAG -> fragment = AddFeedFragment()
SubscriptionFragment.TAG -> fragment = SubscriptionFragment()
else -> {
// default to home screen
fragment = HomeFragment()
tag = HomeFragment.TAG
args = null
}
}
if (args != null) {
fragment.arguments = args
}
NavDrawerFragment.saveLastNavFragment(this, tag)
loadFragment(fragment)
}
fun loadFeedFragmentById(feedId: Long, args: Bundle?) {
val fragment: Fragment = FeedItemlistFragment.newInstance(feedId)
if (args != null) {
fragment.arguments = args
}
NavDrawerFragment.saveLastNavFragment(this, feedId.toString())
loadFragment(fragment)
}
private fun loadFragment(fragment: Fragment) {
val fragmentManager = supportFragmentManager
// clear back stack
for (i in 0 until fragmentManager.backStackEntryCount) {
fragmentManager.popBackStack()
}
val t = fragmentManager.beginTransaction()
t.replace(R.id.main_view, fragment, MAIN_FRAGMENT_TAG)
fragmentManager.popBackStack()
// TODO: we have to allow state loss here
// since this function can get called from an AsyncTask which
// could be finishing after our app has already committed state
// and is about to get shutdown. What we *should* do is
// not commit anything in an AsyncTask, but that's a bigger
// change than we want now.
t.commitAllowingStateLoss()
// Tablet layout does not have a drawer
drawerLayout?.closeDrawer(navDrawer)
}
@JvmOverloads
fun loadChildFragment(fragment: Fragment, transition: TransitionEffect? = TransitionEffect.NONE) {
val transaction = supportFragmentManager.beginTransaction()
when (transition) {
TransitionEffect.FADE -> transaction.setCustomAnimations(R.anim.fade_in, R.anim.fade_out)
TransitionEffect.SLIDE -> transaction.setCustomAnimations(
R.anim.slide_right_in,
R.anim.slide_left_out,
R.anim.slide_left_in,
R.anim.slide_right_out)
TransitionEffect.NONE -> {}
null -> {}
}
transaction
.hide(supportFragmentManager.findFragmentByTag(MAIN_FRAGMENT_TAG)!!)
.add(R.id.main_view, fragment, MAIN_FRAGMENT_TAG)
.addToBackStack(null)
.commit()
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
if (drawerToggle != null) { // Tablet layout does not have a drawer
drawerToggle!!.syncState()
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (drawerToggle != null) { // Tablet layout does not have a drawer
drawerToggle!!.onConfigurationChanged(newConfig)
}
setNavDrawerSize()
}
private fun setNavDrawerSize() {
if (drawerToggle == null) { // Tablet layout does not have a drawer
return
}
val screenPercent = resources.getInteger(R.integer.nav_drawer_screen_size_percent) * 0.01f
val width = (screenWidth * screenPercent).toInt()
val maxWidth = resources.getDimension(R.dimen.nav_drawer_max_screen_size).toInt()
navDrawer.layoutParams.width = min(width.toDouble(), maxWidth.toDouble()).toInt()
}
private val screenWidth: Int
get() {
val displayMetrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(displayMetrics)
return displayMetrics.widthPixels
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
if (bottomSheet.state == BottomSheetBehavior.STATE_EXPANDED) {
bottomSheetCallback.onSlide(dummyView, 1.0f)
}
}
public override fun onStart() {
super.onStart()
EventBus.getDefault().register(this)
RatingDialog.init(this)
}
override fun onResume() {
super.onResume()
handleNavIntent()
RatingDialog.check()
if (lastTheme != getNoTitleTheme(this)) {
finish()
startActivity(Intent(this, MainActivity::class.java))
}
if (hiddenDrawerItems!!.contains(NavDrawerFragment.getLastNavFragment(this))) {
loadFragment(defaultPage, null)
}
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
lastTheme = getNoTitleTheme(this) // Don't recreate activity when a result is pending
}
override fun onStop() {
super.onStop()
EventBus.getDefault().unregister(this)
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
Glide.get(this).trimMemory(level)
}
override fun onLowMemory() {
super.onLowMemory()
Glide.get(this).clearMemory()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (drawerToggle != null && drawerToggle!!.onOptionsItemSelected(item)) { // Tablet layout does not have a drawer
return true
} else if (item.itemId == android.R.id.home) {
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
}
return true
} else {
return super.onOptionsItemSelected(item)
}
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (isDrawerOpen) {
drawerLayout?.closeDrawer(navDrawer)
} else if (bottomSheet.state == BottomSheetBehavior.STATE_EXPANDED) {
bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED)
} else if (supportFragmentManager.backStackEntryCount != 0) {
super.onBackPressed()
} else {
val toPage = defaultPage
if (NavDrawerFragment.getLastNavFragment(this) == toPage || UserPreferences.DEFAULT_PAGE_REMEMBER == toPage) {
if (backButtonOpensDrawer()) {
drawerLayout?.openDrawer(navDrawer)
} else {
super.onBackPressed()
}
} else {
loadFragment(toPage, null)
}
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEventMainThread(event: MessageEvent) {
Log.d(TAG, "onEvent($event)")
val snackbar = showSnackbarAbovePlayer(event.message, Snackbar.LENGTH_LONG)
if (event.action != null) {
snackbar.setAction(event.actionText) { v: View? -> event.action!!.accept(this) }
}
}
private fun handleNavIntent() {
Log.d(TAG, "handleNavIntent()")
val intent = intent
if (intent.hasExtra(EXTRA_FEED_ID)) {
val feedId = intent.getLongExtra(EXTRA_FEED_ID, 0)
val args = intent.getBundleExtra(MainActivityStarter.EXTRA_FRAGMENT_ARGS)
if (feedId > 0) {
val startedFromSearch = intent.getBooleanExtra(EXTRA_STARTED_FROM_SEARCH, false)
val addToBackStack = intent.getBooleanExtra(EXTRA_ADD_TO_BACK_STACK, false)
if (startedFromSearch || addToBackStack) {
loadChildFragment(FeedItemlistFragment.newInstance(feedId))
} else {
loadFeedFragmentById(feedId, args)
}
}
bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED)
} else if (intent.hasExtra(MainActivityStarter.EXTRA_FRAGMENT_TAG)) {
val tag = intent.getStringExtra(MainActivityStarter.EXTRA_FRAGMENT_TAG)
val args = intent.getBundleExtra(MainActivityStarter.EXTRA_FRAGMENT_ARGS)
if (tag != null) {
loadFragment(tag, args)
}
bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED)
} else if (intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_PLAYER, false)) {
bottomSheet.state = BottomSheetBehavior.STATE_EXPANDED
bottomSheetCallback.onSlide(dummyView, 1.0f)
} else {
handleDeeplink(intent.data)
}
if (intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_DRAWER, false)) {
drawerLayout?.open()
}
if (intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_DOWNLOAD_LOGS, false)) {
DownloadLogFragment().show(supportFragmentManager, null)
}
if (intent.getBooleanExtra(EXTRA_REFRESH_ON_START, false)) {
runOnceOrAsk(this)
}
// to avoid handling the intent twice when the configuration changes
setIntent(Intent(this@MainActivity, MainActivity::class.java))
}
@SuppressLint("MissingSuperCall")
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleNavIntent()
}
fun showSnackbarAbovePlayer(text: CharSequence?, duration: Int): Snackbar {
val s: Snackbar
if (bottomSheet.state == BottomSheetBehavior.STATE_COLLAPSED) {
s = Snackbar.make(findViewById(R.id.main_view), text!!, duration)
if (findViewById<View>(R.id.audioplayerFragment).visibility == View.VISIBLE) {
s.setAnchorView(findViewById(R.id.audioplayerFragment))
}
} else {
s = Snackbar.make(findViewById(android.R.id.content), text!!, duration)
}
s.show()
return s
}
fun showSnackbarAbovePlayer(text: Int, duration: Int): Snackbar {
return showSnackbarAbovePlayer(resources.getText(text), duration)
}
/**
* Handles the deep link incoming via App Actions.
* Performs an in-app search or opens the relevant feature of the app
* depending on the query.
*
* @param uri incoming deep link
*/
private fun handleDeeplink(uri: Uri?) {
if (uri == null || uri.path == null) {
return
}
Log.d(TAG, "Handling deeplink: $uri")
when (uri.path) {
"/deeplink/search" -> {
val query = uri.getQueryParameter("query") ?: return
this.loadChildFragment(SearchFragment.newInstance(query))
}
"/deeplink/main" -> {
val feature = uri.getQueryParameter("page") ?: return
when (feature) {
"DOWNLOADS" -> loadFragment(CompletedDownloadsFragment.TAG, null)
"HISTORY" -> loadFragment(PlaybackHistoryFragment.TAG, null)
"EPISODES" -> loadFragment(AllEpisodesFragment.TAG, null)
"QUEUE" -> loadFragment(QueueFragment.TAG, null)
"SUBSCRIPTIONS" -> loadFragment(SubscriptionFragment.TAG, null)
else -> {
showSnackbarAbovePlayer(getString(R.string.app_action_not_found)+feature, Snackbar.LENGTH_LONG)
return
}
}
}
else -> {}
}
}
//Hardware keyboard support
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
val currentFocus = currentFocus
if (currentFocus is EditText) {
return super.onKeyUp(keyCode, event)
}
val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
var customKeyCode: Int? = null
EventBus.getDefault().post(event)
when (keyCode) {
KeyEvent.KEYCODE_P -> customKeyCode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
KeyEvent.KEYCODE_J, KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_COMMA -> customKeyCode =
KeyEvent.KEYCODE_MEDIA_REWIND
KeyEvent.KEYCODE_K, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_PERIOD -> customKeyCode =
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD
KeyEvent.KEYCODE_PLUS, KeyEvent.KEYCODE_W -> {
audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC,
AudioManager.ADJUST_RAISE, AudioManager.FLAG_SHOW_UI)
return true
}
KeyEvent.KEYCODE_MINUS, KeyEvent.KEYCODE_S -> {
audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC,
AudioManager.ADJUST_LOWER, AudioManager.FLAG_SHOW_UI)
return true
}
KeyEvent.KEYCODE_M -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC,
AudioManager.ADJUST_TOGGLE_MUTE, AudioManager.FLAG_SHOW_UI)
return true
}
}
if (customKeyCode != null) {
sendBroadcast(createIntent(this, customKeyCode))
return true
}
return super.onKeyUp(keyCode, event)
}
companion object {
private const val TAG = "MainActivity"
const val MAIN_FRAGMENT_TAG: String = "main"
const val PREF_NAME: String = "MainActivityPrefs"
const val PREF_IS_FIRST_LAUNCH: String = "prefMainActivityIsFirstLaunch"
const val EXTRA_FEED_ID: String = "fragment_feed_id"
const val EXTRA_REFRESH_ON_START: String = "refresh_on_start"
const val EXTRA_STARTED_FROM_SEARCH: String = "started_from_search"
const val EXTRA_ADD_TO_BACK_STACK: String = "add_to_back_stack"
const val KEY_GENERATED_VIEW_ID: String = "generated_view_id"
@JvmStatic
fun getIntentToOpenFeed(context: Context, feedId: Long): Intent {
val intent = Intent(context.applicationContext, MainActivity::class.java)
intent.putExtra(EXTRA_FEED_ID, feedId)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
return intent
}
}
}

View File

@ -1,706 +0,0 @@
package ac.mdiq.podcini.activity
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.LightingColorFilter
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString
import android.text.TextUtils
import android.text.style.ForegroundColorSpan
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.annotation.UiThread
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.media3.common.util.UnstableApi
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import ac.mdiq.podcini.R
import ac.mdiq.podcini.adapter.FeedItemlistDescriptionAdapter
import ac.mdiq.podcini.core.feed.FeedUrlNotFoundException
import ac.mdiq.podcini.core.preferences.PlaybackPreferences.Companion.currentlyPlayingMediaType
import ac.mdiq.podcini.core.preferences.PlaybackPreferences.Companion.writeNoMediaPlaying
import ac.mdiq.podcini.core.preferences.ThemeSwitcher.getTranslucentTheme
import ac.mdiq.podcini.core.service.download.DownloadRequestCreator.create
import ac.mdiq.podcini.core.service.download.Downloader
import ac.mdiq.podcini.core.service.download.HttpDownloader
import ac.mdiq.podcini.core.service.playback.PlaybackServiceInterface
import ac.mdiq.podcini.core.storage.DBReader
import ac.mdiq.podcini.core.storage.DBTasks
import ac.mdiq.podcini.core.storage.DBWriter
import ac.mdiq.podcini.core.util.DownloadErrorLabel.from
import ac.mdiq.podcini.core.util.IntentUtils.sendLocalBroadcast
import ac.mdiq.podcini.core.util.syndication.FeedDiscoverer
import ac.mdiq.podcini.core.util.syndication.HtmlToPlainText
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.databinding.OnlinefeedviewActivityBinding
import ac.mdiq.podcini.databinding.OnlinefeedviewHeaderBinding
import ac.mdiq.podcini.dialog.AuthenticationDialog
import ac.mdiq.podcini.event.EpisodeDownloadEvent
import ac.mdiq.podcini.event.FeedListUpdateEvent
import ac.mdiq.podcini.event.PlayerStatusEvent
import ac.mdiq.podcini.model.download.DownloadError
import ac.mdiq.podcini.model.download.DownloadResult
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.model.playback.RemoteMedia
import ac.mdiq.podcini.net.common.UrlChecker.prepareUrl
import ac.mdiq.podcini.net.discovery.CombinedSearcher
import ac.mdiq.podcini.net.discovery.PodcastSearcherRegistry
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.parser.feed.FeedHandler
import ac.mdiq.podcini.parser.feed.FeedHandlerResult
import ac.mdiq.podcini.parser.feed.UnsupportedFeedtypeException
import ac.mdiq.podcini.storage.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.ui.common.ThemeUtils.getColorFromAttr
import ac.mdiq.podcini.ui.glide.FastBlurTransformation
import io.reactivex.Maybe
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.observers.DisposableMaybeObserver
import io.reactivex.schedulers.Schedulers
import org.apache.commons.lang3.StringUtils
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.io.File
import java.io.IOException
import kotlin.concurrent.Volatile
/**
* Downloads a feed from a feed URL and parses it. Subclasses can display the
* feed object that was parsed. This activity MUST be started with a given URL
* or an Exception will be thrown.
*
*
* If the feed cannot be downloaded or parsed, an error dialog will be displayed
* and the activity will finish as soon as the error dialog is closed.
*/
class OnlineFeedViewActivity : AppCompatActivity() {
@Volatile
private var feeds: List<Feed>? = null
private var selectedDownloadUrl: String? = null
private var downloader: Downloader? = null
private var username: String? = null
private var password: String? = null
private var isPaused = false
private var didPressSubscribe = false
private var isFeedFoundBySearch = false
private var dialog: Dialog? = null
private var download: Disposable? = null
private var parser: Disposable? = null
private var updater: Disposable? = null
private lateinit var headerBinding: OnlinefeedviewHeaderBinding
private lateinit var viewBinding: OnlinefeedviewActivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(getTranslucentTheme(this))
super.onCreate(savedInstanceState)
viewBinding = OnlinefeedviewActivityBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
viewBinding.transparentBackground.setOnClickListener { v: View? -> finish() }
viewBinding.closeButton.setOnClickListener { view: View? -> finish() }
viewBinding.card.setOnClickListener(null)
viewBinding.card.setCardBackgroundColor(getColorFromAttr(this, R.attr.colorSurface))
headerBinding = OnlinefeedviewHeaderBinding.inflate(layoutInflater)
var feedUrl: String? = null
if (intent.hasExtra(ARG_FEEDURL)) {
feedUrl = intent.getStringExtra(ARG_FEEDURL)
} else if (TextUtils.equals(intent.action, Intent.ACTION_SEND)) {
feedUrl = intent.getStringExtra(Intent.EXTRA_TEXT)
} else if (TextUtils.equals(intent.action, Intent.ACTION_VIEW)) {
feedUrl = intent.dataString
}
if (feedUrl == null) {
Log.e(TAG, "feedUrl is null.")
showNoPodcastFoundError()
} else {
Log.d(TAG, "Activity was started with url $feedUrl")
setLoadingLayout()
// Remove subscribeonandroid.com from feed URL in order to subscribe to the actual feed URL
if (feedUrl.contains("subscribeonandroid.com")) {
feedUrl = feedUrl.replaceFirst("((www.)?(subscribeonandroid.com/))".toRegex(), "")
}
if (savedInstanceState != null) {
username = savedInstanceState.getString("username")
password = savedInstanceState.getString("password")
}
lookupUrlAndDownload(feedUrl)
}
}
private fun showNoPodcastFoundError() {
runOnUiThread {
MaterialAlertDialogBuilder(this@OnlineFeedViewActivity)
.setNeutralButton(android.R.string.ok) { dialog: DialogInterface?, which: Int -> finish() }
.setTitle(R.string.error_label)
.setMessage(R.string.null_value_podcast_error)
.setOnDismissListener { dialog1: DialogInterface? ->
setResult(RESULT_ERROR)
finish()
}
.show()
}
}
/**
* Displays a progress indicator.
*/
private fun setLoadingLayout() {
viewBinding.progressBar.visibility = View.VISIBLE
viewBinding.feedDisplayContainer.visibility = View.GONE
}
override fun onStart() {
super.onStart()
isPaused = false
EventBus.getDefault().register(this)
}
override fun onStop() {
super.onStop()
isPaused = true
EventBus.getDefault().unregister(this)
if (downloader != null && !downloader!!.isFinished) {
downloader!!.cancel()
}
if (dialog != null && dialog!!.isShowing) {
dialog!!.dismiss()
}
}
public override fun onDestroy() {
super.onDestroy()
updater?.dispose()
download?.dispose()
parser?.dispose()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("username", username)
outState.putString("password", password)
}
private fun resetIntent(url: String) {
val intent = Intent()
intent.putExtra(ARG_FEEDURL, url)
setIntent(intent)
}
override fun finish() {
super.finish()
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
}
@UnstableApi override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
val destIntent = Intent(this, MainActivity::class.java)
if (NavUtils.shouldUpRecreateTask(this, destIntent)) {
startActivity(destIntent)
} else {
NavUtils.navigateUpFromSameTask(this)
}
return true
}
return super.onOptionsItemSelected(item)
}
private fun lookupUrlAndDownload(url: String) {
download = PodcastSearcherRegistry.lookupUrl(url)
?.subscribeOn(Schedulers.io())
?.observeOn(Schedulers.io())
?.subscribe({ url1: String -> this.startFeedDownload(url1) },
{ error: Throwable? ->
if (error is FeedUrlNotFoundException) {
tryToRetrieveFeedUrlBySearch(error)
} else {
showNoPodcastFoundError()
Log.e(TAG, Log.getStackTraceString(error))
}
})
}
private fun tryToRetrieveFeedUrlBySearch(error: FeedUrlNotFoundException) {
Log.d(TAG, "Unable to retrieve feed url, trying to retrieve feed url from search")
val url = searchFeedUrlByTrackName(error.trackName, error.artistName)
if (url != null) {
Log.d(TAG, "Successfully retrieve feed url")
isFeedFoundBySearch = true
startFeedDownload(url)
} else {
showNoPodcastFoundError()
Log.d(TAG, "Failed to retrieve feed url")
}
}
private fun searchFeedUrlByTrackName(trackName: String, artistName: String): String? {
val searcher = CombinedSearcher()
val query = "$trackName $artistName"
val results = searcher.search(query)?.blockingGet()
if (results.isNullOrEmpty()) return null
for (result in results) {
if (result?.feedUrl != null && result.author != null &&
result.author.equals(artistName, ignoreCase = true) &&
result.title.equals(trackName, ignoreCase = true)) {
return result.feedUrl
}
}
return null
}
private fun startFeedDownload(url: String) {
Log.d(TAG, "Starting feed download")
selectedDownloadUrl = prepareUrl(url)
val request = create(Feed(selectedDownloadUrl, null))
.withAuthentication(username, password)
.withInitiatedByUser(true)
.build()
download = Observable.fromCallable {
feeds = DBReader.getFeedList()
downloader = HttpDownloader(request)
downloader?.call()
downloader?.result
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ status: DownloadResult? -> if (request.destination != null) checkDownloadResult(status, request.destination!!) },
{ error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) })
}
private fun checkDownloadResult(status: DownloadResult?, destination: String) {
if (status == null) return
if (status.isSuccessful) {
parseFeed(destination)
} else if (status.reason == DownloadError.ERROR_UNAUTHORIZED) {
if (!isFinishing && !isPaused) {
if (username != null && password != null) {
Toast.makeText(this, R.string.download_error_unauthorized, Toast.LENGTH_LONG).show()
}
if (downloader?.downloadRequest?.source != null) {
dialog = FeedViewAuthenticationDialog(this@OnlineFeedViewActivity,
R.string.authentication_notification_title, downloader!!.downloadRequest.source!!).create()
dialog?.show()
}
}
} else {
showErrorDialog(getString(from(status.reason)), status.reasonDetailed)
}
}
@UnstableApi @Subscribe
fun onFeedListChanged(event: FeedListUpdateEvent?) {
updater = Observable.fromCallable { DBReader.getFeedList() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ feeds: List<Feed>? ->
this@OnlineFeedViewActivity.feeds = feeds
handleUpdatedFeedStatus()
}, { error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) }
)
}
@UnstableApi @Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
fun onEventMainThread(event: EpisodeDownloadEvent?) {
handleUpdatedFeedStatus()
}
private fun parseFeed(destination: String) {
Log.d(TAG, "Parsing feed")
parser = Maybe.fromCallable { doParseFeed(destination) }
.subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribeWith(object : DisposableMaybeObserver<FeedHandlerResult?>() {
@UnstableApi override fun onSuccess(result: FeedHandlerResult) {
showFeedInformation(result.feed, result.alternateFeedUrls)
}
override fun onComplete() {
// Ignore null result: We showed the discovery dialog.
}
override fun onError(error: Throwable) {
showErrorDialog(error.message, "")
Log.d(TAG, "Feed parser exception: " + Log.getStackTraceString(error))
}
})
}
/**
* Try to parse the feed.
* @return The FeedHandlerResult if successful.
* Null if unsuccessful but we started another attempt.
* @throws Exception If unsuccessful but we do not know a resolution.
*/
@Throws(Exception::class)
private fun doParseFeed(destination: String): FeedHandlerResult? {
val handler = FeedHandler()
val feed = Feed(selectedDownloadUrl, null)
feed.file_url = destination
val destinationFile = File(destination)
return try {
handler.parseFeed(feed)
} catch (e: UnsupportedFeedtypeException) {
Log.d(TAG, "Unsupported feed type detected")
if ("html".equals(e.rootElement, ignoreCase = true)) {
if (selectedDownloadUrl != null) {
val dialogShown = showFeedDiscoveryDialog(destinationFile, selectedDownloadUrl!!)
if (dialogShown) {
null // Should not display an error message
} else {
throw UnsupportedFeedtypeException(getString(R.string.download_error_unsupported_type_html))
}
} else null
} else {
throw e
}
} catch (e: Exception) {
Log.e(TAG, Log.getStackTraceString(e))
throw e
} finally {
val rc = destinationFile.delete()
Log.d(TAG, "Deleted feed source file. Result: $rc")
}
}
/**
* Called when feed parsed successfully.
* This method is executed on the GUI thread.
*/
@UnstableApi private fun showFeedInformation(feed: Feed, alternateFeedUrls: Map<String, String>) {
viewBinding.progressBar.visibility = View.GONE
viewBinding.feedDisplayContainer.visibility = View.VISIBLE
if (isFeedFoundBySearch) {
val resId = R.string.no_feed_url_podcast_found_by_search
Snackbar.make(findViewById(android.R.id.content), resId, Snackbar.LENGTH_LONG).show()
}
viewBinding.backgroundImage.colorFilter = LightingColorFilter(-0x7d7d7e, 0x000000)
viewBinding.listView.addHeaderView(headerBinding.root)
viewBinding.listView.setSelector(android.R.color.transparent)
viewBinding.listView.adapter = FeedItemlistDescriptionAdapter(this, 0, feed.items)
if (StringUtils.isNotBlank(feed.imageUrl)) {
Glide.with(this)
.load(feed.imageUrl)
.apply(RequestOptions()
.placeholder(R.color.light_gray)
.error(R.color.light_gray)
.fitCenter()
.dontAnimate())
.into(viewBinding.coverImage)
Glide.with(this)
.load(feed.imageUrl)
.apply(RequestOptions()
.placeholder(R.color.image_readability_tint)
.error(R.color.image_readability_tint)
.transform(FastBlurTransformation())
.dontAnimate())
.into(viewBinding.backgroundImage)
}
viewBinding.titleLabel.text = feed.title
viewBinding.authorLabel.text = feed.author
headerBinding.txtvDescription.text = HtmlToPlainText.getPlainText(feed.description?:"")
viewBinding.subscribeButton.setOnClickListener { v: View? ->
if (feedInFeedlist()) {
openFeed()
} else {
DBTasks.updateFeed(this, feed, false)
didPressSubscribe = true
handleUpdatedFeedStatus()
}
}
viewBinding.stopPreviewButton.setOnClickListener { v: View? ->
writeNoMediaPlaying()
sendLocalBroadcast(this, PlaybackServiceInterface.ACTION_SHUTDOWN_PLAYBACK_SERVICE)
}
if (isEnableAutodownload) {
val preferences = getSharedPreferences(PREFS, MODE_PRIVATE)
viewBinding.autoDownloadCheckBox.isChecked = preferences.getBoolean(PREF_LAST_AUTO_DOWNLOAD, true)
}
headerBinding.txtvDescription.maxLines = DESCRIPTION_MAX_LINES_COLLAPSED
headerBinding.txtvDescription.setOnClickListener { v: View? ->
if (headerBinding.txtvDescription.maxLines > DESCRIPTION_MAX_LINES_COLLAPSED) {
headerBinding.txtvDescription.maxLines = DESCRIPTION_MAX_LINES_COLLAPSED
} else {
headerBinding.txtvDescription.maxLines = 2000
}
}
if (alternateFeedUrls.isEmpty()) {
viewBinding.alternateUrlsSpinner.visibility = View.GONE
} else {
viewBinding.alternateUrlsSpinner.visibility = View.VISIBLE
val alternateUrlsList: MutableList<String> = ArrayList()
val alternateUrlsTitleList: MutableList<String?> = ArrayList()
if (feed.download_url != null) alternateUrlsList.add(feed.download_url!!)
alternateUrlsTitleList.add(feed.title)
alternateUrlsList.addAll(alternateFeedUrls.keys)
for (url in alternateFeedUrls.keys) {
alternateUrlsTitleList.add(alternateFeedUrls[url])
}
val adapter: ArrayAdapter<String> = object : ArrayAdapter<String>(this,
R.layout.alternate_urls_item, alternateUrlsTitleList) {
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
// reusing the old view causes a visual bug on Android <= 10
return super.getDropDownView(position, null, parent)
}
}
adapter.setDropDownViewResource(R.layout.alternate_urls_dropdown_item)
viewBinding.alternateUrlsSpinner.adapter = adapter
viewBinding.alternateUrlsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
selectedDownloadUrl = alternateUrlsList[position]
}
override fun onNothingSelected(parent: AdapterView<*>?) {
}
}
}
handleUpdatedFeedStatus()
}
@UnstableApi private fun openFeed() {
// feed.getId() is always 0, we have to retrieve the id from the feed list from
// the database
val intent = MainActivity.getIntentToOpenFeed(this, feedId)
intent.putExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH,
getIntent().getBooleanExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, false))
finish()
startActivity(intent)
}
@UnstableApi private fun handleUpdatedFeedStatus() {
val dli = DownloadServiceInterface.get()
if (dli == null || selectedDownloadUrl == null) return
if (dli.isDownloadingEpisode(selectedDownloadUrl!!)) {
viewBinding.subscribeButton.isEnabled = false
viewBinding.subscribeButton.setText(R.string.subscribing_label)
} else if (feedInFeedlist()) {
viewBinding.subscribeButton.isEnabled = true
viewBinding.subscribeButton.setText(R.string.open_podcast)
if (didPressSubscribe) {
didPressSubscribe = false
val feed1 = DBReader.getFeed(feedId)?: return
val feedPreferences = feed1.preferences
if (feedPreferences != null) {
if (isEnableAutodownload) {
val autoDownload = viewBinding.autoDownloadCheckBox.isChecked
feedPreferences.autoDownload = autoDownload
val preferences = getSharedPreferences(PREFS, MODE_PRIVATE)
val editor = preferences.edit()
editor.putBoolean(PREF_LAST_AUTO_DOWNLOAD, autoDownload)
editor.apply()
}
if (username != null) {
feedPreferences.username = username
feedPreferences.password = password
}
DBWriter.setFeedPreferences(feedPreferences)
}
openFeed()
}
} else {
viewBinding.subscribeButton.isEnabled = true
viewBinding.subscribeButton.setText(R.string.subscribe_label)
if (isEnableAutodownload) {
viewBinding.autoDownloadCheckBox.visibility = View.VISIBLE
}
}
}
private fun feedInFeedlist(): Boolean {
return feedId != 0L
}
private val feedId: Long
get() {
if (feeds == null) {
return 0
}
for (f in feeds!!) {
if (f.download_url == selectedDownloadUrl) {
return f.id
}
}
return 0
}
@UiThread
private fun showErrorDialog(errorMsg: String?, details: String) {
if (!isFinishing && !isPaused) {
val builder = MaterialAlertDialogBuilder(this)
builder.setTitle(R.string.error_label)
if (errorMsg != null) {
val total = """
$errorMsg
$details
""".trimIndent()
val errorMessage = SpannableString(total)
errorMessage.setSpan(ForegroundColorSpan(-0x77777778),
errorMsg.length, total.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
builder.setMessage(errorMessage)
} else {
builder.setMessage(R.string.download_error_error_unknown)
}
builder.setPositiveButton(android.R.string.ok) { dialog: DialogInterface, which: Int -> dialog.cancel() }
if (intent.getBooleanExtra(ARG_WAS_MANUAL_URL, false)) {
builder.setNeutralButton(R.string.edit_url_menu) { dialog: DialogInterface?, which: Int -> editUrl() }
}
builder.setOnCancelListener { dialog: DialogInterface? ->
setResult(RESULT_ERROR)
finish()
}
if (dialog != null && dialog!!.isShowing) {
dialog!!.dismiss()
}
dialog = builder.show()
}
}
private fun editUrl() {
val builder = MaterialAlertDialogBuilder(this)
builder.setTitle(R.string.edit_url_menu)
val dialogBinding = EditTextDialogBinding.inflate(layoutInflater)
if (downloader != null) {
dialogBinding.urlEditText.setText(downloader!!.downloadRequest.source)
}
builder.setView(dialogBinding.root)
builder.setPositiveButton(R.string.confirm_label) { dialog: DialogInterface?, which: Int ->
setLoadingLayout()
lookupUrlAndDownload(dialogBinding.urlEditText.text.toString())
}
builder.setNegativeButton(R.string.cancel_label) { dialog1: DialogInterface, which: Int -> dialog1.cancel() }
builder.setOnCancelListener { dialog1: DialogInterface? ->
setResult(RESULT_ERROR)
finish()
}
builder.show()
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun playbackStateChanged(event: PlayerStatusEvent?) {
val isPlayingPreview = currentlyPlayingMediaType == RemoteMedia.PLAYABLE_TYPE_REMOTE_MEDIA.toLong()
viewBinding.stopPreviewButton.visibility = if (isPlayingPreview) View.VISIBLE else View.GONE
}
/**
*
* @return true if a FeedDiscoveryDialog is shown, false otherwise (e.g., due to no feed found).
*/
private fun showFeedDiscoveryDialog(feedFile: File, baseUrl: String): Boolean {
val fd = FeedDiscoverer()
val urlsMap: Map<String, String>
try {
urlsMap = fd.findLinks(feedFile, baseUrl)
if (urlsMap.isEmpty()) {
return false
}
} catch (e: IOException) {
e.printStackTrace()
return false
}
if (isPaused || isFinishing) {
return false
}
val titles: MutableList<String?> = ArrayList()
val urls: List<String> = ArrayList(urlsMap.keys)
for (url in urls) {
titles.add(urlsMap[url])
}
if (urls.size == 1) {
// Skip dialog and display the item directly
resetIntent(urls[0])
startFeedDownload(urls[0])
return true
}
val adapter = ArrayAdapter(this@OnlineFeedViewActivity,
R.layout.ellipsize_start_listitem, R.id.txtvTitle, titles)
val onClickListener = DialogInterface.OnClickListener { dialog: DialogInterface, which: Int ->
val selectedUrl = urls[which]
dialog.dismiss()
resetIntent(selectedUrl)
startFeedDownload(selectedUrl)
}
val ab = MaterialAlertDialogBuilder(this@OnlineFeedViewActivity)
.setTitle(R.string.feeds_label)
.setCancelable(true)
.setOnCancelListener { dialog: DialogInterface? -> finish() }
.setAdapter(adapter, onClickListener)
runOnUiThread {
if (dialog != null && dialog!!.isShowing) {
dialog!!.dismiss()
}
dialog = ab.show()
}
return true
}
private inner class FeedViewAuthenticationDialog(context: Context?, titleRes: Int, private val feedUrl: String) :
AuthenticationDialog(context, titleRes, true, username, password) {
override fun onCancelled() {
super.onCancelled()
finish()
}
override fun onConfirmed(username: String, password: String) {
this@OnlineFeedViewActivity.username = username
this@OnlineFeedViewActivity.password = password
startFeedDownload(feedUrl)
}
}
companion object {
const val ARG_FEEDURL: String = "arg.feedurl"
const val ARG_WAS_MANUAL_URL: String = "manual_url"
private const val RESULT_ERROR = 2
private const val TAG = "OnlineFeedViewActivity"
private const val PREFS = "OnlineFeedViewActivityPreferences"
private const val PREF_LAST_AUTO_DOWNLOAD = "lastAutoDownload"
private const val DESCRIPTION_MAX_LINES_COLLAPSED = 4
}
}

View File

@ -1,270 +0,0 @@
package ac.mdiq.podcini.activity
import android.Manifest
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.AdapterView
import android.widget.AdapterView.OnItemClickListener
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.media3.common.util.UnstableApi
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.export.opml.OpmlElement
import ac.mdiq.podcini.core.export.opml.OpmlReader
import ac.mdiq.podcini.core.preferences.ThemeSwitcher.getTheme
import ac.mdiq.podcini.core.storage.DBTasks
import ac.mdiq.podcini.core.util.download.FeedUpdateManager.runOnce
import ac.mdiq.podcini.databinding.OpmlSelectionBinding
import ac.mdiq.podcini.model.feed.Feed
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import org.apache.commons.io.input.BOMInputStream
import java.io.InputStreamReader
import java.io.Reader
/**
* Activity for Opml Import.
*/
class OpmlImportActivity : AppCompatActivity() {
private var uri: Uri? = null
private lateinit var viewBinding: OpmlSelectionBinding
private lateinit var selectAll: MenuItem
private lateinit var deselectAll: MenuItem
private var listAdapter: ArrayAdapter<String>? = null
private var readElements: ArrayList<OpmlElement>? = null
@UnstableApi override fun onCreate(savedInstanceState: Bundle?) {
setTheme(getTheme(this))
super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
viewBinding = OpmlSelectionBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
viewBinding.feedlist.choiceMode = ListView.CHOICE_MODE_MULTIPLE
viewBinding.feedlist.onItemClickListener =
OnItemClickListener { parent: AdapterView<*>?, view: View?, position: Int, id: Long ->
val checked = viewBinding.feedlist.checkedItemPositions
var checkedCount = 0
for (i in 0 until checked.size()) {
if (checked.valueAt(i)) {
checkedCount++
}
}
if (listAdapter != null) {
if (checkedCount == listAdapter!!.count) {
selectAll.setVisible(false)
deselectAll.setVisible(true)
} else {
deselectAll.setVisible(false)
selectAll.setVisible(true)
}
}
}
viewBinding.butCancel.setOnClickListener { v: View? ->
setResult(RESULT_CANCELED)
finish()
}
viewBinding.butConfirm.setOnClickListener { v: View? ->
viewBinding.progressBar.visibility = View.VISIBLE
Completable.fromAction {
val checked = viewBinding.feedlist.checkedItemPositions
for (i in 0 until checked.size()) {
if (!checked.valueAt(i)) {
continue
}
if (!readElements.isNullOrEmpty()) {
val element = readElements!![checked.keyAt(i)]
val feed = Feed(element.xmlUrl, null,
if (element.text != null) element.text else "Unknown podcast")
feed.items = mutableListOf()
DBTasks.updateFeed(this, feed, false)
}
}
runOnce(this)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
viewBinding.progressBar.visibility = View.GONE
val intent = Intent(this@OpmlImportActivity, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
}, { e: Throwable ->
e.printStackTrace()
viewBinding.progressBar.visibility = View.GONE
Toast.makeText(this, e.message, Toast.LENGTH_LONG).show()
})
}
var uri = intent.data
if (uri != null && uri.toString().startsWith("/")) {
uri = Uri.parse("file://$uri")
} else {
val extraText = intent.getStringExtra(Intent.EXTRA_TEXT)
if (extraText != null) {
uri = Uri.parse(extraText)
}
}
importUri(uri)
}
fun importUri(uri: Uri?) {
if (uri == null) {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.opml_import_error_no_file)
.setPositiveButton(android.R.string.ok, null)
.show()
return
}
this.uri = uri
startImport()
}
private val titleList: List<String>
get() {
val result: MutableList<String> = ArrayList()
if (!readElements.isNullOrEmpty()) {
for (element in readElements!!) {
if (element.text != null) result.add(element.text!!)
}
}
return result
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
val inflater = menuInflater
inflater.inflate(R.menu.opml_selection_options, menu)
selectAll = menu.findItem(R.id.select_all_item)
deselectAll = menu.findItem(R.id.deselect_all_item)
deselectAll.setVisible(false)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val itemId = item.itemId
when (itemId) {
R.id.select_all_item -> {
selectAll.setVisible(false)
selectAllItems(true)
deselectAll.setVisible(true)
return true
}
R.id.deselect_all_item -> {
deselectAll.setVisible(false)
selectAllItems(false)
selectAll.setVisible(true)
return true
}
android.R.id.home -> {
finish()
}
}
return false
}
private fun selectAllItems(b: Boolean) {
for (i in 0 until viewBinding.feedlist.count) {
viewBinding.feedlist.setItemChecked(i, b)
}
}
private fun requestPermission() {
requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted) {
startImport()
} else {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.opml_import_ask_read_permission)
.setPositiveButton(android.R.string.ok) { dialog: DialogInterface?, which: Int -> requestPermission() }
.setNegativeButton(R.string.cancel_label) { dialog: DialogInterface?, which: Int -> finish() }
.show()
}
}
/** Starts the import process. */
private fun startImport() {
viewBinding.progressBar.visibility = View.VISIBLE
Observable.fromCallable {
val opmlFileStream = contentResolver.openInputStream(uri!!)
val bomInputStream = BOMInputStream(opmlFileStream)
val bom = bomInputStream.bom
val charsetName = if (bom == null) "UTF-8" else bom.charsetName
val reader: Reader = InputStreamReader(bomInputStream, charsetName)
val opmlReader = OpmlReader()
val result = opmlReader.readDocument(reader)
reader.close()
result
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ result: ArrayList<OpmlElement>? ->
viewBinding.progressBar.visibility = View.GONE
Log.d(TAG, "Parsing was successful")
readElements = result
listAdapter = ArrayAdapter(this@OpmlImportActivity,
android.R.layout.simple_list_item_multiple_choice,
titleList)
viewBinding.feedlist.adapter = listAdapter
}, { e: Throwable ->
Log.d(TAG, Log.getStackTraceString(e))
val message = if (e.message == null) "" else e.message!!
if (message.lowercase().contains("permission")
&& Build.VERSION.SDK_INT >= 23) {
val permission = ActivityCompat.checkSelfPermission(this,
Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
requestPermission()
return@subscribe
}
}
viewBinding.progressBar.visibility = View.GONE
val alert = MaterialAlertDialogBuilder(this)
alert.setTitle(R.string.error_label)
val userReadable = getString(R.string.opml_reader_error)
val details = e.message
val total = """
$userReadable
$details
""".trimIndent()
val errorMessage = SpannableString(total)
errorMessage.setSpan(ForegroundColorSpan(-0x77777778),
userReadable.length, total.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
alert.setMessage(errorMessage)
alert.setPositiveButton(android.R.string.ok) { dialog: DialogInterface?, which: Int -> finish() }
alert.show()
})
}
companion object {
private const val TAG = "OpmlImportBaseActivity"
}
}

View File

@ -1,23 +0,0 @@
package ac.mdiq.podcini.activity
import android.content.DialogInterface
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import ac.mdiq.podcini.core.preferences.ThemeSwitcher.getTranslucentTheme
import ac.mdiq.podcini.dialog.VariableSpeedDialog
class PlaybackSpeedDialogActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(getTranslucentTheme(this))
super.onCreate(savedInstanceState)
val speedDialog: VariableSpeedDialog = InnerVariableSpeedDialog()
speedDialog.show(supportFragmentManager, null)
}
class InnerVariableSpeedDialog : VariableSpeedDialog() {
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
requireActivity().finish()
}
}
}

View File

@ -1,203 +0,0 @@
package ac.mdiq.podcini.activity
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
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 ac.mdiq.podcini.R
import ac.mdiq.podcini.core.preferences.ThemeSwitcher.getTheme
import ac.mdiq.podcini.databinding.SettingsActivityBinding
import ac.mdiq.podcini.event.MessageEvent
import ac.mdiq.podcini.fragment.preferences.*
import ac.mdiq.podcini.fragment.preferences.synchronization.SynchronizationPreferencesFragment
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
/**
* PreferenceActivity for API 11+. In order to change the behavior of the preference UI, see
* PreferenceController.
*/
class PreferenceActivity : AppCompatActivity(), SearchPreferenceResultListener {
private lateinit var binding: SettingsActivityBinding
@SuppressLint("CommitTransaction")
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(getTheme(this))
super.onCreate(savedInstanceState)
val ab = supportActionBar
ab?.setDisplayHomeAsUpEnabled(true)
binding = SettingsActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
if (supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) == null) {
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)
}
}
private fun getPreferenceScreen(screen: Int): PreferenceFragmentCompat? {
var prefFragment: PreferenceFragmentCompat? = null
when (screen) {
R.xml.preferences_user_interface -> {
prefFragment = UserInterfacePreferencesFragment()
}
R.xml.preferences_downloads -> {
prefFragment = DownloadsPreferencesFragment()
}
R.xml.preferences_import_export -> {
prefFragment = ImportExportPreferencesFragment()
}
R.xml.preferences_autodownload -> {
prefFragment = AutoDownloadPreferencesFragment()
}
R.xml.preferences_synchronization -> {
prefFragment = SynchronizationPreferencesFragment()
}
R.xml.preferences_playback -> {
prefFragment = PlaybackPreferencesFragment()
}
R.xml.preferences_notifications -> {
prefFragment = NotificationPreferencesFragment()
}
R.xml.preferences_swipe -> {
prefFragment = SwipePreferencesFragment()
}
}
return prefFragment
}
@SuppressLint("CommitTransaction")
fun openScreen(screen: Int): PreferenceFragmentCompat? {
val fragment = getPreferenceScreen(screen)
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
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
if (supportFragmentManager.backStackEntryCount == 0) {
finish()
} else {
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
var view = currentFocus
//If no view currently has focus, create a new one, just so we can grab a window token from it
if (view == null) {
view = View(this)
}
imm.hideSoftInputFromWindow(view.windowToken, 0)
supportFragmentManager.popBackStack()
}
return true
}
return false
}
override fun onSearchResultClicked(result: SearchPreferenceResult) {
when (val screen = result.resourceFile) {
R.xml.feed_settings -> {
val builder = MaterialAlertDialogBuilder(this)
builder.setTitle(R.string.feed_settings_label)
builder.setMessage(R.string.pref_feed_settings_dialog_msg)
builder.setPositiveButton(android.R.string.ok, null)
builder.show()
}
R.xml.preferences_notifications -> {
openScreen(screen)
}
else -> {
val fragment = openScreen(result.resourceFile)
result.highlight(fragment)
}
}
}
override fun onStart() {
super.onStart()
EventBus.getDefault().register(this)
}
override fun onStop() {
super.onStop()
EventBus.getDefault().unregister(this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEventMainThread(event: MessageEvent) {
Log.d(FRAGMENT_TAG, "onEvent($event)")
val s = Snackbar.make(binding.root, event.message, Snackbar.LENGTH_LONG)
if (event.action != null) {
s.setAction(event.actionText) { v: View? -> event.action!!.accept(this) }
}
s.show()
}
companion object {
private const val FRAGMENT_TAG = "tag_preferences"
const val OPEN_AUTO_DOWNLOAD_SETTINGS: String = "OpenAutoDownloadSettings"
@JvmStatic
fun getTitleOfPage(preferences: Int): Int {
when (preferences) {
R.xml.preferences_downloads -> {
return R.string.downloads_pref
}
R.xml.preferences_autodownload -> {
return R.string.pref_automatic_download_title
}
R.xml.preferences_playback -> {
return R.string.playback_pref
}
R.xml.preferences_import_export -> {
return R.string.import_export_pref
}
R.xml.preferences_user_interface -> {
return R.string.user_interface_label
}
R.xml.preferences_synchronization -> {
return R.string.synchronization_pref
}
R.xml.preferences_notifications -> {
return R.string.notification_pref_fragment
}
R.xml.feed_settings -> {
return R.string.feed_settings_label
}
R.xml.preferences_swipe -> {
return R.string.swipeactions_label
}
else -> return R.string.settings_label
}
}
}
}

View File

@ -1,158 +0,0 @@
package ac.mdiq.podcini.activity
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.MainActivity.Companion.EXTRA_FEED_ID
import ac.mdiq.podcini.core.preferences.ThemeSwitcher
import ac.mdiq.podcini.core.storage.DBReader
import ac.mdiq.podcini.core.storage.NavDrawerData
import ac.mdiq.podcini.databinding.SubscriptionSelectionActivityBinding
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.storage.preferences.UserPreferences
import android.app.Activity
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ListView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.media3.common.util.UnstableApi
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
class SelectSubscriptionActivity : AppCompatActivity() {
private var disposable: Disposable? = null
@Volatile
private var listItems: List<Feed>? = null
private lateinit var viewBinding: SubscriptionSelectionActivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(ThemeSwitcher.getTranslucentTheme(this))
super.onCreate(savedInstanceState)
viewBinding = SubscriptionSelectionActivityBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
setSupportActionBar(viewBinding.toolbar)
setTitle(R.string.shortcut_select_subscription)
viewBinding.transparentBackground.setOnClickListener { v: View? -> finish() }
viewBinding.card.setOnClickListener(null)
loadSubscriptions()
val checkedPosition = arrayOfNulls<Int>(1)
viewBinding.list.choiceMode = ListView.CHOICE_MODE_SINGLE
viewBinding.list.onItemClickListener =
AdapterView.OnItemClickListener { listView: AdapterView<*>?, view1: View?, position: Int, rowId: Long ->
checkedPosition[0] = position
}
viewBinding.shortcutBtn.setOnClickListener { view: View? ->
if (checkedPosition[0] != null && Intent.ACTION_CREATE_SHORTCUT == intent.action) {
getBitmapFromUrl(listItems!![checkedPosition[0]!!])
}
}
}
fun getFeedItems(items: List<NavDrawerData.DrawerItem?>, result: MutableList<Feed>): List<Feed> {
for (item in items) {
if (item == null) continue
if (item.type == NavDrawerData.DrawerItem.Type.TAG) {
getFeedItems((item as NavDrawerData.TagDrawerItem).children, result)
} else {
val feed: Feed = (item as NavDrawerData.FeedDrawerItem).feed
if (!result.contains(feed)) {
result.add(feed)
}
}
}
return result
}
@UnstableApi private fun addShortcut(feed: Feed, bitmap: Bitmap?) {
val intent = Intent(this, MainActivity::class.java)
intent.setAction(Intent.ACTION_MAIN)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
intent.putExtra(EXTRA_FEED_ID, feed.id)
val id = "subscription-" + feed.id
val icon: IconCompat = if (bitmap != null) {
IconCompat.createWithAdaptiveBitmap(bitmap)
} else {
IconCompat.createWithResource(this, R.drawable.ic_subscriptions_shortcut)
}
val shortcut: ShortcutInfoCompat = ShortcutInfoCompat.Builder(this, id)
.setShortLabel(feed.title?:"")
.setLongLabel(feed.feedTitle?:"")
.setIntent(intent)
.setIcon(icon)
.build()
setResult(Activity.RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent(this, shortcut))
finish()
}
private fun getBitmapFromUrl(feed: Feed) {
val iconSize = (128 * resources.displayMetrics.density).toInt()
Glide.with(this)
.asBitmap()
.load(feed.imageUrl)
.apply(RequestOptions.overrideOf(iconSize, iconSize))
.listener(object : RequestListener<Bitmap?> {
@UnstableApi override fun onLoadFailed(e: GlideException?, model: Any?,
target: Target<Bitmap?>, isFirstResource: Boolean
): Boolean {
addShortcut(feed, null)
return true
}
@UnstableApi override fun onResourceReady(resource: Bitmap, model: Any,
target: Target<Bitmap?>, dataSource: DataSource, isFirstResource: Boolean
): Boolean {
addShortcut(feed, resource)
return true
}
}).submit()
}
private fun loadSubscriptions() {
disposable?.dispose()
disposable = Observable.fromCallable {
val data: NavDrawerData = DBReader.getNavDrawerData(UserPreferences.subscriptionsFilter)
getFeedItems(data.items, ArrayList())
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ result: List<Feed> ->
listItems = result
val titles = ArrayList<String>()
for (feed in result) {
if (feed.title != null) titles.add(feed.title!!)
}
val adapter: ArrayAdapter<String> = ArrayAdapter<String>(this,
R.layout.simple_list_item_multiple_choice_on_start, titles)
viewBinding.list.adapter = adapter
}, { error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) })
}
companion object {
private const val TAG = "SelectSubscription"
}
}

View File

@ -1,48 +0,0 @@
package ac.mdiq.podcini.activity
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.media3.common.util.UnstableApi
import ac.mdiq.podcini.error.CrashReportWriter
import ac.mdiq.podcini.storage.database.PodDBAdapter
import io.reactivex.Completable
import io.reactivex.CompletableEmitter
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
/**
* Shows the Podcini logo while waiting for the main activity to start.
*/
@SuppressLint("CustomSplashScreen")
class SplashActivity : Activity() {
@UnstableApi override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val content = findViewById<View>(android.R.id.content)
content.viewTreeObserver.addOnPreDrawListener { false } // Keep splash screen active
Completable.create { subscriber: CompletableEmitter ->
// Trigger schema updates
PodDBAdapter.getInstance()?.open()
PodDBAdapter.getInstance()?.close()
subscriber.onComplete()
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
val intent = Intent(this@SplashActivity, MainActivity::class.java)
startActivity(intent)
overridePendingTransition(0, 0)
finish()
}, { error: Throwable ->
error.printStackTrace()
CrashReportWriter.write(error)
Toast.makeText(this, error.localizedMessage, Toast.LENGTH_LONG).show()
finish()
})
}
}

View File

@ -1,787 +0,0 @@
package ac.mdiq.podcini.activity
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.MainActivity
import ac.mdiq.podcini.core.service.playback.PlaybackService.Companion.getPlayerActivityIntent
import ac.mdiq.podcini.core.service.playback.PlaybackService.Companion.isCasting
import ac.mdiq.podcini.core.storage.DBReader
import ac.mdiq.podcini.core.storage.DBWriter
import ac.mdiq.podcini.core.util.Converter.getDurationStringLong
import ac.mdiq.podcini.core.util.FeedItemUtil.getLinkWithFallback
import ac.mdiq.podcini.core.util.IntentUtils.openInBrowser
import ac.mdiq.podcini.core.util.ShareUtils.hasLinkToShare
import ac.mdiq.podcini.core.util.TimeSpeedConverter
import ac.mdiq.podcini.core.util.gui.PictureInPictureUtil
import ac.mdiq.podcini.core.util.playback.PlaybackController
import ac.mdiq.podcini.databinding.VideoplayerActivityBinding
import ac.mdiq.podcini.dialog.*
import ac.mdiq.podcini.event.MessageEvent
import ac.mdiq.podcini.event.PlayerErrorEvent
import ac.mdiq.podcini.event.playback.BufferUpdateEvent
import ac.mdiq.podcini.event.playback.PlaybackPositionEvent
import ac.mdiq.podcini.event.playback.PlaybackServiceEvent
import ac.mdiq.podcini.event.playback.SleepTimerUpdatedEvent
import ac.mdiq.podcini.fragment.ChaptersFragment
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.model.feed.FeedMedia
import ac.mdiq.podcini.model.playback.Playable
import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.playback.cast.CastEnabledActivity
import ac.mdiq.podcini.storage.preferences.UserPreferences.fastForwardSecs
import ac.mdiq.podcini.storage.preferences.UserPreferences.rewindSecs
import ac.mdiq.podcini.storage.preferences.UserPreferences.setShowRemainTimeSetting
import ac.mdiq.podcini.storage.preferences.UserPreferences.shouldShowRemainingTime
import ac.mdiq.podcini.ui.appstartintent.MainActivityStarter
import android.content.DialogInterface
import android.content.Intent
import android.graphics.PixelFormat
import android.graphics.drawable.ColorDrawable
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.*
import android.view.View.OnTouchListener
import android.view.animation.*
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import androidx.media3.common.util.UnstableApi
import com.bumptech.glide.Glide
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
/**
* Activity for playing video files.
*/
@UnstableApi
class VideoplayerActivity : CastEnabledActivity(), OnSeekBarChangeListener {
private lateinit var viewBinding: VideoplayerActivityBinding
/**
* True if video controls are currently visible.
*/
private var videoControlsShowing = true
private var videoSurfaceCreated = false
private var destroyingDueToReload = false
private var lastScreenTap: Long = 0
private val videoControlsHider = Handler(Looper.getMainLooper())
private var controller: PlaybackController? = null
private var showTimeLeft = false
private var isFavorite = false
private var switchToAudioOnly = false
private var disposable: Disposable? = null
private var prog = 0f
override fun onCreate(savedInstanceState: Bundle?) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN)
// has to be called before setting layout content
supportRequestWindowFeature(Window.FEATURE_ACTION_BAR_OVERLAY)
setTheme(R.style.Theme_Podcini_VideoPlayer)
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate()")
window.setFormat(PixelFormat.TRANSPARENT)
viewBinding = VideoplayerActivityBinding.inflate(LayoutInflater.from(this))
setContentView(viewBinding.root)
setupView()
supportActionBar?.setBackgroundDrawable(ColorDrawable(-0x80000000))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
@UnstableApi
override fun onResume() {
super.onResume()
switchToAudioOnly = false
if (isCasting) {
val intent = getPlayerActivityIntent(this)
if (intent.component!!.className != VideoplayerActivity::class.java.name) {
destroyingDueToReload = true
finish()
startActivity(intent)
}
}
}
@UnstableApi
override fun onStop() {
controller?.release()
controller = null // prevent leak
disposable?.dispose()
EventBus.getDefault().unregister(this)
super.onStop()
if (!PictureInPictureUtil.isInPictureInPictureMode(this)) {
videoControlsHider.removeCallbacks(hideVideoControls)
}
// Controller released; we will not receive buffering updates
viewBinding.progressBar.visibility = View.GONE
}
public override fun onUserLeaveHint() {
if (!PictureInPictureUtil.isInPictureInPictureMode(this)) {
compatEnterPictureInPicture()
}
}
@UnstableApi
override fun onStart() {
super.onStart()
controller = newPlaybackController()
controller!!.init()
loadMediaInfo()
onPositionObserverUpdate()
EventBus.getDefault().register(this)
}
@UnstableApi
override fun onPause() {
if (!PictureInPictureUtil.isInPictureInPictureMode(this)) {
if (controller != null && controller!!.status == PlayerStatus.PLAYING) {
controller!!.pause()
}
}
super.onPause()
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
Glide.get(this).trimMemory(level)
}
override fun onLowMemory() {
super.onLowMemory()
Glide.get(this).clearMemory()
}
@UnstableApi
private fun newPlaybackController(): PlaybackController {
return object : PlaybackController(this@VideoplayerActivity) {
override fun updatePlayButtonShowsPlay(showPlay: Boolean) {
viewBinding.playButton.setIsShowPlay(showPlay)
if (showPlay) {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setupVideoAspectRatio()
if (videoSurfaceCreated && controller != null) {
Log.d(TAG, "Videosurface already created, setting videosurface now")
controller!!.setVideoSurface(viewBinding.videoView.holder)
}
}
}
override fun loadMediaInfo() {
this@VideoplayerActivity.loadMediaInfo()
}
override fun onPlaybackEnd() {
finish()
}
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
@Suppress("unused")
fun bufferUpdate(event: BufferUpdateEvent) {
if (event.hasStarted()) {
viewBinding.progressBar.visibility = View.VISIBLE
} else if (event.hasEnded()) {
viewBinding.progressBar.visibility = View.INVISIBLE
} else {
viewBinding.sbPosition.secondaryProgress = (event.progress * viewBinding.sbPosition.max).toInt()
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
@Suppress("unused")
fun sleepTimerUpdate(event: SleepTimerUpdatedEvent) {
if (event.isCancelled || event.wasJustEnabled()) {
supportInvalidateOptionsMenu()
}
}
@UnstableApi
private fun loadMediaInfo() {
Log.d(TAG, "loadMediaInfo()")
if (controller?.getMedia() == null) {
return
}
if (controller!!.status == PlayerStatus.PLAYING && !controller!!.isPlayingVideoLocally) {
Log.d(TAG, "Closing, no longer video")
destroyingDueToReload = true
finish()
MainActivityStarter(this).withOpenPlayer().start()
return
}
showTimeLeft = shouldShowRemainingTime()
onPositionObserverUpdate()
checkFavorite()
val media = controller!!.getMedia()
if (media != null) {
supportActionBar!!.subtitle = media.getEpisodeTitle()
supportActionBar!!.title = media.getFeedTitle()
}
}
@UnstableApi
private fun setupView() {
showTimeLeft = shouldShowRemainingTime()
Log.d("timeleft", if (showTimeLeft) "true" else "false")
viewBinding.durationLabel.setOnClickListener { v: View? ->
showTimeLeft = !showTimeLeft
val media = controller?.getMedia() ?: return@setOnClickListener
val converter = TimeSpeedConverter(controller!!.currentPlaybackSpeedMultiplier)
val length: String
if (showTimeLeft) {
val remainingTime = converter.convert(media.getDuration() - media.getPosition())
length = "-" + getDurationStringLong(remainingTime)
} else {
val duration = converter.convert(media.getDuration())
length = getDurationStringLong(duration)
}
viewBinding.durationLabel.text = length
setShowRemainTimeSetting(showTimeLeft)
Log.d("timeleft on click", if (showTimeLeft) "true" else "false")
}
viewBinding.sbPosition.setOnSeekBarChangeListener(this)
viewBinding.rewindButton.setOnClickListener { v: View? -> onRewind() }
viewBinding.rewindButton.setOnLongClickListener { v: View? ->
SkipPreferenceDialog.showSkipPreference(this@VideoplayerActivity,
SkipPreferenceDialog.SkipDirection.SKIP_REWIND, null)
true
}
viewBinding.playButton.setIsVideoScreen(true)
viewBinding.playButton.setOnClickListener { v: View? -> onPlayPause() }
viewBinding.fastForwardButton.setOnClickListener { v: View? -> onFastForward() }
viewBinding.fastForwardButton.setOnLongClickListener { v: View? ->
SkipPreferenceDialog.showSkipPreference(this@VideoplayerActivity,
SkipPreferenceDialog.SkipDirection.SKIP_FORWARD, null)
false
}
// To suppress touches directly below the slider
viewBinding.bottomControlsContainer.setOnTouchListener { view: View?, motionEvent: MotionEvent? -> true }
viewBinding.bottomControlsContainer.fitsSystemWindows = true
viewBinding.videoView.holder.addCallback(surfaceHolderCallback)
viewBinding.videoView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
setupVideoControlsToggler()
window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN)
viewBinding.videoPlayerContainer.setOnTouchListener(onVideoviewTouched)
viewBinding.videoPlayerContainer.viewTreeObserver.addOnGlobalLayoutListener {
viewBinding.videoView.setAvailableSize(
viewBinding.videoPlayerContainer.width.toFloat(), viewBinding.videoPlayerContainer.height.toFloat())
}
}
private val hideVideoControls = Runnable {
if (videoControlsShowing) {
Log.d(TAG, "Hiding video controls")
supportActionBar?.hide()
hideVideoControls(true)
videoControlsShowing = false
}
}
private val onVideoviewTouched = OnTouchListener { v: View, event: MotionEvent ->
if (event.action != MotionEvent.ACTION_DOWN) {
return@OnTouchListener false
}
if (PictureInPictureUtil.isInPictureInPictureMode(this)) {
return@OnTouchListener true
}
videoControlsHider.removeCallbacks(hideVideoControls)
if (System.currentTimeMillis() - lastScreenTap < 300) {
if (event.x > v.measuredWidth / 2.0f) {
onFastForward()
showSkipAnimation(true)
} else {
onRewind()
showSkipAnimation(false)
}
if (videoControlsShowing) {
supportActionBar?.hide()
hideVideoControls(false)
videoControlsShowing = false
}
return@OnTouchListener true
}
toggleVideoControlsVisibility()
if (videoControlsShowing) {
setupVideoControlsToggler()
}
lastScreenTap = System.currentTimeMillis()
true
}
private fun showSkipAnimation(isForward: Boolean) {
val skipAnimation = AnimationSet(true)
skipAnimation.addAnimation(ScaleAnimation(1f, 2f, 1f, 2f,
Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f))
skipAnimation.addAnimation(AlphaAnimation(1f, 0f))
skipAnimation.fillAfter = false
skipAnimation.duration = 800
val params = viewBinding.skipAnimationImage.layoutParams as FrameLayout.LayoutParams
if (isForward) {
viewBinding.skipAnimationImage.setImageResource(R.drawable.ic_fast_forward_video_white)
params.gravity = Gravity.RIGHT or Gravity.CENTER_VERTICAL
} else {
viewBinding.skipAnimationImage.setImageResource(R.drawable.ic_fast_rewind_video_white)
params.gravity = Gravity.LEFT or Gravity.CENTER_VERTICAL
}
viewBinding.skipAnimationImage.visibility = View.VISIBLE
viewBinding.skipAnimationImage.layoutParams = params
viewBinding.skipAnimationImage.startAnimation(skipAnimation)
skipAnimation.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
}
override fun onAnimationEnd(animation: Animation) {
viewBinding.skipAnimationImage.visibility = View.GONE
}
override fun onAnimationRepeat(animation: Animation) {
}
})
}
private fun setupVideoControlsToggler() {
videoControlsHider.removeCallbacks(hideVideoControls)
videoControlsHider.postDelayed(hideVideoControls, 2500)
}
@UnstableApi
private fun setupVideoAspectRatio() {
if (videoSurfaceCreated && controller != null) {
val videoSize = controller!!.videoSize
if (videoSize != null && videoSize.first > 0 && videoSize.second > 0) {
Log.d(TAG, "Width,height of video: " + videoSize.first + ", " + videoSize.second)
viewBinding.videoView.setVideoSize(videoSize.first, videoSize.second)
} else {
Log.e(TAG, "Could not determine video size")
}
}
}
private fun toggleVideoControlsVisibility() {
if (videoControlsShowing) {
supportActionBar?.hide()
hideVideoControls(true)
} else {
supportActionBar?.show()
showVideoControls()
}
videoControlsShowing = !videoControlsShowing
}
@UnstableApi
fun onRewind() {
if (controller == null) {
return
}
val curr = controller!!.position
controller!!.seekTo(curr - rewindSecs * 1000)
setupVideoControlsToggler()
}
@UnstableApi
fun onPlayPause() {
if (controller == null) {
return
}
controller!!.playPause()
setupVideoControlsToggler()
}
@UnstableApi
fun onFastForward() {
if (controller == null) {
return
}
val curr = controller!!.position
controller!!.seekTo(curr + fastForwardSecs * 1000)
setupVideoControlsToggler()
}
private val surfaceHolderCallback: SurfaceHolder.Callback = object : SurfaceHolder.Callback {
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
holder.setFixedSize(width, height)
}
@UnstableApi
override fun surfaceCreated(holder: SurfaceHolder) {
Log.d(TAG, "Videoview holder created")
videoSurfaceCreated = true
if (controller?.status == PlayerStatus.PLAYING) {
controller!!.setVideoSurface(holder)
}
setupVideoAspectRatio()
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.d(TAG, "Videosurface was destroyed")
videoSurfaceCreated = false
if (controller != null && !destroyingDueToReload && !switchToAudioOnly) {
controller!!.notifyVideoSurfaceAbandoned()
}
}
}
private fun showVideoControls() {
viewBinding.bottomControlsContainer.visibility = View.VISIBLE
viewBinding.controlsContainer.visibility = View.VISIBLE
val animation = AnimationUtils.loadAnimation(this, R.anim.fade_in)
if (animation != null) {
viewBinding.bottomControlsContainer.startAnimation(animation)
viewBinding.controlsContainer.startAnimation(animation)
}
viewBinding.videoView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
}
private fun hideVideoControls(showAnimation: Boolean) {
if (showAnimation) {
val animation = AnimationUtils.loadAnimation(this, R.anim.fade_out)
if (animation != null) {
viewBinding.bottomControlsContainer.startAnimation(animation)
viewBinding.controlsContainer.startAnimation(animation)
}
}
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LOW_PROFILE
or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
viewBinding.bottomControlsContainer.fitsSystemWindows = true
viewBinding.bottomControlsContainer.visibility = View.GONE
viewBinding.controlsContainer.visibility = View.GONE
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEventMainThread(event: PlaybackPositionEvent?) {
onPositionObserverUpdate()
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onPlaybackServiceChanged(event: PlaybackServiceEvent) {
if (event.action == PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN) {
finish()
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onMediaPlayerError(event: PlayerErrorEvent) {
MediaPlayerErrorDialog.show(this, event)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEventMainThread(event: MessageEvent) {
Log.d(TAG, "onEvent($event)")
val errorDialog = MaterialAlertDialogBuilder(this)
errorDialog.setMessage(event.message)
errorDialog.setPositiveButton(event.actionText) { dialog: DialogInterface?, which: Int ->
event.action?.accept(this)
}
errorDialog.show()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
requestCastButton(menu)
val inflater = menuInflater
inflater.inflate(R.menu.mediaplayer, menu)
return true
}
@UnstableApi
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
super.onPrepareOptionsMenu(menu)
if (controller == null) {
return false
}
val media = controller!!.getMedia()
val isFeedMedia = (media is FeedMedia)
menu.findItem(R.id.open_feed_item).setVisible(isFeedMedia) // FeedMedia implies it belongs to a Feed
val hasWebsiteLink = getWebsiteLinkWithFallback(media) != null
menu.findItem(R.id.visit_website_item).setVisible(hasWebsiteLink)
val isItemAndHasLink = isFeedMedia && hasLinkToShare((media as FeedMedia).getItem())
val isItemHasDownloadLink = isFeedMedia && (media as FeedMedia?)?.download_url != null
menu.findItem(R.id.share_item).setVisible(hasWebsiteLink || isItemAndHasLink || isItemHasDownloadLink)
menu.findItem(R.id.add_to_favorites_item).setVisible(false)
menu.findItem(R.id.remove_from_favorites_item).setVisible(false)
if (isFeedMedia) {
menu.findItem(R.id.add_to_favorites_item).setVisible(!isFavorite)
menu.findItem(R.id.remove_from_favorites_item).setVisible(isFavorite)
}
menu.findItem(R.id.set_sleeptimer_item).setVisible(!controller!!.sleepTimerActive())
menu.findItem(R.id.disable_sleeptimer_item).setVisible(controller!!.sleepTimerActive())
menu.findItem(R.id.player_switch_to_audio_only).setVisible(true)
menu.findItem(R.id.audio_controls).setVisible(controller!!.audioTracks.size >= 2)
menu.findItem(R.id.playback_speed).setVisible(true)
menu.findItem(R.id.player_show_chapters).setVisible(true)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
// some options option requires FeedItem
when {
item.itemId == R.id.player_switch_to_audio_only -> {
switchToAudioOnly = true
finish()
return true
}
item.itemId == android.R.id.home -> {
val intent = Intent(this@VideoplayerActivity, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
return true
}
item.itemId == R.id.player_show_chapters -> {
ChaptersFragment().show(supportFragmentManager, ChaptersFragment.TAG)
return true
}
controller == null -> {
return false
}
else -> {
val media = controller?.getMedia() ?: return false
val feedItem = getFeedItem(media) // some options option requires FeedItem
if (item.itemId == R.id.add_to_favorites_item && feedItem != null) {
DBWriter.addFavoriteItem(feedItem)
isFavorite = true
invalidateOptionsMenu()
} else if (item.itemId == R.id.remove_from_favorites_item && feedItem != null) {
DBWriter.removeFavoriteItem(feedItem)
isFavorite = false
invalidateOptionsMenu()
} else if (item.itemId == R.id.disable_sleeptimer_item
|| item.itemId == R.id.set_sleeptimer_item) {
SleepTimerDialog().show(supportFragmentManager, "SleepTimerDialog")
} else if (item.itemId == R.id.audio_controls) {
val dialog = PlaybackControlsDialog.newInstance()
dialog.show(supportFragmentManager, "playback_controls")
} else if (item.itemId == R.id.open_feed_item && feedItem != null) {
val intent = MainActivity.getIntentToOpenFeed(this, feedItem.feedId)
startActivity(intent)
} else if (item.itemId == R.id.visit_website_item) {
val url = getWebsiteLinkWithFallback(media)
if (url != null) openInBrowser(this@VideoplayerActivity, url)
} else if (item.itemId == R.id.share_item && feedItem != null) {
val shareDialog = ShareDialog.newInstance(feedItem)
shareDialog.show(supportFragmentManager, "ShareEpisodeDialog")
} else if (item.itemId == R.id.playback_speed) {
VariableSpeedDialog().show(supportFragmentManager, null)
} else {
return false
}
return true
}
}
}
fun onPositionObserverUpdate() {
if (controller == null) {
return
}
val converter = TimeSpeedConverter(controller!!.currentPlaybackSpeedMultiplier)
val currentPosition = converter.convert(controller!!.position)
val duration = converter.convert(controller!!.duration)
val remainingTime = converter.convert(
controller!!.duration - controller!!.position)
// Log.d(TAG, "currentPosition " + Converter.getDurationStringLong(currentPosition));
if (currentPosition == Playable.INVALID_TIME
|| duration == Playable.INVALID_TIME) {
Log.w(TAG, "Could not react to position observer update because of invalid time")
return
}
viewBinding.positionLabel.text = getDurationStringLong(currentPosition)
if (showTimeLeft) {
viewBinding.durationLabel.text = "-" + getDurationStringLong(remainingTime)
} else {
viewBinding.durationLabel.text = getDurationStringLong(duration)
}
updateProgressbarPosition(currentPosition, duration)
}
private fun updateProgressbarPosition(position: Int, duration: Int) {
Log.d(TAG, "updateProgressbarPosition($position, $duration)")
val progress = (position.toFloat()) / duration
viewBinding.sbPosition.progress = (progress * viewBinding.sbPosition.max).toInt()
}
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (controller == null) {
return
}
if (fromUser) {
prog = progress / (seekBar.max.toFloat())
val converter = TimeSpeedConverter(controller!!.currentPlaybackSpeedMultiplier)
val position = converter.convert((prog * controller!!.duration).toInt())
viewBinding.seekPositionLabel.text = getDurationStringLong(position)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar) {
viewBinding.seekCardView.scaleX = .8f
viewBinding.seekCardView.scaleY = .8f
viewBinding.seekCardView.animate()
.setInterpolator(FastOutSlowInInterpolator())
.alpha(1f).scaleX(1f).scaleY(1f)
.setDuration(200)
.start()
videoControlsHider.removeCallbacks(hideVideoControls)
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
if (controller != null) {
controller!!.seekTo((prog * controller!!.duration).toInt())
}
viewBinding.seekCardView.scaleX = 1f
viewBinding.seekCardView.scaleY = 1f
viewBinding.seekCardView.animate()
.setInterpolator(FastOutSlowInInterpolator())
.alpha(0f).scaleX(.8f).scaleY(.8f)
.setDuration(200)
.start()
setupVideoControlsToggler()
}
private fun checkFavorite() {
val feedItem = getFeedItem(controller?.getMedia()) ?: return
disposable?.dispose()
disposable = Observable.fromCallable { DBReader.getFeedItem(feedItem.id) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ item: FeedItem? ->
if (item != null) {
val isFav = item.isTagged(FeedItem.TAG_FAVORITE)
if (isFavorite != isFav) {
isFavorite = isFav
invalidateOptionsMenu()
}
}
}, { error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) })
}
private fun compatEnterPictureInPicture() {
if (PictureInPictureUtil.supportsPictureInPicture(this) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
supportActionBar?.hide()
hideVideoControls(false)
enterPictureInPictureMode()
}
}
//Hardware keyboard support
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
val currentFocus = currentFocus
if (currentFocus is EditText) {
return super.onKeyUp(keyCode, event)
}
val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
when (keyCode) {
KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE -> {
onPlayPause()
toggleVideoControlsVisibility()
return true
}
KeyEvent.KEYCODE_J, KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_COMMA -> {
onRewind()
showSkipAnimation(false)
return true
}
KeyEvent.KEYCODE_K, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_PERIOD -> {
onFastForward()
showSkipAnimation(true)
return true
}
KeyEvent.KEYCODE_F, KeyEvent.KEYCODE_ESCAPE -> {
//Exit fullscreen mode
onBackPressed()
return true
}
KeyEvent.KEYCODE_I -> {
compatEnterPictureInPicture()
return true
}
KeyEvent.KEYCODE_PLUS, KeyEvent.KEYCODE_W -> {
audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC,
AudioManager.ADJUST_RAISE, AudioManager.FLAG_SHOW_UI)
return true
}
KeyEvent.KEYCODE_MINUS, KeyEvent.KEYCODE_S -> {
audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC,
AudioManager.ADJUST_LOWER, AudioManager.FLAG_SHOW_UI)
return true
}
KeyEvent.KEYCODE_M -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC,
AudioManager.ADJUST_TOGGLE_MUTE, AudioManager.FLAG_SHOW_UI)
return true
}
}
//Go to x% of video:
if (keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9) {
controller?.seekTo((0.1f * (keyCode - KeyEvent.KEYCODE_0) * controller!!.duration).toInt())
return true
}
return super.onKeyUp(keyCode, event)
}
companion object {
private const val TAG = "VideoplayerActivity"
private fun getWebsiteLinkWithFallback(media: Playable?): String? {
if (media == null) {
return null
} else if (!media.getWebsiteLink().isNullOrBlank()) {
return media.getWebsiteLink()
} else if (media is FeedMedia) {
return getLinkWithFallback(media.getItem())
}
return null
}
private fun getFeedItem(playable: Playable?): FeedItem? {
return if (playable is FeedMedia) {
playable.getItem()
} else {
null
}
}
}
}

View File

@ -1,141 +0,0 @@
package ac.mdiq.podcini.activity
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.CheckBox
import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.preferences.ThemeSwitcher.getTheme
import ac.mdiq.podcini.core.receiver.PlayerWidget
import ac.mdiq.podcini.core.widget.WidgetUpdaterWorker
class WidgetConfigActivity : AppCompatActivity() {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
private lateinit var widgetPreview: View
private lateinit var opacitySeekBar: SeekBar
private lateinit var opacityTextView: TextView
private lateinit var ckPlaybackSpeed: CheckBox
private lateinit var ckRewind: CheckBox
private lateinit var ckFastForward: CheckBox
private lateinit var ckSkip: CheckBox
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(getTheme(this))
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_widget_config)
val configIntent = intent
val extras = configIntent.extras
if (extras != null) {
appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID)
}
val resultValue = Intent()
resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
setResult(RESULT_CANCELED, resultValue)
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
}
opacityTextView = findViewById(R.id.widget_opacity_textView)
opacitySeekBar = findViewById(R.id.widget_opacity_seekBar)
widgetPreview = findViewById(R.id.widgetLayout)
findViewById<View>(R.id.butConfirm).setOnClickListener { v: View? -> confirmCreateWidget() }
opacitySeekBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, i: Int, b: Boolean) {
opacityTextView.text = seekBar.progress.toString() + "%"
val color = getColorWithAlpha(PlayerWidget.DEFAULT_COLOR, opacitySeekBar.progress)
widgetPreview.setBackgroundColor(color)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
}
})
widgetPreview.findViewById<View>(R.id.txtNoPlaying).visibility = View.GONE
val title = widgetPreview.findViewById<TextView>(R.id.txtvTitle)
title.visibility = View.VISIBLE
title.setText(R.string.app_name)
val progress = widgetPreview.findViewById<TextView>(R.id.txtvProgress)
progress.visibility = View.VISIBLE
progress.setText(R.string.position_default_label)
ckPlaybackSpeed = findViewById(R.id.ckPlaybackSpeed)
ckPlaybackSpeed.setOnClickListener { v: View? -> displayPreviewPanel() }
ckRewind = findViewById(R.id.ckRewind)
ckRewind.setOnClickListener { v: View? -> displayPreviewPanel() }
ckFastForward = findViewById(R.id.ckFastForward)
ckFastForward.setOnClickListener { v: View? -> displayPreviewPanel() }
ckSkip = findViewById(R.id.ckSkip)
ckSkip.setOnClickListener { v: View? -> displayPreviewPanel() }
setInitialState()
}
private fun setInitialState() {
val prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, MODE_PRIVATE)
ckPlaybackSpeed.isChecked = prefs.getBoolean(PlayerWidget.KEY_WIDGET_PLAYBACK_SPEED + appWidgetId, false)
ckRewind.isChecked = prefs.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + appWidgetId, false)
ckFastForward.isChecked = prefs.getBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + appWidgetId, false)
ckSkip.isChecked = prefs.getBoolean(PlayerWidget.KEY_WIDGET_SKIP + appWidgetId, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val color = prefs.getInt(PlayerWidget.KEY_WIDGET_COLOR + appWidgetId, PlayerWidget.DEFAULT_COLOR)
val opacity = Color.alpha(color) * 100 / 0xFF
opacitySeekBar.setProgress(opacity, false)
}
displayPreviewPanel()
}
private fun displayPreviewPanel() {
val showExtendedPreview =
ckPlaybackSpeed.isChecked || ckRewind.isChecked || ckFastForward.isChecked || ckSkip.isChecked
widgetPreview.findViewById<View>(R.id.extendedButtonsContainer).visibility =
if (showExtendedPreview) View.VISIBLE else View.GONE
widgetPreview.findViewById<View>(R.id.butPlay).visibility =
if (showExtendedPreview) View.GONE else View.VISIBLE
widgetPreview.findViewById<View>(R.id.butPlaybackSpeed).visibility =
if (ckPlaybackSpeed.isChecked) View.VISIBLE else View.GONE
widgetPreview.findViewById<View>(R.id.butFastForward).visibility =
if (ckFastForward.isChecked) View.VISIBLE else View.GONE
widgetPreview.findViewById<View>(R.id.butSkip).visibility =
if (ckSkip.isChecked) View.VISIBLE else View.GONE
widgetPreview.findViewById<View>(R.id.butRew).visibility =
if (ckRewind.isChecked) View.VISIBLE else View.GONE
}
private fun confirmCreateWidget() {
val backgroundColor = getColorWithAlpha(PlayerWidget.DEFAULT_COLOR, opacitySeekBar.progress)
val prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, MODE_PRIVATE)
val editor = prefs.edit()
editor.putInt(PlayerWidget.KEY_WIDGET_COLOR + appWidgetId, backgroundColor)
editor.putBoolean(PlayerWidget.KEY_WIDGET_PLAYBACK_SPEED + appWidgetId, ckPlaybackSpeed.isChecked)
editor.putBoolean(PlayerWidget.KEY_WIDGET_SKIP + appWidgetId, ckSkip.isChecked)
editor.putBoolean(PlayerWidget.KEY_WIDGET_REWIND + appWidgetId, ckRewind.isChecked)
editor.putBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + appWidgetId, ckFastForward.isChecked)
editor.apply()
val resultValue = Intent()
resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
setResult(RESULT_OK, resultValue)
finish()
WidgetUpdaterWorker.enqueueWork(this)
}
private fun getColorWithAlpha(color: Int, opacity: Int): Int {
return Math.round(0xFF * (0.01 * opacity)).toInt() * 0x1000000 + (color and 0xffffff)
}
}

View File

@ -1,146 +0,0 @@
package ac.mdiq.podcini.adapter
import android.content.Context
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.FitCenter
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestOptions
import com.google.android.material.elevation.SurfaceColors
import ac.mdiq.podcini.R
import ac.mdiq.podcini.adapter.ChaptersListAdapter.ChapterHolder
import ac.mdiq.podcini.core.util.Converter.getDurationStringLocalized
import ac.mdiq.podcini.core.util.Converter.getDurationStringLong
import ac.mdiq.podcini.core.util.IntentUtils.openInBrowser
import ac.mdiq.podcini.model.feed.Chapter
import ac.mdiq.podcini.model.feed.EmbeddedChapterImage
import ac.mdiq.podcini.model.playback.Playable
import ac.mdiq.podcini.ui.common.CircularProgressBar
import kotlin.math.max
import kotlin.math.min
class ChaptersListAdapter(private val context: Context, private val callback: Callback?) : RecyclerView.Adapter<ChapterHolder>() {
private var media: Playable? = null
private var currentChapterIndex = -1
private var currentChapterPosition: Long = -1
private var hasImages = false
fun setMedia(media: Playable) {
this.media = media
hasImages = false
for (chapter in media.getChapters()) {
if (!TextUtils.isEmpty(chapter.imageUrl)) {
hasImages = true
}
}
notifyDataSetChanged()
}
override fun onBindViewHolder(holder: ChapterHolder, position: Int) {
val sc = getItem(position)?: return
holder.title.text = sc.title
holder.start.text = getDurationStringLong(sc.start.toInt())
val duration = if (position + 1 < itemCount) {
media!!.getChapters()[position + 1].start - sc.start
} else {
(media?.getDuration()?:0) - sc.start
}
holder.duration.text = context.getString(R.string.chapter_duration,
getDurationStringLocalized(context, duration.toInt().toLong()))
if (TextUtils.isEmpty(sc.link)) {
holder.link.visibility = View.GONE
} else {
holder.link.visibility = View.VISIBLE
holder.link.text = sc.link
holder.link.setOnClickListener { v: View? ->
if (sc.link!=null) openInBrowser(context, sc.link!!)
}
}
holder.secondaryActionIcon.setImageResource(R.drawable.ic_play_48dp)
holder.secondaryActionButton.contentDescription = context.getString(R.string.play_chapter)
holder.secondaryActionButton.setOnClickListener { v: View? ->
callback?.onPlayChapterButtonClicked(position)
}
if (position == currentChapterIndex) {
val density = context.resources.displayMetrics.density
holder.itemView.setBackgroundColor(SurfaceColors.getColorForElevation(context, 32 * density))
var progress = ((currentChapterPosition - sc.start).toFloat()) / duration
progress = max(progress.toDouble(), CircularProgressBar.MINIMUM_PERCENTAGE.toDouble()).toFloat()
progress = min(progress.toDouble(), CircularProgressBar.MAXIMUM_PERCENTAGE.toDouble()).toFloat()
holder.progressBar.setPercentage(progress, position)
holder.secondaryActionIcon.setImageResource(R.drawable.ic_replay)
} else {
holder.itemView.setBackgroundColor(ContextCompat.getColor(context, android.R.color.transparent))
holder.progressBar.setPercentage(0f, null)
}
if (hasImages) {
holder.image.visibility = View.VISIBLE
if (TextUtils.isEmpty(sc.imageUrl)) {
Glide.with(context).clear(holder.image)
} else {
if (media != null) Glide.with(context)
.load(EmbeddedChapterImage.getModelFor(media!!, position))
.apply(RequestOptions()
.dontAnimate()
.transform(FitCenter(), RoundedCorners((4 * context.resources.displayMetrics.density).toInt())))
.into(holder.image)
}
} else {
holder.image.visibility = View.GONE
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChapterHolder {
val inflater = LayoutInflater.from(context)
return ChapterHolder(inflater.inflate(R.layout.simplechapter_item, parent, false))
}
override fun getItemCount(): Int {
return media?.getChapters()?.size?:0
}
class ChapterHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val title: TextView = itemView.findViewById(R.id.txtvTitle)
val start: TextView = itemView.findViewById(R.id.txtvStart)
val link: TextView = itemView.findViewById(R.id.txtvLink)
val duration: TextView = itemView.findViewById(R.id.txtvDuration)
val image: ImageView = itemView.findViewById(R.id.imgvCover)
val secondaryActionButton: View = itemView.findViewById(R.id.secondaryActionButton)
val secondaryActionIcon: ImageView = itemView.findViewById(R.id.secondaryActionIcon)
val progressBar: CircularProgressBar = itemView.findViewById(R.id.secondaryActionProgress)
}
fun notifyChapterChanged(newChapterIndex: Int) {
currentChapterIndex = newChapterIndex
currentChapterPosition = getItem(newChapterIndex)?.start?:0
notifyDataSetChanged()
}
fun notifyTimeChanged(timeMs: Long) {
currentChapterPosition = timeMs
// Passing an argument prevents flickering.
// See EpisodeItemListAdapter.notifyItemChangedCompat.
notifyItemChanged(currentChapterIndex, "foo")
}
fun getItem(position: Int): Chapter? {
val chapters = media?.getChapters()?: return null
if (position < 0 || position >= chapters.size) return null
return chapters[position]
}
interface Callback {
fun onPlayChapterButtonClicked(position: Int)
}
}

View File

@ -1,122 +0,0 @@
package ac.mdiq.podcini.adapter
import ac.mdiq.podcini.activity.MainActivity
import android.graphics.drawable.Drawable
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.CustomViewTarget
import com.bumptech.glide.request.transition.Transition
import java.lang.ref.WeakReference
class CoverLoader(activity: MainActivity) {
private var resource = 0
private var uri: String? = null
private var fallbackUri: String? = null
private var imgvCover: ImageView? = null
private var textAndImageCombined = false
private var fallbackTitle: TextView? = null
fun withUri(uri: String?): CoverLoader {
this.uri = uri
return this
}
fun withResource(resource: Int): CoverLoader {
this.resource = resource
return this
}
fun withFallbackUri(uri: String?): CoverLoader {
fallbackUri = uri
return this
}
fun withCoverView(coverView: ImageView): CoverLoader {
imgvCover = coverView
return this
}
fun withPlaceholderView(title: TextView): CoverLoader {
this.fallbackTitle = title
return this
}
/**
* Set cover text and if it should be shown even if there is a cover image.
* @param fallbackTitle Fallback title text
* @param textAndImageCombined Show cover text even if there is a cover image?
*/
fun withPlaceholderView(fallbackTitle: TextView?, textAndImageCombined: Boolean): CoverLoader {
this.fallbackTitle = fallbackTitle
this.textAndImageCombined = textAndImageCombined
return this
}
fun load() {
if (imgvCover == null) return
val coverTarget = CoverTarget(fallbackTitle, imgvCover!!, textAndImageCombined)
if (resource != 0) {
Glide.with(imgvCover!!).clear(coverTarget)
imgvCover!!.setImageResource(resource)
CoverTarget.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 != null) {
builder = builder.error(Glide.with(imgvCover!!)
.`as`(Drawable::class.java)
.load(fallbackUri)
.apply(options))
}
builder.into<CoverTarget>(coverTarget)
}
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
}
}
}
}

View File

@ -1,112 +0,0 @@
package ac.mdiq.podcini.adapter
import android.content.Context
import android.text.format.Formatter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.RadioButton
import android.widget.TextView
import androidx.core.util.Consumer
import androidx.recyclerview.widget.RecyclerView
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.util.StorageUtils.getFreeSpaceAvailable
import ac.mdiq.podcini.core.util.StorageUtils.getTotalSpaceAvailable
import ac.mdiq.podcini.storage.preferences.UserPreferences.getDataFolder
import java.io.File
class DataFolderAdapter(context: Context, selectionHandler: Consumer<String>) : RecyclerView.Adapter<DataFolderAdapter.ViewHolder?>() {
private val selectionHandler: Consumer<String>
private val currentPath: String?
private val entries: List<StoragePath>
private val freeSpaceString: String
init {
this.entries = getStorageEntries(context)
this.currentPath = getCurrentPath()
this.selectionHandler = selectionHandler
this.freeSpaceString = context.getString(R.string.choose_data_directory_available_space)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context)
val entryView = inflater.inflate(R.layout.choose_data_folder_dialog_entry, parent, false)
return ViewHolder(entryView)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val storagePath = entries[position]
val context = holder.root.context
val freeSpace = Formatter.formatShortFileSize(context, storagePath.availableSpace)
val totalSpace = Formatter.formatShortFileSize(context, storagePath.totalSpace)
holder.path.text = storagePath.shortPath
holder.size.text = String.format(freeSpaceString, freeSpace, totalSpace)
holder.progressBar.progress = storagePath.usagePercentage
val selectListener = View.OnClickListener { v: View? ->
selectionHandler.accept(
storagePath.fullPath)
}
holder.root.setOnClickListener(selectListener)
holder.radioButton.setOnClickListener(selectListener)
if (storagePath.fullPath == currentPath) {
holder.radioButton.toggle()
}
}
override fun getItemCount(): Int {
return entries.size
}
private fun getCurrentPath(): String? {
val dataFolder = getDataFolder(null)
return dataFolder?.absolutePath
}
private fun getStorageEntries(context: Context): List<StoragePath> {
val mediaDirs = context.getExternalFilesDirs(null)
val entries: MutableList<StoragePath> = ArrayList(mediaDirs.size)
for (dir in mediaDirs) {
if (!isWritable(dir)) {
continue
}
entries.add(StoragePath(dir.absolutePath))
}
if (entries.isEmpty() && isWritable(context.filesDir)) {
entries.add(StoragePath(context.filesDir.absolutePath))
}
return entries
}
private fun isWritable(dir: File?): Boolean {
return dir != null && dir.exists() && dir.canRead() && dir.canWrite()
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val root: View = itemView.findViewById(R.id.root)
val path: TextView = itemView.findViewById(R.id.path)
val size: TextView = itemView.findViewById(R.id.size)
val radioButton: RadioButton = itemView.findViewById(R.id.radio_button)
val progressBar: ProgressBar = itemView.findViewById(R.id.used_space)
}
internal class StoragePath(val fullPath: String) {
val shortPath: String
get() {
val prefixIndex = fullPath.indexOf("Android")
return if ((prefixIndex > 0)) fullPath.substring(0, prefixIndex) else fullPath
}
val availableSpace: Long
get() = getFreeSpaceAvailable(fullPath)
val totalSpace: Long
get() = getTotalSpaceAvailable(fullPath)
val usagePercentage: Int
get() = 100 - (100 * availableSpace / totalSpace.toFloat()).toInt()
}
}

View File

@ -1,149 +0,0 @@
package ac.mdiq.podcini.adapter
import ac.mdiq.podcini.activity.MainActivity
import android.app.Activity
import android.text.format.DateUtils
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.Toast
import ac.mdiq.podcini.R
import ac.mdiq.podcini.adapter.actionbutton.DownloadActionButton
import ac.mdiq.podcini.core.storage.DBReader
import ac.mdiq.podcini.core.util.DownloadErrorLabel
import ac.mdiq.podcini.core.util.download.FeedUpdateManager
import ac.mdiq.podcini.model.download.DownloadError
import ac.mdiq.podcini.model.download.DownloadResult
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.model.feed.FeedMedia
import ac.mdiq.podcini.ui.common.ThemeUtils
import ac.mdiq.podcini.view.viewholder.DownloadLogItemViewHolder
import androidx.media3.common.util.UnstableApi
/**
* Displays a list of DownloadStatus entries.
*/
class DownloadLogAdapter(private val context: Activity) : BaseAdapter() {
private var downloadLog: List<DownloadResult> = ArrayList()
fun setDownloadLog(downloadLog: List<DownloadResult>) {
this.downloadLog = downloadLog
notifyDataSetChanged()
}
@UnstableApi override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val holder: DownloadLogItemViewHolder
if (convertView == null) {
holder = DownloadLogItemViewHolder(context, parent)
holder.itemView.tag = holder
} else {
holder = convertView.tag as DownloadLogItemViewHolder
}
val item = getItem(position)
if (item != null) bind(holder, item, position)
return holder.itemView
}
@UnstableApi private fun bind(holder: DownloadLogItemViewHolder, status: DownloadResult, position: Int) {
var statusText: String? = ""
if (status.feedfileType == Feed.FEEDFILETYPE_FEED) {
statusText += context.getString(R.string.download_type_feed)
} else if (status.feedfileType == FeedMedia.FEEDFILETYPE_FEEDMEDIA) {
statusText += context.getString(R.string.download_type_media)
}
statusText += " · "
statusText += DateUtils.getRelativeTimeSpanString(status.getCompletionDate().time,
System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, 0)
holder.status.text = statusText
if (status.title.isNotEmpty()) {
holder.title.text = status.title
} else {
holder.title.setText(R.string.download_log_title_unknown)
}
if (status.isSuccessful) {
holder.icon.setTextColor(ThemeUtils.getColorFromAttr(context, R.attr.icon_green))
holder.icon.text = "{fa-check-circle}"
holder.icon.setContentDescription(context.getString(R.string.download_successful))
holder.secondaryActionButton.visibility = View.INVISIBLE
holder.reason.visibility = View.GONE
holder.tapForDetails.visibility = View.GONE
} else {
if (status.reason == DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE) {
holder.icon.setTextColor(ThemeUtils.getColorFromAttr(context, R.attr.icon_yellow))
holder.icon.text = "{fa-exclamation-circle}"
} else {
holder.icon.setTextColor(ThemeUtils.getColorFromAttr(context, R.attr.icon_red))
holder.icon.text = "{fa-times-circle}"
}
holder.icon.setContentDescription(context.getString(R.string.error_label))
holder.reason.setText(DownloadErrorLabel.from(status.reason))
holder.reason.visibility = View.VISIBLE
holder.tapForDetails.visibility = View.VISIBLE
if (newerWasSuccessful(position, status.feedfileType, status.feedfileId)) {
holder.secondaryActionButton.visibility = View.INVISIBLE
holder.secondaryActionButton.setOnClickListener(null)
holder.secondaryActionButton.tag = null
} else {
holder.secondaryActionIcon.setImageResource(R.drawable.ic_refresh)
holder.secondaryActionButton.visibility = View.VISIBLE
if (status.feedfileType == Feed.FEEDFILETYPE_FEED) {
holder.secondaryActionButton.setOnClickListener(View.OnClickListener setOnClickListener@{ v: View? ->
holder.secondaryActionButton.visibility = View.INVISIBLE
val feed: Feed? = DBReader.getFeed(status.feedfileId)
if (feed == null) {
Log.e(TAG, "Could not find feed for feed id: " + status.feedfileId)
return@setOnClickListener
}
FeedUpdateManager.runOnce(context, feed)
})
} else if (status.feedfileType == FeedMedia.FEEDFILETYPE_FEEDMEDIA) {
holder.secondaryActionButton.setOnClickListener(View.OnClickListener { v: View? ->
holder.secondaryActionButton.visibility = View.INVISIBLE
val media: FeedMedia? = DBReader.getFeedMedia(status.feedfileId)
if (media == null) {
Log.e(TAG, "Could not find feed media for feed id: " + status.feedfileId)
return@OnClickListener
}
if (media.getItem() != null) DownloadActionButton(media.getItem()!!).onClick(context)
(context as MainActivity).showSnackbarAbovePlayer(
R.string.status_downloading_label, Toast.LENGTH_SHORT)
})
}
}
}
}
private fun newerWasSuccessful(downloadStatusIndex: Int, feedTypeId: Int, id: Long): Boolean {
for (i in 0 until downloadStatusIndex) {
val status: DownloadResult = downloadLog[i]
if (status.feedfileType == feedTypeId && status.feedfileId == id && status.isSuccessful) {
return true
}
}
return false
}
override fun getCount(): Int {
return downloadLog.size
}
override fun getItem(position: Int): DownloadResult? {
if (position in downloadLog.indices) {
return downloadLog[position]
}
return null
}
override fun getItemId(position: Int): Long {
return position.toLong()
}
companion object {
private const val TAG = "DownloadLogAdapter"
}
}

View File

@ -1,213 +0,0 @@
package ac.mdiq.podcini.adapter
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.MainActivity
import ac.mdiq.podcini.core.util.FeedItemUtil
import ac.mdiq.podcini.fragment.ItemPagerFragment
import ac.mdiq.podcini.menuhandler.FeedItemMenuHandler
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.ui.common.ThemeUtils
import ac.mdiq.podcini.view.viewholder.EpisodeItemViewHolder
import android.R.color
import android.app.Activity
import android.os.Build
import android.view.*
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.RecyclerView
import org.apache.commons.lang3.ArrayUtils
import java.lang.ref.WeakReference
/**
* List adapter for the list of new episodes.
*/
open class EpisodeItemListAdapter(mainActivity: MainActivity) : SelectableAdapter<EpisodeItemViewHolder?>(mainActivity),
View.OnCreateContextMenuListener {
private val mainActivityRef: WeakReference<MainActivity> = WeakReference<MainActivity>(mainActivity)
private var episodes: List<FeedItem> = ArrayList()
var longPressedItem: FeedItem? = null
var longPressedPosition: Int = 0 // used to init actionMode
private var dummyViews = 0
init {
setHasStableIds(true)
}
fun setDummyViews(dummyViews: Int) {
this.dummyViews = dummyViews
notifyDataSetChanged()
}
fun updateItems(items: List<FeedItem>) {
episodes = items
notifyDataSetChanged()
updateTitle()
}
override fun getItemViewType(position: Int): Int {
return R.id.view_type_episode_item
}
@UnstableApi override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeItemViewHolder {
return EpisodeItemViewHolder(mainActivityRef.get()!!, parent)
}
@UnstableApi override fun onBindViewHolder(holder: EpisodeItemViewHolder, pos: Int) {
if (pos >= episodes.size || pos < 0) {
beforeBindViewHolder(holder, pos)
holder.bindDummy()
afterBindViewHolder(holder, pos)
holder.hideSeparatorIfNecessary()
return
}
// Reset state of recycled views
holder.coverHolder.visibility = View.VISIBLE
holder.dragHandle.setVisibility(View.GONE)
beforeBindViewHolder(holder, pos)
val item: FeedItem = episodes[pos]
holder.bind(item)
holder.itemView.setOnClickListener { v: View? ->
val activity: MainActivity? = mainActivityRef.get()
if (!inActionMode()) {
val ids: LongArray = FeedItemUtil.getIds(episodes)
val position = ArrayUtils.indexOf(ids, item.id)
activity?.loadChildFragment(ItemPagerFragment.newInstance(ids, position))
} else {
toggleSelection(holder.bindingAdapterPosition)
}
}
holder.itemView.setOnCreateContextMenuListener(this)
holder.itemView.setOnLongClickListener { v: View? ->
longPressedItem = item
longPressedPosition = holder.bindingAdapterPosition
false
}
holder.itemView.setOnTouchListener(View.OnTouchListener { v: View?, e: MotionEvent ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (e.isFromSource(InputDevice.SOURCE_MOUSE) && e.buttonState == MotionEvent.BUTTON_SECONDARY) {
longPressedItem = item
longPressedPosition = holder.bindingAdapterPosition
return@OnTouchListener false
}
}
false
})
if (inActionMode()) {
holder.secondaryActionButton.setOnClickListener(null)
if (isSelected(pos)) {
holder.itemView.setBackgroundColor(-0x78000000
+ (0xffffff and ThemeUtils.getColorFromAttr(mainActivityRef.get()!!, R.attr.colorAccent)))
} else {
holder.itemView.setBackgroundResource(color.transparent)
}
}
afterBindViewHolder(holder, pos)
holder.hideSeparatorIfNecessary()
}
protected open fun beforeBindViewHolder(holder: EpisodeItemViewHolder, pos: Int) {
}
protected open fun afterBindViewHolder(holder: EpisodeItemViewHolder, pos: Int) {
}
@UnstableApi override fun onViewRecycled(holder: EpisodeItemViewHolder) {
super.onViewRecycled(holder)
// Set all listeners to null. This is required to prevent leaking fragments that have set a listener.
// Activity -> recycledViewPool -> EpisodeItemViewHolder -> Listener -> Fragment (can not be garbage collected)
holder.itemView.setOnClickListener(null)
holder.itemView.setOnCreateContextMenuListener(null)
holder.itemView.setOnLongClickListener(null)
holder.itemView.setOnTouchListener(null)
holder.secondaryActionButton.setOnClickListener(null)
holder.dragHandle.setOnTouchListener(null)
holder.coverHolder.setOnTouchListener(null)
}
/**
* [.notifyItemChanged] is final, so we can not override.
* Calling [.notifyItemChanged] may bind the item to a new ViewHolder and execute a transition.
* This causes flickering and breaks the download animation that stores the old progress in the View.
* Instead, we tell the adapter to use partial binding by calling [.notifyItemChanged].
* We actually ignore the payload and always do a full bind but calling the partial bind method ensures
* that ViewHolders are always re-used.
*
* @param position Position of the item that has changed
*/
fun notifyItemChangedCompat(position: Int) {
notifyItemChanged(position, "foo")
}
override fun getItemId(position: Int): Long {
// if (position >= episodes.size) {
// return RecyclerView.NO_ID // Dummy views
// }
// val item = episodes[position]
// return item.id ?: RecyclerView.NO_POSITION.toLong()
return getItem(position)?.id ?: RecyclerView.NO_ID
}
override fun getItemCount(): Int {
return dummyViews + episodes.size
}
protected fun getItem(index: Int): FeedItem? {
// return episodes[index]
return if (index in episodes.indices) episodes[index] else null
}
protected val activity: Activity?
get() = mainActivityRef.get()
@UnstableApi override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {
val inflater: MenuInflater = activity!!.menuInflater
if (inActionMode()) {
inflater.inflate(R.menu.multi_select_context_popup, menu)
} else {
if (longPressedItem == null) {
return
}
inflater.inflate(R.menu.feeditemlist_context, menu)
menu.setHeaderTitle(longPressedItem!!.title)
FeedItemMenuHandler.onPrepareMenu(menu, longPressedItem, R.id.skip_episode_item)
}
}
fun onContextItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.multi_select -> {
startSelectMode(longPressedPosition)
return true
}
R.id.select_all_above -> {
setSelected(0, longPressedPosition, true)
return true
}
R.id.select_all_below -> {
shouldSelectLazyLoadedItems = true
setSelected(longPressedPosition + 1, itemCount, true)
return true
}
else -> return false
}
}
val selectedItems: List<Any>
get() {
val items: MutableList<FeedItem> = ArrayList()
for (i in 0 until itemCount) {
if (i < episodes.size && isSelected(i)) {
val item = getItem(i)
if (item != null) items.add(item)
}
}
return items
}
}

View File

@ -1,68 +0,0 @@
package ac.mdiq.podcini.adapter
import ac.mdiq.podcini.activity.MainActivity
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.FitCenter
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestOptions
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.discovery.PodcastSearchResult
import java.lang.ref.WeakReference
class FeedDiscoverAdapter(mainActivity: MainActivity) : BaseAdapter() {
private val mainActivityRef: WeakReference<MainActivity> = WeakReference<MainActivity>(mainActivity)
private val data: MutableList<PodcastSearchResult> = ArrayList()
fun updateData(newData: List<PodcastSearchResult>) {
data.clear()
data.addAll(newData)
notifyDataSetChanged()
}
override fun getCount(): Int {
return data.size
}
override fun getItem(position: Int): PodcastSearchResult? {
return if (position in data.indices) data[position] else null
}
override fun getItemId(position: Int): Long {
return 0
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
var convertView = convertView
val holder: Holder
if (convertView == null) {
convertView = View.inflate(mainActivityRef.get(), R.layout.quick_feed_discovery_item, null)
holder = Holder()
holder.imageView = convertView.findViewById(R.id.discovery_cover)
convertView.tag = holder
} else {
holder = convertView.tag as Holder
}
val podcast: PodcastSearchResult? = getItem(position)
holder.imageView!!.contentDescription = podcast?.title
Glide.with(mainActivityRef.get()!!)
.load(podcast?.imageUrl)
.apply(RequestOptions()
.placeholder(R.color.light_gray)
.transform(FitCenter(), RoundedCorners((8 * mainActivityRef.get()!!.resources.displayMetrics.density).toInt()))
.dontAnimate())
.into(holder.imageView!!)
return convertView!!
}
internal class Holder {
var imageView: ImageView? = null
}
}

View File

@ -1,104 +0,0 @@
package ac.mdiq.podcini.adapter
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.service.playback.PlaybackService.Companion.getPlayerActivityIntent
import ac.mdiq.podcini.core.util.DateFormatter.formatAbbrev
import ac.mdiq.podcini.core.util.NetworkUtils.isStreamingAllowed
import ac.mdiq.podcini.core.util.playback.PlaybackServiceStarter
import ac.mdiq.podcini.core.util.syndication.HtmlToPlainText
import ac.mdiq.podcini.dialog.StreamingConfirmationDialog
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.model.playback.MediaType
import ac.mdiq.podcini.model.playback.Playable
import ac.mdiq.podcini.model.playback.RemoteMedia
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.TextView
import androidx.media3.common.util.UnstableApi
/**
* List adapter for showing a list of FeedItems with their title and description.
*/
class FeedItemlistDescriptionAdapter(context: Context, resource: Int, objects: List<FeedItem?>?) :
ArrayAdapter<FeedItem?>(context, resource, objects!!) {
@UnstableApi override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
var convertView = convertView
val holder: Holder
val item = getItem(position)
// Inflate layout
if (convertView == null) {
holder = Holder()
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
convertView = inflater.inflate(R.layout.itemdescription_listitem, parent, false)
holder.title = convertView.findViewById(R.id.txtvTitle)
holder.pubDate = convertView.findViewById(R.id.txtvPubDate)
holder.description = convertView.findViewById(R.id.txtvDescription)
holder.preview = convertView.findViewById(R.id.butPreview)
convertView.tag = holder
} else {
holder = convertView.tag as Holder
}
holder.title!!.text = item!!.title
holder.pubDate!!.text = formatAbbrev(context, item.pubDate)
if (item.description != null) {
val description = HtmlToPlainText.getPlainText(item.description!!)
.replace("\n".toRegex(), " ")
.replace("\\s+".toRegex(), " ")
.trim { it <= ' ' }
holder.description!!.text = description
holder.description!!.maxLines = MAX_LINES_COLLAPSED
}
holder.description!!.tag = false
holder.preview!!.visibility = View.GONE
holder.preview!!.setOnClickListener { v: View? ->
if (item.media == null) {
return@setOnClickListener
}
val playable: Playable = RemoteMedia(item)
if (!isStreamingAllowed) {
StreamingConfirmationDialog(context, playable).show()
return@setOnClickListener
}
PlaybackServiceStarter(context, playable)
.callEvenIfRunning(true)
.start()
if (playable.getMediaType() == MediaType.VIDEO) {
context.startActivity(getPlayerActivityIntent(context, playable))
}
}
convertView!!.setOnClickListener { v: View? ->
if (holder.description!!.tag == true) {
holder.description!!.maxLines = MAX_LINES_COLLAPSED
holder.preview!!.visibility = View.GONE
holder.description!!.tag = false
} else {
holder.description!!.maxLines = 30
holder.description!!.tag = true
holder.preview!!.visibility = if (item.media != null) View.VISIBLE else View.GONE
holder.preview!!.setText(R.string.preview_episode)
}
}
return convertView
}
internal class Holder {
var title: TextView? = null
var pubDate: TextView? = null
var description: TextView? = null
var preview: Button? = null
}
companion object {
private const val MAX_LINES_COLLAPSED = 2
}
}

View File

@ -1,125 +0,0 @@
package ac.mdiq.podcini.adapter
import ac.mdiq.podcini.activity.MainActivity
import android.view.ContextMenu
import android.view.MenuInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.annotation.StringRes
import androidx.cardview.widget.CardView
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import ac.mdiq.podcini.R
import ac.mdiq.podcini.fragment.FeedItemlistFragment
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.ui.common.SquareImageView
import java.lang.ref.WeakReference
open class HorizontalFeedListAdapter(mainActivity: MainActivity) :
RecyclerView.Adapter<HorizontalFeedListAdapter.Holder>(), View.OnCreateContextMenuListener {
private val mainActivityRef: WeakReference<MainActivity> = WeakReference<MainActivity>(mainActivity)
private val data: MutableList<Feed> = ArrayList()
private var dummyViews = 0
var longPressedItem: Feed? = null
@StringRes
private var endButtonText = 0
private var endButtonAction: Runnable? = null
fun setDummyViews(dummyViews: Int) {
this.dummyViews = dummyViews
}
fun updateData(newData: List<Feed>?) {
data.clear()
data.addAll(newData!!)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val convertView = View.inflate(mainActivityRef.get(), R.layout.horizontal_feed_item, null)
return Holder(convertView)
}
@UnstableApi override fun onBindViewHolder(holder: Holder, position: Int) {
if (position == itemCount - 1 && endButtonAction != null) {
holder.cardView.visibility = View.GONE
holder.actionButton.visibility = View.VISIBLE
holder.actionButton.setText(endButtonText)
holder.actionButton.setOnClickListener { v: View? -> endButtonAction!!.run() }
return
}
holder.cardView.visibility = View.VISIBLE
holder.actionButton.visibility = View.GONE
if (position >= data.size) {
holder.itemView.alpha = 0.1f
Glide.with(mainActivityRef.get()!!).clear(holder.imageView)
holder.imageView.setImageResource(R.color.medium_gray)
return
}
holder.itemView.alpha = 1.0f
val podcast: Feed = data[position]
holder.imageView.setContentDescription(podcast.title)
holder.imageView.setOnClickListener { v: View? ->
mainActivityRef.get()?.loadChildFragment(FeedItemlistFragment.newInstance(podcast.id))
}
holder.imageView.setOnCreateContextMenuListener(this)
holder.imageView.setOnLongClickListener { v: View? ->
val currentItemPosition = holder.bindingAdapterPosition
longPressedItem = data[currentItemPosition]
false
}
Glide.with(mainActivityRef.get()!!)
.load(podcast.imageUrl)
.apply(RequestOptions()
.placeholder(R.color.light_gray)
.fitCenter()
.dontAnimate())
.into(holder.imageView)
}
override fun getItemId(position: Int): Long {
if (position >= data.size) {
return RecyclerView.NO_ID // Dummy views
}
return data[position].id
}
override fun getItemCount(): Int {
return dummyViews + data.size + (if ((endButtonAction == null)) 0 else 1)
}
override fun onCreateContextMenu(contextMenu: ContextMenu, view: View, contextMenuInfo: ContextMenu.ContextMenuInfo?) {
val inflater: MenuInflater = mainActivityRef.get()!!.menuInflater
if (longPressedItem == null) {
return
}
inflater.inflate(R.menu.nav_feed_context, contextMenu)
contextMenu.setHeaderTitle(longPressedItem!!.title)
}
fun setEndButton(@StringRes text: Int, action: Runnable?) {
endButtonAction = action
endButtonText = text
notifyDataSetChanged()
}
class Holder(itemView: View) : RecyclerView.ViewHolder(itemView) {
var imageView: SquareImageView = itemView.findViewById(R.id.discovery_cover)
var cardView: CardView
var actionButton: Button
init {
imageView.setDirection(SquareImageView.DIRECTION_HEIGHT)
actionButton = itemView.findViewById(R.id.actionButton)
cardView = itemView.findViewById(R.id.cardView)
}
}
}

View File

@ -1,120 +0,0 @@
package ac.mdiq.podcini.adapter
import ac.mdiq.podcini.activity.MainActivity
import android.view.ContextMenu
import android.view.MenuInflater
import android.view.View
import android.view.ViewGroup
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.RecyclerView
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.util.FeedItemUtil
import ac.mdiq.podcini.fragment.ItemPagerFragment
import ac.mdiq.podcini.menuhandler.FeedItemMenuHandler
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.view.viewholder.HorizontalItemViewHolder
import org.apache.commons.lang3.ArrayUtils
import java.lang.ref.WeakReference
open class HorizontalItemListAdapter(mainActivity: MainActivity) : RecyclerView.Adapter<HorizontalItemViewHolder?>(),
View.OnCreateContextMenuListener {
private val mainActivityRef: WeakReference<MainActivity> = WeakReference<MainActivity>(mainActivity)
private var data: List<FeedItem> = ArrayList()
var longPressedItem: FeedItem? = null
private var dummyViews = 0
init {
setHasStableIds(true)
}
fun setDummyViews(dummyViews: Int) {
this.dummyViews = dummyViews
}
fun updateData(newData: List<FeedItem>) {
data = newData
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HorizontalItemViewHolder {
return HorizontalItemViewHolder(mainActivityRef.get()!!, parent)
}
@UnstableApi override fun onBindViewHolder(holder: HorizontalItemViewHolder, position: Int) {
if (position >= data.size) {
holder.bindDummy()
return
}
val item: FeedItem = data[position]
holder.bind(item)
holder.card.setOnCreateContextMenuListener(this)
holder.card.setOnLongClickListener { v: View? ->
longPressedItem = item
false
}
holder.secondaryActionIcon.setOnCreateContextMenuListener(this)
holder.secondaryActionIcon.setOnLongClickListener { v: View? ->
longPressedItem = item
false
}
holder.card.setOnClickListener { v: View? ->
val activity: MainActivity? = mainActivityRef.get()
if (activity != null) {
val ids: LongArray = FeedItemUtil.getIds(data)
val clickPosition = ArrayUtils.indexOf(ids, item.id)
activity.loadChildFragment(ItemPagerFragment.newInstance(ids, clickPosition))
}
}
}
override fun getItemId(position: Int): Long {
if (position in data.indices) {
val item: FeedItem = data[position]
return item.id
}
return RecyclerView.NO_ID // Dummy views
}
override fun getItemCount(): Int {
return dummyViews + data.size
}
override fun onViewRecycled(holder: HorizontalItemViewHolder) {
super.onViewRecycled(holder)
// Set all listeners to null. This is required to prevent leaking fragments that have set a listener.
// Activity -> recycledViewPool -> ViewHolder -> Listener -> Fragment (can not be garbage collected)
holder.card.setOnClickListener(null)
holder.card.setOnCreateContextMenuListener(null)
holder.card.setOnLongClickListener(null)
holder.secondaryActionIcon.setOnClickListener(null)
holder.secondaryActionIcon.setOnCreateContextMenuListener(null)
holder.secondaryActionIcon.setOnLongClickListener(null)
}
/**
* [.notifyItemChanged] is final, so we can not override.
* Calling [.notifyItemChanged] may bind the item to a new ViewHolder and execute a transition.
* This causes flickering and breaks the download animation that stores the old progress in the View.
* Instead, we tell the adapter to use partial binding by calling [.notifyItemChanged].
* We actually ignore the payload and always do a full bind but calling the partial bind method ensures
* that ViewHolders are always re-used.
*
* @param position Position of the item that has changed
*/
fun notifyItemChangedCompat(position: Int) {
notifyItemChanged(position, "foo")
}
@UnstableApi override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {
val inflater: MenuInflater = mainActivityRef.get()!!.menuInflater
if (longPressedItem == null) return
menu.clear()
inflater.inflate(R.menu.feeditemlist_context, menu)
menu.setHeaderTitle(longPressedItem!!.title)
FeedItemMenuHandler.onPrepareMenu(menu, longPressedItem, R.id.skip_episode_item)
}
}

View File

@ -1,376 +0,0 @@
package ac.mdiq.podcini.adapter
import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.os.Build
import android.view.*
import android.view.ContextMenu.ContextMenuInfo
import android.view.View.OnCreateContextMenuListener
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.FitCenter
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestOptions
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.activity.PreferenceActivity
import ac.mdiq.podcini.core.storage.NavDrawerData.*
import ac.mdiq.podcini.fragment.*
import ac.mdiq.podcini.storage.preferences.UserPreferences
import ac.mdiq.podcini.storage.preferences.UserPreferences.episodeCacheSize
import ac.mdiq.podcini.storage.preferences.UserPreferences.hiddenDrawerItems
import ac.mdiq.podcini.storage.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.storage.preferences.UserPreferences.subscriptionsFilter
import ac.mdiq.podcini.ui.home.HomeFragment
import androidx.media3.common.util.UnstableApi
import org.apache.commons.lang3.ArrayUtils
import java.lang.ref.WeakReference
import java.text.NumberFormat
import java.util.*
import kotlin.math.abs
/**
* BaseAdapter for the navigation drawer
*/
class NavListAdapter(private val itemAccess: ItemAccess, context: Activity) :
RecyclerView.Adapter<NavListAdapter.Holder>(), OnSharedPreferenceChangeListener {
private val fragmentTags: MutableList<String?> = ArrayList()
private val titles: Array<String> = context.resources.getStringArray(R.array.nav_drawer_titles)
private val activity = WeakReference(context)
@JvmField
var showSubscriptionList: Boolean = true
init {
loadItems()
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.registerOnSharedPreferenceChangeListener(this)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
if (UserPreferences.PREF_HIDDEN_DRAWER_ITEMS == key) {
loadItems()
}
}
private fun loadItems() {
val newTags: MutableList<String?> = ArrayList(listOf(*NavDrawerFragment.NAV_DRAWER_TAGS))
val hiddenFragments = hiddenDrawerItems
newTags.removeAll(hiddenFragments!!)
if (newTags.contains(SUBSCRIPTION_LIST_TAG)) {
// we never want SUBSCRIPTION_LIST_TAG to be in 'tags'
// since it doesn't actually correspond to a position in the list, but is
// a placeholder that indicates if we should show the subscription list in the
// nav drawer at all.
showSubscriptionList = true
newTags.remove(SUBSCRIPTION_LIST_TAG)
} else {
showSubscriptionList = false
}
fragmentTags.clear()
fragmentTags.addAll(newTags)
notifyDataSetChanged()
}
fun getLabel(tag: String?): String {
val index = ArrayUtils.indexOf(NavDrawerFragment.NAV_DRAWER_TAGS, tag)
return titles[index]
}
@UnstableApi @DrawableRes
private fun getDrawable(tag: String?): Int {
return when (tag) {
HomeFragment.TAG -> R.drawable.ic_home
QueueFragment.TAG -> R.drawable.ic_playlist_play
InboxFragment.TAG -> R.drawable.ic_inbox
AllEpisodesFragment.TAG -> R.drawable.ic_feed
CompletedDownloadsFragment.TAG -> R.drawable.ic_download
PlaybackHistoryFragment.TAG -> R.drawable.ic_history
SubscriptionFragment.TAG -> R.drawable.ic_subscriptions
AddFeedFragment.TAG -> R.drawable.ic_add
else -> 0
}
}
fun getFragmentTags(): List<String?> {
return Collections.unmodifiableList(fragmentTags)
}
override fun getItemCount(): Int {
var baseCount = subscriptionOffset
if (showSubscriptionList) {
baseCount += itemAccess.count
}
return baseCount
}
override fun getItemId(position: Int): Long {
val viewType = getItemViewType(position)
return when (viewType) {
VIEW_TYPE_SUBSCRIPTION -> {
itemAccess.getItem(position - subscriptionOffset)?.id?:0
}
VIEW_TYPE_NAV -> {
(-abs(fragmentTags[position].hashCode().toLong().toDouble()) - 1).toLong() // Folder IDs are >0
}
else -> {
0
}
}
}
override fun getItemViewType(position: Int): Int {
return if (0 <= position && position < fragmentTags.size) {
VIEW_TYPE_NAV
} else if (position < subscriptionOffset) {
VIEW_TYPE_SECTION_DIVIDER
} else {
VIEW_TYPE_SUBSCRIPTION
}
}
val subscriptionOffset: Int
get() = if (fragmentTags.size > 0) fragmentTags.size + 1 else 0
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val inflater = LayoutInflater.from(activity.get())
return when (viewType) {
VIEW_TYPE_NAV -> {
NavHolder(inflater.inflate(R.layout.nav_listitem, parent, false))
}
VIEW_TYPE_SECTION_DIVIDER -> {
DividerHolder(inflater.inflate(R.layout.nav_section_item, parent, false))
}
else -> {
FeedHolder(inflater.inflate(R.layout.nav_listitem, parent, false))
}
}
}
@UnstableApi override fun onBindViewHolder(holder: Holder, position: Int) {
val viewType = getItemViewType(position)
holder.itemView.setOnCreateContextMenuListener(null)
when (viewType) {
VIEW_TYPE_NAV -> {
bindNavView(getLabel(fragmentTags[position]), position, holder as NavHolder)
}
VIEW_TYPE_SECTION_DIVIDER -> {
bindSectionDivider(holder as DividerHolder)
}
else -> {
val itemPos = position - subscriptionOffset
val item = itemAccess.getItem(itemPos)
if (item != null) {
bindListItem(item, holder as FeedHolder)
if (item.type == DrawerItem.Type.FEED) {
bindFeedView(item as FeedDrawerItem, holder)
} else {
bindTagView(item as TagDrawerItem, holder)
}
}
holder.itemView.setOnCreateContextMenuListener(itemAccess)
}
}
if (viewType != VIEW_TYPE_SECTION_DIVIDER) {
holder.itemView.isSelected = itemAccess.isSelected(position)
holder.itemView.setOnClickListener { v: View? -> itemAccess.onItemClick(position) }
holder.itemView.setOnLongClickListener { v: View? -> itemAccess.onItemLongClick(position) }
holder.itemView.setOnTouchListener { v: View?, e: MotionEvent ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (e.isFromSource(InputDevice.SOURCE_MOUSE)
&& e.buttonState == MotionEvent.BUTTON_SECONDARY) {
itemAccess.onItemLongClick(position)
return@setOnTouchListener false
}
}
false
}
}
}
@UnstableApi private fun bindNavView(title: String, position: Int, holder: NavHolder) {
val context = activity.get() ?: return
holder.title.text = title
// reset for re-use
holder.count.visibility = View.GONE
holder.count.setOnClickListener(null)
holder.count.isClickable = false
val tag = fragmentTags[position]
when {
tag == QueueFragment.TAG -> {
val queueSize = itemAccess.queueSize
if (queueSize > 0) {
holder.count.text = NumberFormat.getInstance().format(queueSize.toLong())
holder.count.visibility = View.VISIBLE
}
}
tag == InboxFragment.TAG -> {
val unreadItems = itemAccess.numberOfNewItems
if (unreadItems > 0) {
holder.count.text = NumberFormat.getInstance().format(unreadItems.toLong())
holder.count.visibility = View.VISIBLE
}
}
tag == SubscriptionFragment.TAG -> {
val sum = itemAccess.feedCounterSum
if (sum > 0) {
holder.count.text = NumberFormat.getInstance().format(sum.toLong())
holder.count.visibility = View.VISIBLE
}
}
tag == CompletedDownloadsFragment.TAG && isEnableAutodownload -> {
val epCacheSize = episodeCacheSize
// don't count episodes that can be reclaimed
val spaceUsed = (itemAccess.numberOfDownloadedItems
- itemAccess.reclaimableItems)
if (epCacheSize in 1..spaceUsed) {
holder.count.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_disc_alert, 0)
holder.count.visibility = View.VISIBLE
holder.count.setOnClickListener { v: View? ->
MaterialAlertDialogBuilder(context)
.setTitle(R.string.episode_cache_full_title)
.setMessage(R.string.episode_cache_full_message)
.setPositiveButton(android.R.string.ok, null)
.setNeutralButton(R.string.open_autodownload_settings) { dialog: DialogInterface?, which: Int ->
val intent = Intent(context, PreferenceActivity::class.java)
intent.putExtra(PreferenceActivity.OPEN_AUTO_DOWNLOAD_SETTINGS, true)
context.startActivity(intent)
}
.show()
}
}
}
}
holder.image.setImageResource(getDrawable(fragmentTags[position]))
}
private fun bindSectionDivider(holder: DividerHolder) {
val context = activity.get() ?: return
if (subscriptionsFilter.isEnabled && showSubscriptionList) {
holder.itemView.isEnabled = true
holder.feedsFilteredMsg.visibility = View.VISIBLE
} else {
holder.itemView.isEnabled = false
holder.feedsFilteredMsg.visibility = View.GONE
}
}
private fun bindListItem(item: DrawerItem, holder: FeedHolder) {
if (item.counter > 0) {
holder.count.visibility = View.VISIBLE
holder.count.text = NumberFormat.getInstance().format(item.counter.toLong())
} else {
holder.count.visibility = View.GONE
}
holder.title.text = item.title
val padding = (activity.get()!!.resources.getDimension(R.dimen.thumbnail_length_navlist) / 2).toInt()
holder.itemView.setPadding(item.layer * padding, 0, 0, 0)
}
private fun bindFeedView(drawerItem: FeedDrawerItem, holder: FeedHolder) {
val feed = drawerItem.feed
val context = activity.get() ?: return
Glide.with(context)
.load(feed.imageUrl)
.apply(RequestOptions()
.placeholder(R.color.light_gray)
.error(R.color.light_gray)
.transform(FitCenter(),
RoundedCorners((4 * context.resources.displayMetrics.density).toInt()))
.dontAnimate())
.into(holder.image)
if (feed.hasLastUpdateFailed()) {
val p = holder.title.layoutParams as RelativeLayout.LayoutParams
p.addRule(RelativeLayout.LEFT_OF, R.id.itxtvFailure)
holder.failure.visibility = View.VISIBLE
} else {
val p = holder.title.layoutParams as RelativeLayout.LayoutParams
p.addRule(RelativeLayout.LEFT_OF, R.id.txtvCount)
holder.failure.visibility = View.GONE
}
}
private fun bindTagView(tag: TagDrawerItem, holder: FeedHolder) {
val context = activity.get() ?: return
if (tag.isOpen) {
holder.count.visibility = View.GONE
}
Glide.with(context).clear(holder.image)
holder.image.setImageResource(R.drawable.ic_tag)
holder.failure.visibility = View.GONE
}
open class Holder(itemView: View) : RecyclerView.ViewHolder(itemView)
internal class DividerHolder(itemView: View) : Holder(itemView) {
val feedsFilteredMsg: LinearLayout = itemView.findViewById(R.id.nav_feeds_filtered_message)
}
internal class NavHolder(itemView: View) : Holder(itemView) {
val image: ImageView = itemView.findViewById(R.id.imgvCover)
val title: TextView = itemView.findViewById(R.id.txtvTitle)
val count: TextView = itemView.findViewById(R.id.txtvCount)
}
internal class FeedHolder(itemView: View) : Holder(itemView) {
val image: ImageView = itemView.findViewById(R.id.imgvCover)
val title: TextView = itemView.findViewById(R.id.txtvTitle)
val failure: ImageView = itemView.findViewById(R.id.itxtvFailure)
val count: TextView = itemView.findViewById(R.id.txtvCount)
}
interface ItemAccess : OnCreateContextMenuListener {
val count: Int
fun getItem(position: Int): DrawerItem?
fun isSelected(position: Int): Boolean
val queueSize: Int
val numberOfNewItems: Int
val numberOfDownloadedItems: Int
val reclaimableItems: Int
val feedCounterSum: Int
fun onItemClick(position: Int)
fun onItemLongClick(position: Int): Boolean
override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenuInfo?)
}
companion object {
const val VIEW_TYPE_NAV: Int = 0
const val VIEW_TYPE_SECTION_DIVIDER: Int = 1
private const val VIEW_TYPE_SUBSCRIPTION = 2
/**
* a tag used as a placeholder to indicate if the subscription list should be displayed or not
* This tag doesn't correspond to any specific activity.
*/
const val SUBSCRIPTION_LIST_TAG: String = "SubscriptionList"
}
}

View File

@ -1,91 +0,0 @@
package ac.mdiq.podcini.adapter
import ac.mdiq.podcini.activity.MainActivity
import android.annotation.SuppressLint
import android.util.Log
import android.view.ContextMenu
import android.view.MenuInflater
import android.view.MotionEvent
import android.view.View
import androidx.media3.common.util.UnstableApi
import ac.mdiq.podcini.R
import ac.mdiq.podcini.fragment.swipeactions.SwipeActions
import ac.mdiq.podcini.storage.preferences.UserPreferences
import ac.mdiq.podcini.view.viewholder.EpisodeItemViewHolder
/**
* List adapter for the queue.
*/
open class QueueRecyclerAdapter(mainActivity: MainActivity, private val swipeActions: SwipeActions) : EpisodeItemListAdapter(mainActivity) {
private var dragDropEnabled: Boolean
init {
dragDropEnabled = !(UserPreferences.isQueueKeepSorted || UserPreferences.isQueueLocked)
}
fun updateDragDropEnabled() {
dragDropEnabled = !(UserPreferences.isQueueKeepSorted || UserPreferences.isQueueLocked)
notifyDataSetChanged()
}
@UnstableApi @SuppressLint("ClickableViewAccessibility")
override fun afterBindViewHolder(holder: EpisodeItemViewHolder, pos: Int) {
if (!dragDropEnabled) {
holder.dragHandle.setVisibility(View.GONE)
holder.dragHandle.setOnTouchListener(null)
holder.coverHolder.setOnTouchListener(null)
} else {
holder.dragHandle.setVisibility(View.VISIBLE)
holder.dragHandle.setOnTouchListener { v1: View?, event: MotionEvent ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
Log.d(TAG, "startDrag()")
swipeActions.startDrag(holder)
}
false
}
holder.coverHolder.setOnTouchListener { v1, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
val isLtr = holder.itemView.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR
val factor = (if (isLtr) 1 else -1).toFloat()
if (factor * event.x < factor * 0.5 * v1.width) {
Log.d(TAG, "startDrag()")
swipeActions.startDrag(holder)
} else {
Log.d(TAG, "Ignoring drag in right half of the image")
}
}
false
}
}
if (inActionMode()) {
holder.dragHandle.setOnTouchListener(null)
holder.coverHolder.setOnTouchListener(null)
}
holder.isInQueue.setVisibility(View.GONE)
}
@UnstableApi override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {
val inflater: MenuInflater = activity!!.getMenuInflater()
inflater.inflate(R.menu.queue_context, menu)
super.onCreateContextMenu(menu, v, menuInfo)
if (!inActionMode()) {
menu.findItem(R.id.multi_select).setVisible(true)
val keepSorted: Boolean = UserPreferences.isQueueKeepSorted
if (getItem(0)?.id === longPressedItem?.id || keepSorted) {
menu.findItem(R.id.move_to_top_item).setVisible(false)
}
if (getItem(itemCount - 1)?.id === longPressedItem?.id || keepSorted) {
menu.findItem(R.id.move_to_bottom_item).setVisible(false)
}
} else {
menu.findItem(R.id.move_to_top_item).setVisible(false)
menu.findItem(R.id.move_to_bottom_item).setVisible(false)
}
}
companion object {
private const val TAG = "QueueRecyclerAdapter"
}
}

View File

@ -1,188 +0,0 @@
package ac.mdiq.podcini.adapter
import android.app.Activity
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import androidx.recyclerview.widget.RecyclerView
import ac.mdiq.podcini.R
/**
* Used by Recyclerviews that need to provide ability to select items.
*/
abstract class SelectableAdapter<T : RecyclerView.ViewHolder?>(private val activity: Activity) :
RecyclerView.Adapter<T>() {
private var actionMode: ActionMode? = null
private val selectedIds = HashSet<Long>()
private var onSelectModeListener: OnSelectModeListener? = null
var shouldSelectLazyLoadedItems: Boolean = false
private var totalNumberOfItems = COUNT_AUTOMATICALLY
fun startSelectMode(pos: Int) {
if (inActionMode()) {
endSelectMode()
}
onSelectModeListener?.onStartSelectMode()
shouldSelectLazyLoadedItems = false
selectedIds.clear()
selectedIds.add(getItemId(pos))
notifyDataSetChanged()
actionMode = activity.startActionMode(object : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
val inflater = mode.menuInflater
inflater.inflate(R.menu.multi_select_options, menu)
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
updateTitle()
toggleSelectAllIcon(menu.findItem(R.id.select_toggle), false)
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
if (item.itemId == R.id.select_toggle) {
val selectAll = selectedIds.size != itemCount
shouldSelectLazyLoadedItems = selectAll
setSelected(0, itemCount, selectAll)
toggleSelectAllIcon(item, selectAll)
updateTitle()
return true
}
return false
}
override fun onDestroyActionMode(mode: ActionMode) {
callOnEndSelectMode()
actionMode = null
shouldSelectLazyLoadedItems = false
selectedIds.clear()
notifyDataSetChanged()
}
})
updateTitle()
}
/**
* End action mode if currently in select mode, otherwise do nothing
*/
fun endSelectMode() {
if (inActionMode()) {
callOnEndSelectMode()
actionMode?.finish()
}
}
fun isSelected(pos: Int): Boolean {
return selectedIds.contains(getItemId(pos))
}
/**
* Set the selected state of item at given position
*
* @param pos the position to select
* @param selected true for selected state and false for unselected
*/
open fun setSelected(pos: Int, selected: Boolean) {
if (selected) {
selectedIds.add(getItemId(pos))
} else {
selectedIds.remove(getItemId(pos))
}
updateTitle()
}
/**
* Set the selected state of item for a given range
*
* @param startPos start position of range, inclusive
* @param endPos end position of range, inclusive
* @param selected indicates the selection state
* @throws IllegalArgumentException if start and end positions are not valid
*/
@Throws(IllegalArgumentException::class)
fun setSelected(startPos: Int, endPos: Int, selected: Boolean) {
var i = startPos
while (i < endPos && i < itemCount) {
setSelected(i, selected)
i++
}
notifyItemRangeChanged(startPos, (endPos - startPos))
}
protected fun toggleSelection(pos: Int) {
setSelected(pos, !isSelected(pos))
notifyItemChanged(pos)
if (selectedIds.size == 0) {
endSelectMode()
}
}
fun inActionMode(): Boolean {
return actionMode != null
}
val selectedCount: Int
get() = selectedIds.size
private fun toggleSelectAllIcon(selectAllItem: MenuItem, allSelected: Boolean) {
if (allSelected) {
selectAllItem.setIcon(R.drawable.ic_select_none)
selectAllItem.setTitle(R.string.deselect_all_label)
} else {
selectAllItem.setIcon(R.drawable.ic_select_all)
selectAllItem.setTitle(R.string.select_all_label)
}
}
fun updateTitle() {
if (actionMode == null) {
return
}
var totalCount = itemCount
var selectedCount = selectedIds.size
if (totalNumberOfItems != COUNT_AUTOMATICALLY) {
totalCount = totalNumberOfItems
if (shouldSelectLazyLoadedItems) {
selectedCount += (totalNumberOfItems - itemCount)
}
}
actionMode!!.title = activity.resources
.getQuantityString(R.plurals.num_selected_label, selectedIds.size,
selectedCount, totalCount)
}
fun setOnSelectModeListener(onSelectModeListener: OnSelectModeListener?) {
this.onSelectModeListener = onSelectModeListener
}
private fun callOnEndSelectMode() {
onSelectModeListener?.onEndSelectMode()
}
fun shouldSelectLazyLoadedItems(): Boolean {
return shouldSelectLazyLoadedItems
}
/**
* Sets the total number of items that could be lazy-loaded.
* Can also be set to [.COUNT_AUTOMATICALLY] to simply use [.getItemCount]
*/
fun setTotalNumberOfItems(totalNumberOfItems: Int) {
this.totalNumberOfItems = totalNumberOfItems
}
interface OnSelectModeListener {
fun onStartSelectMode()
fun onEndSelectMode()
}
companion object {
const val COUNT_AUTOMATICALLY: Int = -1
}
}

View File

@ -1,40 +0,0 @@
package ac.mdiq.podcini.adapter
import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.Chip
import ac.mdiq.podcini.R
abstract class SimpleChipAdapter(private val context: Context) : RecyclerView.Adapter<SimpleChipAdapter.ViewHolder>() {
init {
setHasStableIds(true)
}
protected abstract fun getChips(): List<String>
protected abstract fun onRemoveClicked(position: Int)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val chip = Chip(context)
chip.isCloseIconVisible = true
chip.setCloseIconResource(R.drawable.ic_delete)
return ViewHolder(chip)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.chip.text = getChips()[position]
holder.chip.setOnCloseIconClickListener { v: View? -> onRemoveClicked(position) }
}
override fun getItemCount(): Int {
return getChips().size
}
override fun getItemId(position: Int): Long {
return getChips()[position].hashCode().toLong()
}
class ViewHolder internal constructor(var chip: Chip) : RecyclerView.ViewHolder(chip)
}

View File

@ -1,41 +0,0 @@
package ac.mdiq.podcini.adapter
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.TextView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import ac.mdiq.podcini.R
/**
* Displays a list of items that have a subtitle and an icon.
*/
class SimpleIconListAdapter<T : SimpleIconListAdapter.ListItem>(private val context: Context,
private val listItems: List<T>
) : ArrayAdapter<T>(context, R.layout.simple_icon_list_item, listItems) {
override fun getView(position: Int, view: View?, parent: ViewGroup): View {
var view = view
if (view == null) {
view = View.inflate(context, R.layout.simple_icon_list_item, null)
}
val item: ListItem = listItems[position]
(view!!.findViewById<View>(R.id.title) as TextView).text = item.title
(view.findViewById<View>(R.id.subtitle) as TextView).text = item.subtitle
Glide.with(context)
.load(item.imageUrl)
.apply(RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.NONE)
.fitCenter()
.dontAnimate())
.into(((view.findViewById<View>(R.id.icon) as ImageView)))
return view
}
open class ListItem(val title: String, val subtitle: String, val imageUrl: String)
}

View File

@ -1,272 +0,0 @@
package ac.mdiq.podcini.adapter
import ac.mdiq.podcini.activity.MainActivity
import android.content.Context
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Build
import android.view.*
import android.widget.*
import androidx.appcompat.content.res.AppCompatResources
import androidx.cardview.widget.CardView
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.elevation.SurfaceColors
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.storage.NavDrawerData
import ac.mdiq.podcini.fragment.FeedItemlistFragment
import ac.mdiq.podcini.fragment.SubscriptionFragment
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.storage.preferences.UserPreferences
import java.lang.ref.WeakReference
import java.text.NumberFormat
/**
* Adapter for subscriptions
*/
open class SubscriptionsRecyclerAdapter(mainActivity: MainActivity) :
SelectableAdapter<SubscriptionsRecyclerAdapter.SubscriptionViewHolder?>(mainActivity),
View.OnCreateContextMenuListener {
private val mainActivityRef: WeakReference<MainActivity> = WeakReference<MainActivity>(mainActivity)
private var listItems: List<NavDrawerData.DrawerItem>
private var selectedItem: NavDrawerData.DrawerItem? = null
var longPressedPosition: Int = 0 // used to init actionMode
private var columnCount = 3
init {
this.listItems = ArrayList()
setHasStableIds(true)
}
fun setColumnCount(columnCount: Int) {
this.columnCount = columnCount
}
fun getItem(position: Int): Any {
return listItems[position]
}
fun getSelectedItem(): NavDrawerData.DrawerItem? {
return selectedItem
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
val itemView: View =
LayoutInflater.from(mainActivityRef.get()).inflate(R.layout.subscription_item, parent, false)
itemView.findViewById<View>(R.id.titleLabel).visibility = if (viewType == COVER_WITH_TITLE) View.VISIBLE else View.GONE
return SubscriptionViewHolder(itemView)
}
@UnstableApi override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) {
val drawerItem: NavDrawerData.DrawerItem = listItems[position]
val isFeed = drawerItem.type == NavDrawerData.DrawerItem.Type.FEED
holder.bind(drawerItem)
holder.itemView.setOnCreateContextMenuListener(this)
if (inActionMode()) {
if (isFeed) {
holder.selectCheckbox.visibility = View.VISIBLE
holder.selectView.visibility = View.VISIBLE
}
holder.selectCheckbox.setChecked((isSelected(position)))
holder.selectCheckbox.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean ->
setSelected(holder.bindingAdapterPosition,
isChecked)
}
if (holder.coverImage != null) holder.coverImage.alpha = 0.6f
holder.count.visibility = View.GONE
} else {
holder.selectView.visibility = View.GONE
if (holder.coverImage != null) holder.coverImage.alpha = 1.0f
}
holder.itemView.setOnLongClickListener { v: View? ->
if (!inActionMode()) {
if (isFeed) {
longPressedPosition = holder.bindingAdapterPosition
}
selectedItem = drawerItem
}
false
}
holder.itemView.setOnTouchListener { v: View?, e: MotionEvent ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (e.isFromSource(InputDevice.SOURCE_MOUSE)
&& e.buttonState == MotionEvent.BUTTON_SECONDARY) {
if (!inActionMode()) {
if (isFeed) {
longPressedPosition = holder.bindingAdapterPosition
}
selectedItem = drawerItem
}
}
}
false
}
holder.itemView.setOnClickListener { v: View? ->
if (isFeed) {
if (inActionMode()) {
holder.selectCheckbox.setChecked(!isSelected(holder.bindingAdapterPosition))
} else {
val fragment: Fragment = FeedItemlistFragment
.newInstance((drawerItem as NavDrawerData.FeedDrawerItem).feed.id)
mainActivityRef.get()?.loadChildFragment(fragment)
}
} else if (!inActionMode()) {
val fragment: Fragment = SubscriptionFragment.newInstance(drawerItem.title)
mainActivityRef.get()?.loadChildFragment(fragment)
}
}
}
override fun getItemCount(): Int {
return listItems.size
}
override fun getItemId(position: Int): Long {
if (position >= listItems.size) {
return RecyclerView.NO_ID // Dummy views
}
return listItems[position].id
}
override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {
if (inActionMode() || selectedItem == null) {
return
}
val inflater: MenuInflater = mainActivityRef.get()!!.menuInflater
if (selectedItem?.type == NavDrawerData.DrawerItem.Type.FEED) {
inflater.inflate(R.menu.nav_feed_context, menu)
menu.findItem(R.id.multi_select).setVisible(true)
} else {
inflater.inflate(R.menu.nav_folder_context, menu)
}
menu.setHeaderTitle(selectedItem?.title)
}
fun onContextItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.multi_select) {
startSelectMode(longPressedPosition)
return true
}
return false
}
val selectedItems: List<Any>
get() {
val items = ArrayList<Feed>()
for (i in 0 until itemCount) {
if (isSelected(i)) {
val drawerItem: NavDrawerData.DrawerItem = listItems[i]
if (drawerItem.type == NavDrawerData.DrawerItem.Type.FEED) {
val feed: Feed = (drawerItem as NavDrawerData.FeedDrawerItem).feed
items.add(feed)
}
}
}
return items
}
fun setItems(listItems: List<NavDrawerData.DrawerItem>) {
this.listItems = listItems
notifyDataSetChanged()
}
override fun setSelected(pos: Int, selected: Boolean) {
val drawerItem: NavDrawerData.DrawerItem = listItems[pos]
if (drawerItem.type == NavDrawerData.DrawerItem.Type.FEED) {
super.setSelected(pos, selected)
}
}
override fun getItemViewType(position: Int): Int {
return if (UserPreferences.shouldShowSubscriptionTitle()) COVER_WITH_TITLE else 0
}
inner class SubscriptionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val title = itemView.findViewById<TextView>(R.id.titleLabel)
val coverImage: ImageView? = itemView.findViewById(R.id.coverImage)
val count: TextView = itemView.findViewById(R.id.countViewPill)
private val fallbackTitle: TextView = itemView.findViewById(R.id.fallbackTitleLabel)
val selectView: FrameLayout = itemView.findViewById(R.id.selectContainer)
val selectCheckbox: CheckBox = itemView.findViewById(R.id.selectCheckBox)
private val card: CardView = itemView.findViewById(R.id.outerContainer)
private val errorIcon: View = itemView.findViewById(R.id.errorIcon)
fun bind(drawerItem: NavDrawerData.DrawerItem) {
val drawable: Drawable? = AppCompatResources.getDrawable(selectView.context,
R.drawable.ic_checkbox_background)
selectView.background = drawable // Setting this in XML crashes API <= 21
title.text = drawerItem.title
fallbackTitle.text = drawerItem.title
if (coverImage != null) coverImage.contentDescription = drawerItem.title
if (drawerItem.counter > 0) {
count.text = NumberFormat.getInstance().format(drawerItem.counter.toLong())
count.visibility = View.VISIBLE
} else {
count.visibility = View.GONE
}
val coverLoader = CoverLoader(mainActivityRef.get()!!)
val textAndImageCombined: Boolean
if (drawerItem.type == NavDrawerData.DrawerItem.Type.FEED) {
val feed: Feed = (drawerItem as NavDrawerData.FeedDrawerItem).feed
textAndImageCombined = feed.isLocalFeed && feed.imageUrl != null && feed.imageUrl!!.startsWith(Feed.PREFIX_GENERATIVE_COVER)
coverLoader.withUri(feed.imageUrl)
errorIcon.visibility = if (feed.hasLastUpdateFailed()) View.VISIBLE else View.GONE
} else {
textAndImageCombined = true
coverLoader.withResource(R.drawable.ic_tag)
errorIcon.visibility = View.GONE
}
if (UserPreferences.shouldShowSubscriptionTitle()) {
// No need for fallback title when already showing title
fallbackTitle.visibility = View.GONE
} else {
coverLoader.withPlaceholderView(fallbackTitle, textAndImageCombined)
}
if (coverImage != null) coverLoader.withCoverView(coverImage)
coverLoader.load()
val density: Float = mainActivityRef.get()!!.resources.displayMetrics.density
card.setCardBackgroundColor(SurfaceColors.getColorForElevation(mainActivityRef.get()!!, 1 * density))
val textPadding = if (columnCount <= 3) 16 else 8
title.setPadding(textPadding, textPadding, textPadding, textPadding)
fallbackTitle.setPadding(textPadding, textPadding, textPadding, textPadding)
var textSize = 14
if (columnCount == 3) {
textSize = 15
} else if (columnCount == 2) {
textSize = 16
}
title.textSize = textSize.toFloat()
fallbackTitle.textSize = textSize.toFloat()
}
}
class GridDividerItemDecorator : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
val context = parent.context
val insetOffset = convertDpToPixel(context, 1f).toInt()
outRect[insetOffset, insetOffset, insetOffset] = insetOffset
}
}
companion object {
private const val COVER_WITH_TITLE = 1
fun convertDpToPixel(context: Context, dp: Float): Float {
return dp * context.resources.displayMetrics.density
}
}
}

View File

@ -1,32 +0,0 @@
package ac.mdiq.podcini.adapter.actionbutton
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.media3.common.util.UnstableApi
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.storage.DBWriter
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.storage.preferences.UserPreferences.isEnableAutodownload
class CancelDownloadActionButton(item: FeedItem) : ItemActionButton(item) {
@StringRes
override fun getLabel(): Int {
return R.string.cancel_download_label
}
@DrawableRes
override fun getDrawable(): Int {
return R.drawable.ic_cancel
}
@UnstableApi override fun onClick(context: Context) {
val media = item.media
if (media != null) DownloadServiceInterface.get()?.cancel(context, media)
if (isEnableAutodownload) {
item.disableAutoDownload()
DBWriter.setFeedItem(item)
}
}
}

View File

@ -1,32 +0,0 @@
package ac.mdiq.podcini.adapter.actionbutton
import android.content.Context
import android.view.View
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.storage.DBWriter
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.view.LocalDeleteModal.showLocalFeedDeleteWarningIfNecessary
import androidx.media3.common.util.UnstableApi
class DeleteActionButton(item: FeedItem) : ItemActionButton(item) {
override fun getLabel(): Int {
return R.string.delete_label
}
override fun getDrawable(): Int {
return R.drawable.ic_delete
}
@UnstableApi override fun onClick(context: Context) {
val media = item.media ?: return
showLocalFeedDeleteWarningIfNecessary(context, listOf(item)) { DBWriter.deleteFeedMediaOfItem(context, media.id) }
}
override val visibility: Int
get() {
if (item.media != null && (item.media!!.isDownloaded() || item.feed?.isLocalFeed == true)) {
return View.VISIBLE
}
return View.INVISIBLE
}
}

View File

@ -1,60 +0,0 @@
package ac.mdiq.podcini.adapter.actionbutton
import android.content.Context
import android.content.DialogInterface
import android.view.View
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.preferences.UsageStatistics
import ac.mdiq.podcini.core.preferences.UsageStatistics.logAction
import ac.mdiq.podcini.core.util.NetworkUtils.isEpisodeDownloadAllowed
import ac.mdiq.podcini.core.util.NetworkUtils.isNetworkRestricted
import ac.mdiq.podcini.core.util.NetworkUtils.isVpnOverWifi
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.model.feed.FeedMedia
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
class DownloadActionButton(item: FeedItem) : ItemActionButton(item) {
override fun getLabel(): Int {
return R.string.download_label
}
override fun getDrawable(): Int {
return R.drawable.ic_download
}
override val visibility: Int
get() = if (item.feed?.isLocalFeed == true) View.INVISIBLE else View.VISIBLE
override fun onClick(context: Context) {
val media = item.media
if (media == null || shouldNotDownload(media)) {
return
}
logAction(UsageStatistics.ACTION_DOWNLOAD)
if (isEpisodeDownloadAllowed) {
DownloadServiceInterface.get()?.downloadNow(context, item, false)
} else {
val builder = MaterialAlertDialogBuilder(context)
.setTitle(R.string.confirm_mobile_download_dialog_title)
.setPositiveButton(R.string.confirm_mobile_download_dialog_download_later
) { d: DialogInterface?, w: Int -> DownloadServiceInterface.get()?.downloadNow(context, item, false) }
.setNeutralButton(R.string.confirm_mobile_download_dialog_allow_this_time
) { d: DialogInterface?, w: Int -> DownloadServiceInterface.get()?.downloadNow(context, item, true) }
.setNegativeButton(R.string.cancel_label, null)
if (isNetworkRestricted && isVpnOverWifi) {
builder.setMessage(R.string.confirm_mobile_download_dialog_message_vpn)
} else {
builder.setMessage(R.string.confirm_mobile_download_dialog_message)
}
builder.show()
}
}
private fun shouldNotDownload(media: FeedMedia): Boolean {
if (media.download_url == null) return true
val isDownloading = DownloadServiceInterface.get()?.isDownloadingEpisode(media.download_url!!)?:false
return isDownloading || media.isDownloaded()
}
}

View File

@ -1,59 +0,0 @@
package ac.mdiq.podcini.adapter.actionbutton
import android.content.Context
import android.view.View
import android.widget.ImageView
import androidx.media3.common.util.UnstableApi
import ac.mdiq.podcini.core.util.PlaybackStatus.isCurrentlyPlaying
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.storage.preferences.UserPreferences.isStreamOverDownload
abstract class ItemActionButton internal constructor(@JvmField var item: FeedItem) {
abstract fun getLabel(): Int
abstract fun getDrawable(): Int
abstract fun onClick(context: Context)
open val visibility: Int
get() = View.VISIBLE
fun configure(button: View, icon: ImageView, context: Context) {
button.visibility = visibility
button.contentDescription = context.getString(getLabel())
button.setOnClickListener { view: View? -> onClick(context) }
icon.setImageResource(getDrawable())
}
@UnstableApi companion object {
fun forItem(item: FeedItem): ItemActionButton {
val media = item.media ?: return MarkAsPlayedActionButton(item)
val isDownloadingMedia = when (media.download_url) {
null -> false
else -> DownloadServiceInterface.get()?.isDownloadingEpisode(media.download_url!!)?:false
}
return when {
isCurrentlyPlaying(media) -> {
PauseActionButton(item)
}
item.feed != null && item.feed!!.isLocalFeed -> {
PlayLocalActionButton(item)
}
media.isDownloaded() -> {
PlayActionButton(item)
}
isDownloadingMedia -> {
CancelDownloadActionButton(item)
}
isStreamOverDownload -> {
StreamActionButton(item)
}
else -> {
DownloadActionButton(item)
}
}
}
}
}

View File

@ -1,25 +0,0 @@
package ac.mdiq.podcini.adapter.actionbutton
import android.content.Context
import android.view.View
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.storage.DBWriter
import ac.mdiq.podcini.model.feed.FeedItem
import androidx.media3.common.util.UnstableApi
class MarkAsPlayedActionButton(item: FeedItem) : ItemActionButton(item) {
override fun getLabel(): Int {
return (if (item.hasMedia()) R.string.mark_read_label else R.string.mark_read_no_media_label)
}
override fun getDrawable(): Int {
return R.drawable.ic_check
}
@UnstableApi override fun onClick(context: Context) {
if (!item.isPlayed()) {
DBWriter.markItemPlayed(item, FeedItem.PLAYED, true)
}
}
override val visibility: Int
get() = if (item.isPlayed()) View.INVISIBLE else View.VISIBLE
}

View File

@ -1,25 +0,0 @@
package ac.mdiq.podcini.adapter.actionbutton
import android.content.Context
import android.view.KeyEvent
import androidx.media3.common.util.UnstableApi
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.receiver.MediaButtonReceiver.Companion.createIntent
import ac.mdiq.podcini.core.util.PlaybackStatus.isCurrentlyPlaying
import ac.mdiq.podcini.model.feed.FeedItem
class PauseActionButton(item: FeedItem) : ItemActionButton(item) {
override fun getLabel(): Int {
return R.string.pause_label
}
override fun getDrawable(): Int {
return R.drawable.ic_pause
}
@UnstableApi override fun onClick(context: Context) {
val media = item.media ?: return
if (isCurrentlyPlaying(media)) {
context.sendBroadcast(createIntent(context, KeyEvent.KEYCODE_MEDIA_PAUSE))
}
}
}

View File

@ -1,33 +0,0 @@
package ac.mdiq.podcini.adapter.actionbutton
import android.content.Context
import androidx.media3.common.util.UnstableApi
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.service.playback.PlaybackService.Companion.getPlayerActivityIntent
import ac.mdiq.podcini.core.storage.DBTasks
import ac.mdiq.podcini.core.util.playback.PlaybackServiceStarter
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.model.playback.MediaType
class PlayActionButton(item: FeedItem) : ItemActionButton(item) {
override fun getLabel(): Int {
return R.string.play_label
}
override fun getDrawable(): Int {
return R.drawable.ic_play_24dp
}
@UnstableApi override fun onClick(context: Context) {
val media = item.media ?: return
if (!media.fileExists()) {
DBTasks.notifyMissingFeedMediaFile(context, media)
return
}
PlaybackServiceStarter(context, media)
.callEvenIfRunning(true)
.start()
if (media.getMediaType() == MediaType.VIDEO) {
context.startActivity(getPlayerActivityIntent(context, media))
}
}
}

View File

@ -1,29 +0,0 @@
package ac.mdiq.podcini.adapter.actionbutton
import android.content.Context
import androidx.media3.common.util.UnstableApi
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.service.playback.PlaybackService.Companion.getPlayerActivityIntent
import ac.mdiq.podcini.core.util.playback.PlaybackServiceStarter
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.model.playback.MediaType
class PlayLocalActionButton(item: FeedItem?) : ItemActionButton(item!!) {
override fun getLabel(): Int {
return R.string.play_label
}
override fun getDrawable(): Int {
return R.drawable.ic_play_24dp
}
@UnstableApi override fun onClick(context: Context) {
val media = item.media ?: return
PlaybackServiceStarter(context, media)
.callEvenIfRunning(true)
.start()
if (media.getMediaType() == MediaType.VIDEO) {
context.startActivity(getPlayerActivityIntent(context, media))
}
}
}

View File

@ -1,38 +0,0 @@
package ac.mdiq.podcini.adapter.actionbutton
import android.content.Context
import androidx.media3.common.util.UnstableApi
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.preferences.UsageStatistics
import ac.mdiq.podcini.core.preferences.UsageStatistics.logAction
import ac.mdiq.podcini.core.service.playback.PlaybackService.Companion.getPlayerActivityIntent
import ac.mdiq.podcini.core.util.NetworkUtils.isStreamingAllowed
import ac.mdiq.podcini.core.util.playback.PlaybackServiceStarter
import ac.mdiq.podcini.dialog.StreamingConfirmationDialog
import ac.mdiq.podcini.model.feed.FeedItem
import ac.mdiq.podcini.model.playback.MediaType
class StreamActionButton(item: FeedItem) : ItemActionButton(item) {
override fun getLabel(): Int {
return R.string.stream_label
}
override fun getDrawable(): Int {
return R.drawable.ic_stream
}
@UnstableApi override fun onClick(context: Context) {
val media = item.media ?: return
logAction(UsageStatistics.ACTION_STREAM)
if (!isStreamingAllowed) {
StreamingConfirmationDialog(context, media).show()
return
}
PlaybackServiceStarter(context, media)
.callEvenIfRunning(true)
.start()
if (media.getMediaType() == MediaType.VIDEO) {
context.startActivity(getPlayerActivityIntent(context, media))
}
}
}

View File

@ -1,22 +0,0 @@
package ac.mdiq.podcini.adapter.actionbutton
import android.content.Context
import android.view.View
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.util.IntentUtils.openInBrowser
import ac.mdiq.podcini.model.feed.FeedItem
class VisitWebsiteActionButton(item: FeedItem) : ItemActionButton(item) {
override fun getLabel(): Int {
return R.string.visit_website_label
}
override fun getDrawable(): Int {
return R.drawable.ic_web
}
override fun onClick(context: Context) {
if (item.link!= null) openInBrowser(context, item.link!!)
}
override val visibility: Int
get() = if (item.link == null) View.INVISIBLE else View.VISIBLE
}

View File

@ -1,93 +0,0 @@
package ac.mdiq.podcini.adapter.itunes
import ac.mdiq.podcini.activity.MainActivity
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.TextView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.FitCenter
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestOptions
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.discovery.PodcastSearchResult
import androidx.media3.common.util.UnstableApi
class ItunesAdapter(
/**
* Related Context
*/
private val context: Context, objects: List<PodcastSearchResult>
) : ArrayAdapter<PodcastSearchResult?>(context, 0, objects) {
/**
* List holding the podcasts found in the search
*/
private val data: List<PodcastSearchResult> = objects
@UnstableApi override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
//Current podcast
val podcast: PodcastSearchResult = data[position]
//ViewHolder
val viewHolder: PodcastViewHolder
//Resulting view
val view: View
//Handle view holder stuff
if (convertView == null) {
view = (context as MainActivity).layoutInflater.inflate(R.layout.itunes_podcast_listitem, parent, false)
viewHolder = PodcastViewHolder(view)
view.tag = viewHolder
} else {
view = convertView
viewHolder = view.tag as PodcastViewHolder
}
// Set the title
viewHolder.titleView.text = podcast.title
if (podcast.author != null && podcast.author!!.trim { it <= ' ' }.isNotEmpty()) {
viewHolder.authorView.text = podcast.author
viewHolder.authorView.visibility = View.VISIBLE
} else if (podcast.feedUrl != null && !podcast.feedUrl!!.contains("itunes.apple.com")) {
viewHolder.authorView.text = podcast.feedUrl
viewHolder.authorView.visibility = View.VISIBLE
} else {
viewHolder.authorView.visibility = View.GONE
}
//Update the empty imageView with the image from the feed
Glide.with(context)
.load(podcast.imageUrl)
.apply(RequestOptions()
.placeholder(R.color.light_gray)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transform(FitCenter(),
RoundedCorners((4 * context.resources.displayMetrics.density).toInt()))
.dontAnimate())
.into(viewHolder.coverView)
//Feed the grid view
return view
}
/**
* View holder object for the GridView
*/
internal class PodcastViewHolder(view: View) {
/**
* ImageView holding the Podcast image
*/
val coverView: ImageView = view.findViewById(R.id.imgvCover)
/**
* TextView holding the Podcast title
*/
val titleView: TextView = view.findViewById(R.id.txtvTitle)
val authorView: TextView = view.findViewById(R.id.txtvAuthor)
}
}

View File

@ -1,56 +0,0 @@
package ac.mdiq.podcini.asynctask
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import ac.mdiq.podcini.core.export.ExportWriter
import ac.mdiq.podcini.core.storage.DBReader
import io.reactivex.Observable
import io.reactivex.ObservableEmitter
import java.io.IOException
import java.io.OutputStream
import java.io.OutputStreamWriter
import java.nio.charset.Charset
/**
* Writes an OPML file into the user selected export directory in the background.
*/
class DocumentFileExportWorker(private val exportWriter: ExportWriter,
private val context: Context,
private val outputFileUri: Uri
) {
fun exportObservable(): Observable<DocumentFile?> {
val output = DocumentFile.fromSingleUri(context, outputFileUri)
return Observable.create { subscriber: ObservableEmitter<DocumentFile?> ->
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"))
exportWriter.writeDocument(DBReader.getFeedList(), writer, context)
subscriber.onNext(output)
} catch (e: IOException) {
subscriber.onError(e)
} finally {
if (writer != null) {
try {
writer.close()
} catch (e: IOException) {
subscriber.onError(e)
}
}
if (outputStream != null) {
try {
outputStream.close()
} catch (e: IOException) {
subscriber.onError(e)
}
}
subscriber.onComplete()
}
}
}
}

View File

@ -1,57 +0,0 @@
package ac.mdiq.podcini.asynctask
import android.content.Context
import android.util.Log
import ac.mdiq.podcini.core.export.ExportWriter
import ac.mdiq.podcini.core.storage.DBReader
import ac.mdiq.podcini.storage.preferences.UserPreferences.getDataFolder
import io.reactivex.Observable
import io.reactivex.ObservableEmitter
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStreamWriter
import java.nio.charset.Charset
/**
* 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)
fun exportObservable(): Observable<File?> {
if (output.exists()) {
val success = output.delete()
Log.w(TAG, "Overwriting previously exported file: $success")
}
return Observable.create { subscriber: ObservableEmitter<File?> ->
var writer: OutputStreamWriter? = null
try {
writer = OutputStreamWriter(FileOutputStream(output), Charset.forName("UTF-8"))
exportWriter.writeDocument(DBReader.getFeedList(), writer, context)
subscriber.onNext(output)
} catch (e: IOException) {
subscriber.onError(e)
} finally {
if (writer != null) {
try {
writer.close()
} catch (e: IOException) {
subscriber.onError(e)
}
}
subscriber.onComplete()
}
}
}
companion object {
private const val EXPORT_DIR = "export/"
private const val TAG = "ExportWorker"
private const val DEFAULT_OUTPUT_NAME = "podcini-feeds"
}
}

View File

@ -1,12 +0,0 @@
package ac.mdiq.podcini.config
import android.app.Application
import ac.mdiq.podcini.PodciniApp
import ac.mdiq.podcini.core.ApplicationCallbacks
class ApplicationCallbacksImpl : ApplicationCallbacks {
override fun getApplicationInstance(): Application {
return PodciniApp.getInstance()
}
}

View File

@ -1,22 +0,0 @@
package ac.mdiq.podcini.dialog
import android.os.Bundle
import ac.mdiq.podcini.model.feed.FeedItemFilter
import org.greenrobot.eventbus.EventBus
class AllEpisodesFilterDialog : ItemFilterDialog() {
override fun onFilterChanged(newFilterValues: Set<String>) {
EventBus.getDefault().post(AllEpisodesFilterChangedEvent(newFilterValues))
}
class AllEpisodesFilterChangedEvent(val filterValues: Set<String?>?)
companion object {
fun newInstance(filter: FeedItemFilter?): AllEpisodesFilterDialog {
val dialog = AllEpisodesFilterDialog()
val arguments = Bundle()
arguments.putSerializable(ARGUMENT_FILTER, filter)
dialog.arguments = arguments
return dialog
}
}
}

View File

@ -1,57 +0,0 @@
package ac.mdiq.podcini.dialog
import android.content.Context
import android.content.DialogInterface
import android.text.method.HideReturnsTransformationMethod
import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import android.view.View
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.AuthenticationDialogBinding
/**
* 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 { v: View? ->
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 { dialog: DialogInterface? -> onCancelled() }
setNegativeButton(R.string.cancel_label) { dialog: DialogInterface?, which: Int -> onCancelled() }
setPositiveButton(R.string.confirm_label) { dialog: DialogInterface?, which: Int ->
onConfirmed(viewBinding.usernameEditText.text.toString(),
viewBinding.passwordEditText.text.toString())
}
}
protected open fun onCancelled() {
}
protected abstract fun onConfirmed(username: String, password: String)
}

View File

@ -1,33 +0,0 @@
package ac.mdiq.podcini.dialog
import android.content.Context
import android.view.View
import androidx.core.util.Consumer
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.adapter.DataFolderAdapter
object ChooseDataFolderDialog {
fun showDialog(context: Context?, handlerFunc: Consumer<String?>) {
val content = View.inflate(context, R.layout.choose_data_folder_dialog, null)
val dialog = MaterialAlertDialogBuilder(context!!)
.setView(content)
.setTitle(R.string.choose_data_directory)
.setMessage(R.string.choose_data_directory_message)
.setNegativeButton(R.string.cancel_label, null)
.create()
val recyclerView = content.findViewById<RecyclerView>(R.id.recyclerView)
recyclerView.layoutManager = LinearLayoutManager(context)
val adapter = DataFolderAdapter(context) { path: String? ->
dialog.dismiss()
handlerFunc.accept(path)
}
recyclerView.adapter = adapter
if (adapter.itemCount != 0) {
dialog.show()
}
}
}

View File

@ -1,60 +0,0 @@
package ac.mdiq.podcini.dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.view.View
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.core.storage.DBReader
import ac.mdiq.podcini.core.util.DownloadErrorLabel.from
import ac.mdiq.podcini.event.MessageEvent
import ac.mdiq.podcini.model.download.DownloadResult
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.model.feed.FeedMedia
import org.greenrobot.eventbus.EventBus
class DownloadLogDetailsDialog(context: Context, status: DownloadResult) : MaterialAlertDialogBuilder(context) {
init {
var url = "unknown"
if (status.feedfileType == FeedMedia.FEEDFILETYPE_FEEDMEDIA) {
val media = DBReader.getFeedMedia(status.feedfileId)
if (media != null) {
url = media.download_url?:""
}
} else if (status.feedfileType == Feed.FEEDFILETYPE_FEED) {
val feed = DBReader.getFeed(status.feedfileId)
if (feed != null) {
url = feed.download_url?:""
}
}
var message = context.getString(R.string.download_successful)
if (!status.isSuccessful) {
message = status.reasonDetailed
}
val messageFull = context.getString(R.string.download_log_details_message,
context.getString(from(status.reason)), message, url)
setTitle(R.string.download_error_details)
setMessage(messageFull)
setPositiveButton("OK", null)
setNeutralButton(R.string.copy_to_clipboard) { dialog, which ->
val clipboard = getContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(context.getString(R.string.download_error_details), messageFull)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT < 32) {
EventBus.getDefault().post(MessageEvent(context.getString(R.string.copied_to_clipboard)))
}
}
}
override fun show(): AlertDialog {
val dialog = super.show()
(dialog.findViewById<View>(R.id.message) as? TextView)?.setTextIsSelectable(true)
return dialog
}
}

View File

@ -1,47 +0,0 @@
package ac.mdiq.podcini.dialog
import android.content.Context
import android.content.DialogInterface
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.fragment.NavDrawerFragment
import ac.mdiq.podcini.storage.preferences.UserPreferences
import ac.mdiq.podcini.storage.preferences.UserPreferences.defaultPage
import ac.mdiq.podcini.storage.preferences.UserPreferences.hiddenDrawerItems
object DrawerPreferencesDialog {
fun show(context: Context, callback: Runnable?) {
val hiddenDrawerItems = hiddenDrawerItems?.toMutableList()?: mutableListOf()
val navTitles = context.resources.getStringArray(R.array.nav_drawer_titles)
val checked = BooleanArray(NavDrawerFragment.NAV_DRAWER_TAGS.size)
for (i in NavDrawerFragment.NAV_DRAWER_TAGS.indices) {
val tag = NavDrawerFragment.NAV_DRAWER_TAGS[i]
if (!hiddenDrawerItems.contains(tag)) {
checked[i] = true
}
}
val builder = MaterialAlertDialogBuilder(context)
builder.setTitle(R.string.drawer_preferences)
builder.setMultiChoiceItems(navTitles, checked) { dialog: DialogInterface?, which: Int, isChecked: Boolean ->
if (isChecked) {
hiddenDrawerItems.remove(NavDrawerFragment.NAV_DRAWER_TAGS[which])
} else {
hiddenDrawerItems.add(NavDrawerFragment.NAV_DRAWER_TAGS[which])
}
}
builder.setPositiveButton(R.string.confirm_label) { dialog: DialogInterface?, which: Int ->
UserPreferences.hiddenDrawerItems = hiddenDrawerItems
if (hiddenDrawerItems.contains(defaultPage)) {
for (tag in NavDrawerFragment.NAV_DRAWER_TAGS) {
if (!hiddenDrawerItems.contains(tag)) {
defaultPage = tag
break
}
}
}
callback?.run()
}
builder.setNegativeButton(R.string.cancel_label, null)
builder.create().show()
}
}

View File

@ -1,82 +0,0 @@
package ac.mdiq.podcini.dialog
import android.app.Activity
import android.content.DialogInterface
import android.os.CountDownTimer
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.storage.DBWriter
import ac.mdiq.podcini.core.util.download.FeedUpdateManager.runOnce
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.model.feed.Feed
import androidx.media3.common.util.UnstableApi
import java.lang.ref.WeakReference
import java.util.*
import java.util.concurrent.ExecutionException
@UnstableApi
abstract class EditUrlSettingsDialog(activity: Activity, private val feed: Feed) {
private val activityRef = WeakReference(activity)
fun show() {
val activity = activityRef.get() ?: return
val binding = EditTextDialogBinding.inflate(LayoutInflater.from(activity))
binding.urlEditText.setText(feed.download_url)
MaterialAlertDialogBuilder(activity)
.setView(binding.root)
.setTitle(R.string.edit_url_menu)
.setPositiveButton(android.R.string.ok) { d: DialogInterface?, input: Int -> showConfirmAlertDialog(binding.urlEditText.text.toString()) }
.setNegativeButton(R.string.cancel_label, null)
.show()
}
@UnstableApi private fun onConfirmed(original: String, updated: String) {
try {
DBWriter.updateFeedDownloadURL(original, updated).get()
feed.download_url = updated
runOnce(activityRef.get()!!, feed)
} catch (e: ExecutionException) {
throw RuntimeException(e)
} catch (e: InterruptedException) {
throw RuntimeException(e)
}
}
@UnstableApi private fun showConfirmAlertDialog(url: String) {
val activity = activityRef.get()
val alertDialog = MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.edit_url_menu)
.setMessage(R.string.edit_url_confirmation_msg)
.setPositiveButton(android.R.string.ok) { d: DialogInterface?, input: Int ->
onConfirmed(feed.download_url?:"", url)
setUrl(url)
}
.setNegativeButton(R.string.cancel_label, null)
.show()
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
object : CountDownTimer(15000, 1000) {
override fun onTick(millisUntilFinished: Long) {
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).text = String.format(Locale.getDefault(), "%s (%d)",
activity.getString(android.R.string.ok), millisUntilFinished / 1000 + 1)
}
override fun onFinish() {
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(android.R.string.ok)
}
}.start()
}
protected abstract fun setUrl(url: String?)
companion object {
const val TAG: String = "EditUrlSettingsDialog"
}
}

View File

@ -1,111 +0,0 @@
package ac.mdiq.podcini.dialog
import android.content.Context
import android.content.DialogInterface
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.widget.CompoundButton
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.adapter.SimpleChipAdapter
import ac.mdiq.podcini.databinding.EpisodeFilterDialogBinding
import ac.mdiq.podcini.model.feed.FeedFilter
import ac.mdiq.podcini.view.ItemOffsetDecoration
/**
* Displays a dialog with a text box for filtering episodes and two radio buttons for exclusion/inclusion
*/
abstract class EpisodeFilterDialog(context: Context?, filter: FeedFilter) : MaterialAlertDialogBuilder(
context!!) {
private val viewBinding = EpisodeFilterDialogBinding.inflate(LayoutInflater.from(context))
private val termList: MutableList<String>
init {
setTitle(R.string.episode_filters_label)
setView(viewBinding.root)
viewBinding.durationCheckBox.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean ->
viewBinding.episodeFilterDurationText.isEnabled = isChecked
}
if (filter.hasMinimalDurationFilter()) {
viewBinding.durationCheckBox.isChecked = true
// Store minimal duration in seconds, show in minutes
viewBinding.episodeFilterDurationText
.setText((filter.minimalDurationFilter / 60).toString())
} else {
viewBinding.episodeFilterDurationText.isEnabled = false
}
if (filter.excludeOnly()) {
termList = filter.getExcludeFilter().toMutableList()
viewBinding.excludeRadio.isChecked = true
} else {
termList = filter.getIncludeFilter().toMutableList()
viewBinding.includeRadio.isChecked = true
}
setupWordsList()
setNegativeButton(R.string.cancel_label, null)
setPositiveButton(R.string.confirm_label) { dialog: DialogInterface, which: Int ->
this.onConfirmClick(dialog,
which)
}
}
private fun setupWordsList() {
viewBinding.termsRecycler.layoutManager = GridLayoutManager(context, 2)
viewBinding.termsRecycler.addItemDecoration(ItemOffsetDecoration(context, 4))
val adapter: SimpleChipAdapter = object : SimpleChipAdapter(context) {
override fun getChips(): List<String> {
return termList
}
override fun onRemoveClicked(position: Int) {
termList.removeAt(position)
notifyDataSetChanged()
}
}
viewBinding.termsRecycler.adapter = adapter
viewBinding.termsTextInput.setEndIconOnClickListener { v: View? ->
val newWord = viewBinding.termsTextInput.editText!!.text.toString().replace("\"", "").trim { it <= ' ' }
if (TextUtils.isEmpty(newWord) || termList.contains(newWord)) {
return@setEndIconOnClickListener
}
termList.add(newWord)
viewBinding.termsTextInput.editText!!.setText("")
adapter.notifyDataSetChanged()
}
}
protected abstract fun onConfirmed(filter: FeedFilter)
private fun onConfirmClick(dialog: DialogInterface, which: Int) {
var minimalDuration = -1
if (viewBinding.durationCheckBox.isChecked) {
try {
// Store minimal duration in seconds
minimalDuration = viewBinding.episodeFilterDurationText.text.toString().toInt() * 60
} catch (e: NumberFormatException) {
// Do not change anything on error
}
}
var excludeFilter = ""
var includeFilter = ""
if (viewBinding.includeRadio.isChecked) {
includeFilter = toFilterString(termList)
} else {
excludeFilter = toFilterString(termList)
}
onConfirmed(FeedFilter(includeFilter, excludeFilter, minimalDuration))
}
private fun toFilterString(words: List<String>?): String {
val result = StringBuilder()
for (word in words!!) {
result.append("\"").append(word).append("\" ")
}
return result.toString()
}
}

View File

@ -1,25 +0,0 @@
package ac.mdiq.podcini.dialog
import android.os.Bundle
import ac.mdiq.podcini.core.storage.DBWriter
import ac.mdiq.podcini.model.feed.Feed
class FeedItemFilterDialog : ItemFilterDialog() {
override fun onFilterChanged(newFilterValues: Set<String>) {
val feedId = requireArguments().getLong(ARGUMENT_FEED_ID)
DBWriter.setFeedItemsFilter(feedId, newFilterValues)
}
companion object {
private const val ARGUMENT_FEED_ID = "feedId"
fun newInstance(feed: Feed): FeedItemFilterDialog {
val dialog = FeedItemFilterDialog()
val arguments = Bundle()
arguments.putSerializable(ARGUMENT_FILTER, feed.itemFilter)
arguments.putLong(ARGUMENT_FEED_ID, feed.id)
dialog.arguments = arguments
return dialog
}
}
}

View File

@ -1,44 +0,0 @@
package ac.mdiq.podcini.dialog
import android.content.Context
import android.content.DialogInterface
import android.view.View
import android.widget.EditText
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
/**
* Displays a dialog with a username and password text field and an optional checkbox to save username and preferences.
*/
abstract class FeedPreferenceSkipDialog(context: Context?, skipIntroInitialValue: Int,
skipEndInitialValue: Int
) : MaterialAlertDialogBuilder(context!!) {
init {
setTitle(R.string.pref_feed_skip)
val rootView = View.inflate(context, R.layout.feed_pref_skip_dialog, null)
setView(rootView)
val etxtSkipIntro = rootView.findViewById<EditText>(R.id.etxtSkipIntro)
val etxtSkipEnd = rootView.findViewById<EditText>(R.id.etxtSkipEnd)
etxtSkipIntro.setText(skipIntroInitialValue.toString())
etxtSkipEnd.setText(skipEndInitialValue.toString())
setNegativeButton(R.string.cancel_label, null)
setPositiveButton(R.string.confirm_label) { dialog: DialogInterface?, which: Int ->
var skipIntro = try {
etxtSkipIntro.text.toString().toInt()
} catch (e: NumberFormatException) {
0
}
var skipEnding = try {
etxtSkipEnd.text.toString().toInt()
} catch (e: NumberFormatException) {
0
}
onConfirmed(skipIntro, skipEnding)
}
}
protected abstract fun onConfirmed(skipIntro: Int, skipEndig: Int)
}

View File

@ -1,34 +0,0 @@
package ac.mdiq.podcini.dialog
import android.content.Context
import android.content.DialogInterface
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.event.UnreadItemsUpdateEvent
import ac.mdiq.podcini.storage.preferences.UserPreferences.feedOrder
import ac.mdiq.podcini.storage.preferences.UserPreferences.setFeedOrder
import org.greenrobot.eventbus.EventBus
object FeedSortDialog {
fun showDialog(context: Context) {
val dialog = MaterialAlertDialogBuilder(context)
dialog.setTitle(context.getString(R.string.pref_nav_drawer_feed_order_title))
dialog.setNegativeButton(android.R.string.cancel) { d: DialogInterface, listener: Int -> d.dismiss() }
val selected = feedOrder
val entryValues =
listOf(*context.resources.getStringArray(R.array.nav_drawer_feed_order_values))
val selectedIndex = entryValues.indexOf("" + selected)
val items = context.resources.getStringArray(R.array.nav_drawer_feed_order_options)
dialog.setSingleChoiceItems(items, selectedIndex) { d: DialogInterface, which: Int ->
if (selectedIndex != which) {
setFeedOrder(entryValues[which])
//Update subscriptions
EventBus.getDefault().post(UnreadItemsUpdateEvent())
}
d.dismiss()
}
dialog.show()
}
}

View File

@ -1,116 +0,0 @@
package ac.mdiq.podcini.dialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButtonToggleGroup
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.feed.FeedItemFilterGroup
import ac.mdiq.podcini.databinding.FilterDialogBinding
import ac.mdiq.podcini.databinding.FilterDialogRowBinding
import ac.mdiq.podcini.model.feed.FeedItemFilter
abstract class ItemFilterDialog : BottomSheetDialogFragment() {
private var rows: LinearLayout? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val layout = inflater.inflate(R.layout.filter_dialog, null, false)
val binding = FilterDialogBinding.bind(layout)
rows = binding.filterRows
val filter = requireArguments().getSerializable(ARGUMENT_FILTER) as FeedItemFilter?
//add filter rows
for (item in FeedItemFilterGroup.entries) {
val rowBinding = FilterDialogRowBinding.inflate(inflater)
rowBinding.root.addOnButtonCheckedListener { group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean ->
onFilterChanged(newFilterValues)
}
rowBinding.filterButton1.setText(item.values[0].displayName)
rowBinding.filterButton1.tag = item.values[0].filterId
rowBinding.filterButton2.setText(item.values[1].displayName)
rowBinding.filterButton2.tag = item.values[1].filterId
rowBinding.filterButton1.maxLines = 3
rowBinding.filterButton1.isSingleLine = false
rowBinding.filterButton2.maxLines = 3
rowBinding.filterButton2.isSingleLine = false
rows!!.addView(rowBinding.root, rows!!.childCount - 1)
}
binding.confirmFiltermenu.setOnClickListener { view1: View? -> dismiss() }
binding.resetFiltermenu.setOnClickListener { view1: View? ->
onFilterChanged(emptySet())
for (i in 0 until rows!!.childCount) {
if (rows!!.getChildAt(i) is MaterialButtonToggleGroup) {
(rows!!.getChildAt(i) as MaterialButtonToggleGroup).clearChecked()
}
}
}
for (filterId in filter!!.values) {
if (!TextUtils.isEmpty(filterId)) {
val button = layout.findViewWithTag<Button>(filterId)
if (button != null) {
(button.parent as MaterialButtonToggleGroup).check(button.id)
}
}
}
return layout
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.setOnShowListener { dialogInterface: DialogInterface ->
val bottomSheetDialog = dialogInterface as BottomSheetDialog
setupFullHeight(bottomSheetDialog)
}
return dialog
}
private fun setupFullHeight(bottomSheetDialog: BottomSheetDialog) {
val bottomSheet = bottomSheetDialog.findViewById<View>(R.id.design_bottom_sheet) as FrameLayout?
if (bottomSheet != null) {
val behavior = BottomSheetBehavior.from(bottomSheet)
val layoutParams = bottomSheet.layoutParams
bottomSheet.layoutParams = layoutParams
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
protected val newFilterValues: Set<String>
get() {
val newFilterValues: MutableSet<String> = HashSet()
for (i in 0 until rows!!.childCount) {
if (rows!!.getChildAt(i) !is MaterialButtonToggleGroup) {
continue
}
val group = rows!!.getChildAt(i) as MaterialButtonToggleGroup
if (group.checkedButtonId == View.NO_ID) {
continue
}
val tag = group.findViewById<View>(group.checkedButtonId).tag as String?
?: // Clear buttons use no tag
continue
newFilterValues.add(tag)
}
return newFilterValues
}
abstract fun onFilterChanged(newFilterValues: Set<String>)
companion object {
@JvmStatic
protected val ARGUMENT_FILTER: String = "filter"
}
}

View File

@ -1,100 +0,0 @@
package ac.mdiq.podcini.dialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CompoundButton
import android.widget.FrameLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.SortDialogBinding
import ac.mdiq.podcini.databinding.SortDialogItemActiveBinding
import ac.mdiq.podcini.databinding.SortDialogItemBinding
import ac.mdiq.podcini.model.feed.SortOrder
open class ItemSortDialog : BottomSheetDialogFragment() {
protected var sortOrder: SortOrder? = null
protected var viewBinding: SortDialogBinding? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
viewBinding = SortDialogBinding.inflate(inflater)
populateList()
viewBinding!!.keepSortedCheckbox.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean -> this@ItemSortDialog.onSelectionChanged() }
return viewBinding!!.root
}
private fun populateList() {
viewBinding!!.gridLayout.removeAllViews()
onAddItem(R.string.episode_title, SortOrder.EPISODE_TITLE_A_Z, SortOrder.EPISODE_TITLE_Z_A, true)
onAddItem(R.string.feed_title, SortOrder.FEED_TITLE_A_Z, SortOrder.FEED_TITLE_Z_A, true)
onAddItem(R.string.duration, SortOrder.DURATION_SHORT_LONG, SortOrder.DURATION_LONG_SHORT, true)
onAddItem(R.string.date, SortOrder.DATE_OLD_NEW, SortOrder.DATE_NEW_OLD, false)
onAddItem(R.string.size, SortOrder.SIZE_SMALL_LARGE, SortOrder.SIZE_LARGE_SMALL, false)
onAddItem(R.string.filename, SortOrder.EPISODE_FILENAME_A_Z, SortOrder.EPISODE_FILENAME_Z_A, true)
onAddItem(R.string.random, SortOrder.RANDOM, SortOrder.RANDOM, true)
onAddItem(R.string.smart_shuffle, SortOrder.SMART_SHUFFLE_OLD_NEW, SortOrder.SMART_SHUFFLE_NEW_OLD, false)
}
protected open fun onAddItem(title: Int, ascending: SortOrder, descending: SortOrder, ascendingIsDefault: Boolean) {
if (sortOrder == ascending || sortOrder == descending) {
val item = SortDialogItemActiveBinding.inflate(
layoutInflater, viewBinding!!.gridLayout, false)
val other: SortOrder
if (ascending == descending) {
item.button.setText(title)
other = ascending
} else if (sortOrder == ascending) {
item.button.text = getString(title) + "\u00A0"
other = descending
} else {
item.button.text = getString(title) + "\u00A0"
other = ascending
}
item.button.setOnClickListener { v: View? ->
sortOrder = other
populateList()
onSelectionChanged()
}
viewBinding!!.gridLayout.addView(item.root)
} else {
val item = SortDialogItemBinding.inflate(
layoutInflater, viewBinding!!.gridLayout, false)
item.button.setText(title)
item.button.setOnClickListener { v: View? ->
sortOrder = if (ascendingIsDefault) ascending else descending
populateList()
onSelectionChanged()
}
viewBinding!!.gridLayout.addView(item.root)
}
}
protected open fun onSelectionChanged() {
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.setOnShowListener { dialogInterface: DialogInterface ->
val bottomSheetDialog = dialogInterface as BottomSheetDialog
setupFullHeight(bottomSheetDialog)
}
return dialog
}
private fun setupFullHeight(bottomSheetDialog: BottomSheetDialog) {
val bottomSheet = bottomSheetDialog.findViewById<FrameLayout>(R.id.design_bottom_sheet)
if (bottomSheet != null) {
val behavior = BottomSheetBehavior.from(bottomSheet)
val layoutParams = bottomSheet.layoutParams
bottomSheet.layoutParams = layoutParams
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
}

View File

@ -1,37 +0,0 @@
package ac.mdiq.podcini.dialog
import ac.mdiq.podcini.activity.MainActivity
import android.app.Activity
import android.content.DialogInterface
import android.text.Spannable
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.event.PlayerErrorEvent
object MediaPlayerErrorDialog {
fun show(activity: Activity, event: PlayerErrorEvent) {
val errorDialog = MaterialAlertDialogBuilder(activity)
errorDialog.setTitle(R.string.error_label)
val genericMessage: String = activity.getString(R.string.playback_error_generic)
val errorMessage = SpannableString("""
$genericMessage
${event.message}
""".trimIndent())
errorMessage.setSpan(ForegroundColorSpan(-0x77777778),
genericMessage.length, errorMessage.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
errorDialog.setMessage(errorMessage)
errorDialog.setPositiveButton("OK"
) { dialog: DialogInterface?, which: Int ->
if (activity is MainActivity) {
activity.bottomSheet?.state = BottomSheetBehavior.STATE_COLLAPSED
}
}
errorDialog.create().show()
}
}

View File

@ -1,69 +0,0 @@
package ac.mdiq.podcini.dialog
import android.app.Dialog
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.Button
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.media3.common.util.UnstableApi
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.util.playback.PlaybackController
class PlaybackControlsDialog : DialogFragment() {
private var controller: PlaybackController? = null
private var dialog: AlertDialog? = null
@UnstableApi override fun onStart() {
super.onStart()
controller = object : PlaybackController(requireActivity()) {
override fun loadMediaInfo() {
setupAudioTracks()
}
}
controller?.init()
}
@UnstableApi override fun onStop() {
super.onStop()
controller?.release()
controller = null
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.audio_controls)
.setView(R.layout.audio_controls)
.setPositiveButton(R.string.close_label, null).create()
return dialog!!
}
@UnstableApi private fun setupAudioTracks() {
val audioTracks = controller!!.audioTracks
val selectedAudioTrack = controller!!.selectedAudioTrack
val butAudioTracks = dialog!!.findViewById<Button>(R.id.audio_tracks)
if (audioTracks.size < 2 || selectedAudioTrack < 0) {
butAudioTracks!!.visibility = View.GONE
return
}
butAudioTracks!!.visibility = View.VISIBLE
butAudioTracks.text = audioTracks[selectedAudioTrack]
butAudioTracks.setOnClickListener { v: View? ->
controller!!.setAudioTrack((selectedAudioTrack + 1) % audioTracks.size)
Handler(Looper.getMainLooper()).postDelayed({ this.setupAudioTracks() }, 500)
}
}
companion object {
fun newInstance(): PlaybackControlsDialog {
val arguments = Bundle()
val dialog = PlaybackControlsDialog()
dialog.arguments = arguments
return dialog
}
}
}

View File

@ -1,303 +0,0 @@
package ac.mdiq.podcini.dialog
import android.app.Dialog
import android.content.Context
import android.os.Build
import android.text.Editable
import android.text.TextUtils
import android.text.TextWatcher
import android.util.Patterns
import android.view.View
import android.widget.*
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.service.download.PodciniHttpClient.newBuilder
import ac.mdiq.podcini.core.service.download.PodciniHttpClient.reinit
import ac.mdiq.podcini.core.service.download.PodciniHttpClient.setProxyConfig
import ac.mdiq.podcini.model.download.ProxyConfig
import ac.mdiq.podcini.storage.preferences.UserPreferences.proxyConfig
import ac.mdiq.podcini.ui.common.ThemeUtils.getColorFromAttr
import io.reactivex.Completable
import io.reactivex.CompletableEmitter
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import okhttp3.*
import okhttp3.Credentials.basic
import okhttp3.OkHttpClient
import okhttp3.Request.Builder
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.SocketAddress
import java.util.concurrent.TimeUnit
class ProxyDialog(private val context: Context) {
private var dialog: AlertDialog? = null
private var spType: Spinner? = null
private var etHost: EditText? = null
private var etPort: EditText? = null
private var etUsername: EditText? = null
private var etPassword: EditText? = null
private var testSuccessful = false
private var txtvMessage: TextView? = null
private var disposable: Disposable? = null
fun show(): Dialog? {
val content = View.inflate(context, R.layout.proxy_settings, null)
spType = content.findViewById(R.id.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 { view: View? ->
if (!testSuccessful) {
dialog!!.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
test()
return@setOnClickListener
}
setProxyConfig()
reinit()
dialog?.dismiss()
}
dialog?.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { view: View? ->
etHost!!.text.clear()
etPort!!.text.clear()
etUsername!!.text.clear()
etPassword!!.text.clear()
setProxyConfig()
}
val types: MutableList<String> = ArrayList()
types.add(Proxy.Type.DIRECT.name)
types.add(Proxy.Type.HTTP.name)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
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 = content.findViewById(R.id.etHost)
if (!TextUtils.isEmpty(proxyConfig.host)) {
etHost?.setText(proxyConfig.host)
}
etHost?.addTextChangedListener(requireTestOnChange)
etPort = content.findViewById(R.id.etPort)
if (proxyConfig.port > 0) {
etPort?.setText(proxyConfig.port.toString())
}
etPort?.addTextChangedListener(requireTestOnChange)
etUsername = content.findViewById(R.id.etUsername)
if (!TextUtils.isEmpty(proxyConfig.username)) {
etUsername?.setText(proxyConfig.username)
}
etUsername?.addTextChangedListener(requireTestOnChange)
etPassword = content.findViewById(R.id.etPassword)
if (!TextUtils.isEmpty(proxyConfig.password)) {
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) {
if (position == 0) {
dialog!!.getButton(AlertDialog.BUTTON_NEUTRAL).visibility = View.GONE
} else {
dialog!!.getButton(AlertDialog.BUTTON_NEUTRAL).visibility = View.VISIBLE
}
enableSettings(position > 0)
setTestRequired(position > 0)
}
override fun onNothingSelected(parent: AdapterView<*>?) {
enableSettings(false)
}
}
txtvMessage = content.findViewById(R.id.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 (TextUtils.isEmpty(username)) {
username = null
}
var password: String? = etPassword!!.text.toString()
if (TextUtils.isEmpty(password)) {
password = null
}
var portValue = 0
if (!TextUtils.isEmpty(port)) {
portValue = port.toInt()
}
val config = ProxyConfig(typeEnum, host, portValue, username, password)
proxyConfig = config
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 val port: Int
get() {
val port = etPort!!.text.toString()
if (port.isNotEmpty()) {
try {
return port.toInt()
} catch (e: NumberFormatException) {
// ignore
}
}
return 0
}
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 (disposable != null) {
disposable!!.dispose()
}
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 = "{fa-circle-o-notch spin} $checking"
txtvMessage!!.visibility = View.VISIBLE
disposable = Completable.create { emitter: CompletableEmitter ->
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 (!TextUtils.isEmpty(port)) {
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 (!TextUtils.isEmpty(username)) {
builder.proxyAuthenticator { route: 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) {
emitter.onComplete()
} else {
emitter.onError(IOException(response.message))
}
}
} catch (e: IOException) {
emitter.onError(e)
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
txtvMessage!!.setTextColor(getColorFromAttr(context, R.attr.icon_green))
val message = String.format("%s %s", "{fa-check}",
context.getString(R.string.proxy_test_successful))
txtvMessage!!.text = message
setTestRequired(false)
},
{ error: Throwable ->
error.printStackTrace()
txtvMessage!!.setTextColor(getColorFromAttr(context, R.attr.icon_red))
val message = String.format("%s %s: %s", "{fa-close}",
context.getString(R.string.proxy_test_failed), error.message)
txtvMessage!!.text = message
setTestRequired(true)
}
)
}
}

View File

@ -1,75 +0,0 @@
package ac.mdiq.podcini.dialog
import android.app.ProgressDialog
import android.content.Context
import android.content.DialogInterface
import android.util.Log
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.dialog.ConfirmationDialog
import ac.mdiq.podcini.core.storage.DBWriter
import ac.mdiq.podcini.model.feed.Feed
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import io.reactivex.Completable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
object RemoveFeedDialog {
private const val TAG = "RemoveFeedDialog"
fun show(context: Context, feed: Feed, callback: Runnable?) {
val feeds = listOf(feed)
val message = getMessageId(context, feeds)
showDialog(context, feeds, message, callback)
}
fun show(context: Context, feeds: List<Feed>) {
val message = getMessageId(context, feeds)
showDialog(context, feeds, message, null)
}
private fun showDialog(context: Context, feeds: List<Feed>, message: String, callback: Runnable?) {
val dialog: ConfirmationDialog = object : ConfirmationDialog(context, R.string.remove_feed_label, message) {
@OptIn(UnstableApi::class) override fun onConfirmButtonPressed(clickedDialog: DialogInterface) {
callback?.run()
clickedDialog.dismiss()
val progressDialog = ProgressDialog(context)
progressDialog.setMessage(context.getString(R.string.feed_remover_msg))
progressDialog.isIndeterminate = true
progressDialog.setCancelable(false)
progressDialog.show()
Completable.fromAction {
for (feed in feeds) {
DBWriter.deleteFeed(context, feed.id).get()
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
Log.d(TAG, "Feed(s) deleted")
progressDialog.dismiss()
}, { error: Throwable? ->
Log.e(TAG, Log.getStackTraceString(error))
progressDialog.dismiss()
})
}
}
dialog.createNewDialog().show()
}
private fun getMessageId(context: Context, feeds: List<Feed>): String {
return if (feeds.size == 1) {
if (feeds[0].isLocalFeed) {
context.getString(R.string.feed_delete_confirmation_local_msg) + feeds[0].title
} else {
context.getString(R.string.feed_delete_confirmation_msg) + feeds[0].title
}
} else {
context.getString(R.string.feed_delete_confirmation_msg_batch)
}
}
}

View File

@ -1,74 +0,0 @@
package ac.mdiq.podcini.dialog
import android.app.Activity
import android.content.DialogInterface
import android.view.LayoutInflater
import android.view.View
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.storage.DBWriter
import ac.mdiq.podcini.core.storage.NavDrawerData.*
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.model.feed.Feed
import ac.mdiq.podcini.model.feed.FeedPreferences
import java.lang.ref.WeakReference
class RenameItemDialog {
private val activityRef: WeakReference<Activity>
private var feed: Feed? = null
private var drawerItem: DrawerItem? = null
constructor(activity: Activity, feed: Feed?) {
this.activityRef = WeakReference(activity)
this.feed = feed
}
constructor(activity: Activity, drawerItem: DrawerItem?) {
this.activityRef = WeakReference(activity)
this.drawerItem = drawerItem
}
fun show() {
val activity = activityRef.get() ?: return
val binding = EditTextDialogBinding.inflate(LayoutInflater.from(activity))
val title = if (feed != null) feed!!.title else drawerItem!!.title
binding.urlEditText.setText(title)
val dialog = MaterialAlertDialogBuilder(activity)
.setView(binding.root)
.setTitle(if (feed != null) R.string.rename_feed_label else R.string.rename_tag_label)
.setPositiveButton(android.R.string.ok) { d: DialogInterface?, input: Int ->
val newTitle = binding.urlEditText.text.toString()
if (feed != null) {
feed!!.setCustomTitle(newTitle)
DBWriter.setFeedCustomTitle(feed!!)
} else {
renameTag(newTitle)
}
}
.setNeutralButton(ac.mdiq.podcini.core.R.string.reset, null)
.setNegativeButton(ac.mdiq.podcini.core.R.string.cancel_label, null)
.show()
// To prevent cancelling the dialog on button click
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)
.setOnClickListener { view: View? -> binding.urlEditText.setText(title) }
}
private fun renameTag(title: String) {
if (DrawerItem.Type.TAG == drawerItem!!.type) {
val feedPreferences: MutableList<FeedPreferences?> = ArrayList()
for (item in (drawerItem as TagDrawerItem?)!!.children) {
feedPreferences.add((item as FeedDrawerItem).feed.preferences)
}
for (preferences in feedPreferences) {
preferences!!.getTags().remove(drawerItem!!.title)
preferences.getTags().add(title)
DBWriter.setFeedPreferences(preferences)
}
}
}
}

View File

@ -1,99 +0,0 @@
package ac.mdiq.podcini.dialog
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.RadioGroup
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import ac.mdiq.podcini.core.util.ShareUtils.shareFeedItemFile
import ac.mdiq.podcini.core.util.ShareUtils.shareFeedItemLinkWithDownloadLink
import ac.mdiq.podcini.core.util.ShareUtils.shareMediaDownloadLink
import ac.mdiq.podcini.databinding.ShareEpisodeDialogBinding
import ac.mdiq.podcini.model.feed.FeedItem
class ShareDialog : BottomSheetDialogFragment() {
private var ctx: Context? = null
private var item: FeedItem? = null
private var prefs: SharedPreferences? = null
private var viewBinding: ShareEpisodeDialogBinding? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
if (arguments != null) {
ctx = activity
item = requireArguments().getSerializable(ARGUMENT_FEED_ITEM) as FeedItem?
prefs = requireActivity().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
}
viewBinding = ShareEpisodeDialogBinding.inflate(inflater)
viewBinding!!.shareDialogRadioGroup.setOnCheckedChangeListener { group: RadioGroup?, checkedId: Int ->
viewBinding!!.sharePositionCheckbox.isEnabled = checkedId == viewBinding!!.shareSocialRadio.id
}
setupOptions()
viewBinding!!.shareButton.setOnClickListener { v: View? ->
val includePlaybackPosition = viewBinding!!.sharePositionCheckbox.isChecked
val position: Int
if (viewBinding!!.shareSocialRadio.isChecked) {
shareFeedItemLinkWithDownloadLink(ctx!!, item!!, includePlaybackPosition)
position = 1
} else if (viewBinding!!.shareMediaReceiverRadio.isChecked) {
shareMediaDownloadLink(ctx!!, item!!.media!!)
position = 2
} else if (viewBinding!!.shareMediaFileRadio.isChecked) {
shareFeedItemFile(ctx!!, item!!.media!!)
position = 3
} else {
throw IllegalStateException("Unknown share method")
}
prefs!!.edit()
.putBoolean(PREF_SHARE_EPISODE_START_AT, includePlaybackPosition)
.putInt(PREF_SHARE_EPISODE_TYPE, position)
.apply()
dismiss()
}
return viewBinding!!.root
}
private fun setupOptions() {
val hasMedia = item!!.media != null
val downloaded = hasMedia && item!!.media!!.isDownloaded()
viewBinding!!.shareMediaFileRadio.visibility = if (downloaded) View.VISIBLE else View.GONE
val hasDownloadUrl = hasMedia && item!!.media!!.download_url != null
if (!hasDownloadUrl) {
viewBinding!!.shareMediaReceiverRadio.visibility = View.GONE
}
var type = prefs!!.getInt(PREF_SHARE_EPISODE_TYPE, 1)
if ((type == 2 && !hasDownloadUrl) || (type == 3 && !downloaded)) {
type = 1
}
viewBinding!!.shareSocialRadio.isChecked = type == 1
viewBinding!!.shareMediaReceiverRadio.isChecked = type == 2
viewBinding!!.shareMediaFileRadio.isChecked = type == 3
val switchIsChecked = prefs!!.getBoolean(PREF_SHARE_EPISODE_START_AT, false)
viewBinding!!.sharePositionCheckbox.isChecked = switchIsChecked
}
companion object {
private const val ARGUMENT_FEED_ITEM = "feedItem"
private const val PREF_NAME = "ShareDialog"
private const val PREF_SHARE_EPISODE_START_AT = "prefShareEpisodeStartAt"
private const val PREF_SHARE_EPISODE_TYPE = "prefShareEpisodeType"
fun newInstance(item: FeedItem?): ShareDialog {
val arguments = Bundle()
arguments.putSerializable(ARGUMENT_FEED_ITEM, item)
val dialog = ShareDialog()
dialog.arguments = arguments
return dialog
}
}
}

View File

@ -1,62 +0,0 @@
package ac.mdiq.podcini.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.storage.preferences.UserPreferences.fastForwardSecs
import ac.mdiq.podcini.storage.preferences.UserPreferences.rewindSecs
import java.text.NumberFormat
import java.util.*
/**
* Shows the dialog that allows setting the skip time.
*/
object SkipPreferenceDialog {
fun showSkipPreference(context: Context, direction: SkipDirection, textView: TextView?) {
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<String>(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, which: 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
}
}

View File

@ -1,208 +0,0 @@
package ac.mdiq.podcini.dialog
import android.app.Activity
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.text.format.DateFormat
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.*
import androidx.fragment.app.DialogFragment
import androidx.media3.common.util.UnstableApi
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.autoEnable
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.autoEnableFrom
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.autoEnableTo
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.lastTimerValue
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.setAutoEnable
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.setAutoEnableFrom
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.setAutoEnableTo
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.setLastTimer
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.setShakeToReset
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.setVibrate
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.shakeToReset
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.timerMillis
import ac.mdiq.podcini.core.preferences.SleepTimerPreferences.vibrate
import ac.mdiq.podcini.core.service.playback.PlaybackService
import ac.mdiq.podcini.core.util.Converter.getDurationStringLong
import ac.mdiq.podcini.core.util.playback.PlaybackController
import ac.mdiq.podcini.event.playback.SleepTimerUpdatedEvent
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.util.*
class SleepTimerDialog : DialogFragment() {
private var controller: PlaybackController? = null
private var etxtTime: EditText? = null
private var timeSetup: LinearLayout? = null
private var timeDisplay: LinearLayout? = null
private var time: TextView? = null
private var chAutoEnable: CheckBox? = null
@UnstableApi override fun onStart() {
super.onStart()
controller = object : PlaybackController(requireActivity()) {
override fun loadMediaInfo() {
}
}
controller?.init()
EventBus.getDefault().register(this)
}
@UnstableApi override fun onStop() {
super.onStop()
controller?.release()
EventBus.getDefault().unregister(this)
}
@UnstableApi override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val content = View.inflate(context, R.layout.time_dialog, null)
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle(R.string.sleep_timer_label)
builder.setView(content)
builder.setPositiveButton(R.string.close_label, null)
etxtTime = content.findViewById(R.id.etxtTime)
timeSetup = content.findViewById(R.id.timeSetup)
timeDisplay = content.findViewById(R.id.timeDisplay)
timeDisplay?.visibility = View.GONE
time = content.findViewById(R.id.time)
val extendSleepFiveMinutesButton = content.findViewById<Button>(R.id.extendSleepFiveMinutesButton)
extendSleepFiveMinutesButton.text = getString(R.string.extend_sleep_timer_label, 5)
val extendSleepTenMinutesButton = content.findViewById<Button>(R.id.extendSleepTenMinutesButton)
extendSleepTenMinutesButton.text = getString(R.string.extend_sleep_timer_label, 10)
val extendSleepTwentyMinutesButton = content.findViewById<Button>(R.id.extendSleepTwentyMinutesButton)
extendSleepTwentyMinutesButton.text = getString(R.string.extend_sleep_timer_label, 20)
extendSleepFiveMinutesButton.setOnClickListener { v: View? ->
if (controller != null) {
controller!!.extendSleepTimer((5 * 1000 * 60).toLong())
}
}
extendSleepTenMinutesButton.setOnClickListener { v: View? ->
if (controller != null) {
controller!!.extendSleepTimer((10 * 1000 * 60).toLong())
}
}
extendSleepTwentyMinutesButton.setOnClickListener { v: View? ->
if (controller != null) {
controller!!.extendSleepTimer((20 * 1000 * 60).toLong())
}
}
etxtTime?.setText(lastTimerValue())
etxtTime?.postDelayed({
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(etxtTime, InputMethodManager.SHOW_IMPLICIT)
}, 100)
val cbShakeToReset = content.findViewById<CheckBox>(R.id.cbShakeToReset)
val cbVibrate = content.findViewById<CheckBox>(R.id.cbVibrate)
chAutoEnable = content.findViewById(R.id.chAutoEnable)
val changeTimesButton = content.findViewById<ImageView>(R.id.changeTimesButton)
cbShakeToReset.isChecked = shakeToReset()
cbVibrate.isChecked = vibrate()
chAutoEnable?.setChecked(autoEnable())
if (chAutoEnable != null) {
changeTimesButton.isEnabled = chAutoEnable!!.isChecked
changeTimesButton.alpha = if (chAutoEnable!!.isChecked) 1.0f else 0.5f
}
cbShakeToReset.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean ->
setShakeToReset(isChecked)
}
cbVibrate.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean -> setVibrate(isChecked) }
chAutoEnable?.setOnCheckedChangeListener { compoundButton: CompoundButton?, isChecked: Boolean ->
setAutoEnable(isChecked)
changeTimesButton.isEnabled = isChecked
changeTimesButton.alpha = if (isChecked) 1.0f else 0.5f
}
updateAutoEnableText()
changeTimesButton.setOnClickListener { changeTimesBtn: View? ->
val from = autoEnableFrom()
val to = autoEnableTo()
showTimeRangeDialog(context, from, to)
}
val disableButton = content.findViewById<Button>(R.id.disableSleeptimerButton)
disableButton.setOnClickListener { v: View? ->
if (controller != null) {
controller!!.disableSleepTimer()
}
}
val setButton = content.findViewById<Button>(R.id.setSleeptimerButton)
setButton.setOnClickListener { v: View? ->
if (!PlaybackService.isRunning) {
Snackbar.make(content, R.string.no_media_playing_label, Snackbar.LENGTH_LONG).show()
return@setOnClickListener
}
try {
if (etxtTime != null) {
val time = etxtTime!!.getText().toString().toLong()
if (time == 0L) {
throw NumberFormatException("Timer must not be zero")
}
setLastTimer(etxtTime!!.getText().toString())
}
if (controller != null) {
controller!!.setSleepTimer(timerMillis())
}
closeKeyboard(content)
} catch (e: NumberFormatException) {
e.printStackTrace()
Snackbar.make(content, R.string.time_dialog_invalid_input, Snackbar.LENGTH_LONG).show()
}
}
return builder.create()
}
private fun showTimeRangeDialog(context: Context?, from: Int, to: Int) {
val dialog = TimeRangeDialog(requireContext(), from, to)
dialog.setOnDismissListener { v: DialogInterface? ->
setAutoEnableFrom(dialog.from)
setAutoEnableTo(dialog.to)
updateAutoEnableText()
}
dialog.show()
}
private fun updateAutoEnableText() {
val text: String
val from = autoEnableFrom()
val to = autoEnableTo()
if (from == to) {
text = getString(R.string.auto_enable_label)
} else if (DateFormat.is24HourFormat(context)) {
val formattedFrom = String.format(Locale.getDefault(), "%02d:00", from)
val formattedTo = String.format(Locale.getDefault(), "%02d:00", to)
text = getString(R.string.auto_enable_label_with_times, formattedFrom, formattedTo)
} else {
val formattedFrom = String.format(Locale.getDefault(), "%02d:00 %s",
from % 12, if (from >= 12) "PM" else "AM")
val formattedTo = String.format(Locale.getDefault(), "%02d:00 %s",
to % 12, if (to >= 12) "PM" else "AM")
text = getString(R.string.auto_enable_label_with_times, formattedFrom, formattedTo)
}
chAutoEnable!!.text = text
}
@Subscribe(threadMode = ThreadMode.MAIN)
@Suppress("unused")
fun timerUpdated(event: SleepTimerUpdatedEvent) {
timeDisplay!!.visibility = if (event.isOver || event.isCancelled) View.GONE else View.VISIBLE
timeSetup!!.visibility =
if (event.isOver || event.isCancelled) View.VISIBLE else View.GONE
time!!.text = getDurationStringLong(event.getTimeLeft().toInt())
}
private fun closeKeyboard(content: View) {
val imm = requireContext().getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(content.windowToken, 0)
}
}

View File

@ -1,34 +0,0 @@
package ac.mdiq.podcini.dialog
import android.content.Context
import android.content.DialogInterface
import androidx.media3.common.util.UnstableApi
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.util.playback.PlaybackServiceStarter
import ac.mdiq.podcini.model.playback.Playable
import ac.mdiq.podcini.storage.preferences.UserPreferences.isAllowMobileStreaming
class StreamingConfirmationDialog(private val context: Context, private val playable: Playable) {
@UnstableApi
fun show() {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.stream_label)
.setMessage(R.string.confirm_mobile_streaming_notification_message)
.setPositiveButton(R.string.confirm_mobile_streaming_button_once) { dialog: DialogInterface?, which: Int -> stream() }
.setNegativeButton(R.string.confirm_mobile_streaming_button_always) { dialog: DialogInterface?, which: Int ->
isAllowMobileStreaming = true
stream()
}
.setNeutralButton(R.string.cancel_label, null)
.show()
}
@UnstableApi
private fun stream() {
PlaybackServiceStarter(context, playable)
.callEvenIfRunning(true)
.shouldStreamThisTime(true)
.start()
}
}

View File

@ -1,130 +0,0 @@
package ac.mdiq.podcini.dialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButtonToggleGroup
import ac.mdiq.podcini.R
import ac.mdiq.podcini.core.feed.SubscriptionsFilterGroup
import ac.mdiq.podcini.databinding.FilterDialogBinding
import ac.mdiq.podcini.databinding.FilterDialogRowBinding
import ac.mdiq.podcini.event.UnreadItemsUpdateEvent
import ac.mdiq.podcini.model.feed.SubscriptionsFilter
import ac.mdiq.podcini.storage.preferences.UserPreferences
import ac.mdiq.podcini.storage.preferences.UserPreferences.subscriptionsFilter
import org.greenrobot.eventbus.EventBus
import java.util.*
class SubscriptionsFilterDialog : BottomSheetDialogFragment() {
private var rows: LinearLayout? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val subscriptionsFilter = subscriptionsFilter
val dialogBinding = FilterDialogBinding.inflate(inflater)
rows = dialogBinding.filterRows
for (item in SubscriptionsFilterGroup.entries) {
val binding = FilterDialogRowBinding.inflate(inflater)
binding.root.addOnButtonCheckedListener { group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean ->
updateFilter(
filterValues)
}
binding.buttonGroup.weightSum = item.values.size.toFloat()
binding.filterButton1.setText(item.values[0].displayName)
binding.filterButton1.tag = item.values[0].filterId
if (item.values.size == 2) {
binding.filterButton2.setText(item.values[1].displayName)
binding.filterButton2.tag = item.values[1].filterId
} else {
binding.filterButton2.visibility = View.GONE
}
binding.filterButton1.maxLines = 3
binding.filterButton1.isSingleLine = false
binding.filterButton2.maxLines = 3
binding.filterButton2.isSingleLine = false
rows!!.addView(binding.root, rows!!.childCount - 1)
}
val filterValues: Set<String> = HashSet(listOf(*subscriptionsFilter.values))
for (filterId in filterValues) {
if (!TextUtils.isEmpty(filterId)) {
val button = dialogBinding.root.findViewWithTag<Button>(filterId)
if (button != null) {
(button.parent as MaterialButtonToggleGroup).check(button.id)
}
}
}
dialogBinding.confirmFiltermenu.setOnClickListener { view: View? ->
updateFilter(this.filterValues)
dismiss()
}
dialogBinding.resetFiltermenu.setOnClickListener { view: View? ->
updateFilter(emptySet())
for (i in 0 until rows!!.childCount) {
if (rows!!.getChildAt(i) is MaterialButtonToggleGroup) {
(rows!!.getChildAt(i) as MaterialButtonToggleGroup).clearChecked()
}
}
}
return dialogBinding.root
}
private val filterValues: Set<String>
get() {
val filterValues: MutableSet<String> = HashSet()
for (i in 0 until rows!!.childCount) {
if (rows!!.getChildAt(i) !is MaterialButtonToggleGroup) {
continue
}
val group = rows!!.getChildAt(i) as MaterialButtonToggleGroup
if (group.checkedButtonId == View.NO_ID) {
continue
}
val tag = group.findViewById<View>(group.checkedButtonId).tag as String?
?: // Clear buttons use no tag
continue
filterValues.add(tag)
}
return filterValues
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.setOnShowListener { dialogInterface: DialogInterface ->
val bottomSheetDialog = dialogInterface as BottomSheetDialog
setupFullHeight(bottomSheetDialog)
}
return dialog
}
private fun setupFullHeight(bottomSheetDialog: BottomSheetDialog) {
val bottomSheet = bottomSheetDialog.findViewById<FrameLayout>(R.id.design_bottom_sheet)
if (bottomSheet != null) {
val behavior = BottomSheetBehavior.from(bottomSheet)
val layoutParams = bottomSheet.layoutParams
bottomSheet.layoutParams = layoutParams
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
companion object {
private fun updateFilter(filterValues: Set<String>) {
val subscriptionsFilter = SubscriptionsFilter(filterValues.toTypedArray<String>())
UserPreferences.subscriptionsFilter = subscriptionsFilter
EventBus.getDefault().post(UnreadItemsUpdateEvent())
}
}
}

View File

@ -1,203 +0,0 @@
package ac.mdiq.podcini.dialog
import android.content.Context
import android.content.DialogInterface
import android.graphics.PorterDuff
import android.view.LayoutInflater
import android.view.View
import android.widget.CompoundButton
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.graphics.drawable.DrawableCompat
import androidx.gridlayout.widget.GridLayout
import com.annimon.stream.Stream
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.*
import ac.mdiq.podcini.fragment.*
import ac.mdiq.podcini.fragment.swipeactions.SwipeAction
import ac.mdiq.podcini.fragment.swipeactions.SwipeActions
import ac.mdiq.podcini.fragment.swipeactions.SwipeActions.Companion.getPrefsWithDefaults
import ac.mdiq.podcini.fragment.swipeactions.SwipeActions.Companion.isSwipeActionEnabled
import ac.mdiq.podcini.ui.common.ThemeUtils.getColorFromAttr
class SwipeActionsDialog(private val context: Context, private val tag: String) {
private var rightAction: SwipeAction? = null
private var leftAction: SwipeAction? = null
private var keys: List<SwipeAction>? = null
fun show(prefsChanged: Callback) {
val actions = getPrefsWithDefaults(
context, tag)
leftAction = actions.left
rightAction = actions.right
val builder = MaterialAlertDialogBuilder(context)
keys = SwipeActions.swipeActions
var forFragment = ""
when (tag) {
InboxFragment.TAG -> {
forFragment = context.getString(R.string.inbox_label)
keys = Stream.of(keys!!).filter { a: SwipeAction ->
(!a.getId().equals(SwipeAction.TOGGLE_PLAYED)
&& !a.getId().equals(SwipeAction.DELETE)
&& !a.getId().equals(SwipeAction.REMOVE_FROM_HISTORY))
}.toList()
}
AllEpisodesFragment.TAG -> {
forFragment = context.getString(R.string.episodes_label)
keys = Stream.of(keys!!).filter { a: SwipeAction -> !a.getId().equals(SwipeAction.REMOVE_FROM_HISTORY) }
.toList()
}
CompletedDownloadsFragment.TAG -> {
forFragment = context.getString(R.string.downloads_label)
keys = Stream.of(keys!!).filter { a: SwipeAction ->
(!a.getId().equals(SwipeAction.REMOVE_FROM_INBOX)
&& !a.getId().equals(SwipeAction.REMOVE_FROM_HISTORY)
&& !a.getId().equals(SwipeAction.START_DOWNLOAD))
}.toList()
}
FeedItemlistFragment.TAG -> {
forFragment = context.getString(R.string.individual_subscription)
keys = Stream.of(keys!!).filter { a: SwipeAction -> !a.getId().equals(SwipeAction.REMOVE_FROM_HISTORY) }
.toList()
}
QueueFragment.TAG -> {
forFragment = context.getString(R.string.queue_label)
keys = Stream.of(keys!!).filter { a: SwipeAction ->
(!a.getId().equals(SwipeAction.ADD_TO_QUEUE)
&& !a.getId().equals(SwipeAction.REMOVE_FROM_INBOX)
&& !a.getId().equals(SwipeAction.REMOVE_FROM_HISTORY))
}.toList()
}
PlaybackHistoryFragment.TAG -> {
forFragment = context.getString(R.string.playback_history_label)
keys = Stream.of(keys!!).filter { a: SwipeAction -> !a.getId().equals(SwipeAction.REMOVE_FROM_INBOX) }
.toList()
}
else -> {}
}
if (tag != QueueFragment.TAG) {
keys = Stream.of(keys!!).filter { a: SwipeAction -> !a.getId().equals(SwipeAction.REMOVE_FROM_QUEUE) }
.toList()
}
builder.setTitle(context.getString(R.string.swipeactions_label) + " - " + forFragment)
val viewBinding = SwipeactionsDialogBinding.inflate(LayoutInflater.from(
context))
builder.setView(viewBinding.root)
viewBinding.enableSwitch.setOnCheckedChangeListener { compoundButton: CompoundButton?, b: Boolean ->
viewBinding.actionLeftContainer.root.alpha = if (b) 1.0f else 0.4f
viewBinding.actionRightContainer.root.alpha = if (b) 1.0f else 0.4f
}
viewBinding.enableSwitch.isChecked = isSwipeActionEnabled(context, tag)
setupSwipeDirectionView(viewBinding.actionLeftContainer, LEFT)
setupSwipeDirectionView(viewBinding.actionRightContainer, RIGHT)
builder.setPositiveButton(R.string.confirm_label) { dialog: DialogInterface?, which: Int ->
savePrefs(tag, rightAction!!.getId(), leftAction!!.getId())
saveActionsEnabledPrefs(viewBinding.enableSwitch.isChecked)
prefsChanged.onCall()
}
builder.setNegativeButton(R.string.cancel_label, null)
builder.create().show()
}
private fun setupSwipeDirectionView(view: SwipeactionsRowBinding, direction: Int) {
val action = if (direction == LEFT) leftAction else rightAction
view.swipeDirectionLabel.setText(if (direction == LEFT) R.string.swipe_left else R.string.swipe_right)
view.swipeActionLabel.text = action!!.getTitle(context)
populateMockEpisode(view.mockEpisode)
if (direction == RIGHT && view.previewContainer.getChildAt(0) !== view.swipeIcon) {
view.previewContainer.removeView(view.swipeIcon)
view.previewContainer.addView(view.swipeIcon, 0)
}
view.swipeIcon.setImageResource(action.getActionColor())
view.swipeIcon.setColorFilter(getColorFromAttr(context, action.getActionColor()))
view.changeButton.setOnClickListener { v: View? -> showPicker(view, direction) }
view.previewContainer.setOnClickListener { v: View? -> showPicker(view, direction) }
}
private fun showPicker(view: SwipeactionsRowBinding, direction: Int) {
val builder = MaterialAlertDialogBuilder(context)
builder.setTitle(if (direction == LEFT) R.string.swipe_left else R.string.swipe_right)
val picker = SwipeactionsPickerBinding.inflate(LayoutInflater.from(
context))
builder.setView(picker.root)
builder.setNegativeButton(R.string.cancel_label, null)
val dialog = builder.show()
for (i in keys!!.indices) {
val action = keys!![i]
val item = SwipeactionsPickerItemBinding.inflate(LayoutInflater.from(
context))
item.swipeActionLabel.text = action.getTitle(context)
val icon = DrawableCompat.wrap(AppCompatResources.getDrawable(
context, action.getActionColor())!!)
icon.mutate()
icon.setTintMode(PorterDuff.Mode.SRC_ATOP)
if ((direction == LEFT && leftAction === action) || (direction == RIGHT && rightAction === action)) {
icon.setTint(getColorFromAttr(context, action.getActionColor()))
item.swipeActionLabel.setTextColor(getColorFromAttr(context, action.getActionColor()))
} else {
icon.setTint(getColorFromAttr(context, R.attr.action_icon_color))
}
item.swipeIcon.setImageDrawable(icon)
item.root.setOnClickListener { v: View? ->
if (direction == LEFT) {
leftAction = keys!![i]
} else {
rightAction = keys!![i]
}
setupSwipeDirectionView(view, direction)
dialog.dismiss()
}
val param = GridLayout.LayoutParams(
GridLayout.spec(GridLayout.UNDEFINED, GridLayout.BASELINE),
GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f))
param.width = 0
picker.pickerGridLayout.addView(item.root, param)
}
picker.pickerGridLayout.columnCount = 2
picker.pickerGridLayout.rowCount = (keys!!.size + 1) / 2
}
private fun populateMockEpisode(view: FeeditemlistItemBinding) {
view.container.alpha = 0.3f
view.secondaryActionButton.secondaryActionButton.visibility = View.GONE
view.dragHandle.visibility = View.GONE
view.statusInbox.visibility = View.GONE
view.txtvTitle.text = "███████"
view.txtvPosition.text = "█████"
}
private fun savePrefs(tag: String, right: String?, left: String?) {
val prefs = context.getSharedPreferences(SwipeActions.PREF_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(SwipeActions.KEY_PREFIX_SWIPEACTIONS + tag, "$right,$left").apply()
}
private fun saveActionsEnabledPrefs(enabled: Boolean) {
val prefs = context.getSharedPreferences(SwipeActions.PREF_NAME, Context.MODE_PRIVATE)
prefs.edit().putBoolean(SwipeActions.KEY_PREFIX_NO_ACTION + tag, enabled).apply()
}
interface Callback {
fun onCall()
}
companion object {
private const val LEFT = 1
private const val RIGHT = 0
}
}

View File

@ -1,142 +0,0 @@
package ac.mdiq.podcini.dialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.View
import android.widget.ArrayAdapter
import androidx.fragment.app.DialogFragment
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.adapter.SimpleChipAdapter
import ac.mdiq.podcini.core.storage.DBReader
import ac.mdiq.podcini.core.storage.DBWriter
import ac.mdiq.podcini.core.storage.NavDrawerData.DrawerItem
import ac.mdiq.podcini.databinding.EditTagsDialogBinding
import ac.mdiq.podcini.model.feed.FeedPreferences
import ac.mdiq.podcini.view.ItemOffsetDecoration
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
class TagSettingsDialog : DialogFragment() {
private var displayedTags: MutableList<String>? = null
private var viewBinding: EditTagsDialogBinding? = null
private var adapter: SimpleChipAdapter? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val feedPreferencesList =
requireArguments().getSerializable(ARG_FEED_PREFERENCES) as ArrayList<FeedPreferences>?
val commonTags: MutableSet<String> = HashSet(
feedPreferencesList!![0].getTags())
for (preference in feedPreferencesList) {
commonTags.retainAll(preference.getTags())
}
displayedTags = ArrayList(commonTags)
displayedTags?.remove(FeedPreferences.TAG_ROOT)
viewBinding = EditTagsDialogBinding.inflate(layoutInflater)
viewBinding!!.tagsRecycler.layoutManager = GridLayoutManager(context, 2)
viewBinding!!.tagsRecycler.addItemDecoration(ItemOffsetDecoration(requireContext(), 4))
adapter = object : SimpleChipAdapter(requireContext()) {
override fun getChips(): List<String> {
return displayedTags?: listOf()
}
override fun onRemoveClicked(position: Int) {
displayedTags?.removeAt(position)
notifyDataSetChanged()
}
}
viewBinding!!.tagsRecycler.adapter = adapter
viewBinding!!.rootFolderCheckbox.isChecked = commonTags.contains(FeedPreferences.TAG_ROOT)
viewBinding!!.newTagTextInput.setEndIconOnClickListener { v: View? ->
addTag(
viewBinding!!.newTagEditText.text.toString().trim { it <= ' ' })
}
loadTags()
viewBinding!!.newTagEditText.threshold = 1
viewBinding!!.newTagEditText.setOnTouchListener { v, event ->
viewBinding!!.newTagEditText.showDropDown()
viewBinding!!.newTagEditText.requestFocus()
false
}
if (feedPreferencesList.size > 1) {
viewBinding!!.commonTagsInfo.visibility = View.VISIBLE
}
val dialog = MaterialAlertDialogBuilder(requireContext())
dialog.setView(viewBinding!!.root)
dialog.setTitle(R.string.feed_tags_label)
dialog.setPositiveButton(android.R.string.ok) { d: DialogInterface?, input: Int ->
addTag(viewBinding!!.newTagEditText.text.toString().trim { it <= ' ' })
updatePreferencesTags(feedPreferencesList, commonTags)
}
dialog.setNegativeButton(R.string.cancel_label, null)
return dialog.create()
}
private fun loadTags() {
Observable.fromCallable<List<String>> {
val data = DBReader.getNavDrawerData(null)
val items = data.items
val folders: MutableList<String> = ArrayList()
for (item in items) {
if (item.type == DrawerItem.Type.TAG) {
if (item.title != null) folders.add(item.title!!)
}
}
folders
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ result: List<String> ->
val acAdapter = ArrayAdapter(
requireContext(),
R.layout.single_tag_text_view, result)
viewBinding!!.newTagEditText.setAdapter(acAdapter)
}, { error: Throwable? ->
Log.e(TAG, Log.getStackTraceString(error))
})
}
private fun addTag(name: String) {
if (TextUtils.isEmpty(name) || displayedTags!!.contains(name)) {
return
}
displayedTags!!.add(name)
viewBinding!!.newTagEditText.setText("")
adapter!!.notifyDataSetChanged()
}
private fun updatePreferencesTags(feedPreferencesList: List<FeedPreferences>?, commonTags: Set<String?>) {
if (viewBinding!!.rootFolderCheckbox.isChecked) {
displayedTags!!.add(FeedPreferences.TAG_ROOT)
}
for (preferences in feedPreferencesList!!) {
preferences.getTags().removeAll(commonTags)
preferences.getTags().addAll(displayedTags!!)
DBWriter.setFeedPreferences(preferences)
}
}
companion object {
const val TAG: String = "TagSettingsDialog"
private const val ARG_FEED_PREFERENCES = "feed_preferences"
fun newInstance(preferencesList: List<FeedPreferences>?): TagSettingsDialog {
val fragment = TagSettingsDialog()
val args = Bundle()
args.putSerializable(ARG_FEED_PREFERENCES, ArrayList(preferencesList))
fragment.arguments = args
return fragment
}
}
}

View File

@ -1,179 +0,0 @@
package ac.mdiq.podcini.dialog
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Point
import android.graphics.RectF
import android.text.format.DateFormat
import android.view.MotionEvent
import android.view.View
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.ui.common.ThemeUtils.getColorFromAttr
import java.util.*
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
class TimeRangeDialog(context: Context, from: Int, to: Int) : MaterialAlertDialogBuilder(context) {
private val view = TimeRangeView(context, from, to)
init {
setView(view)
setPositiveButton(android.R.string.ok, null)
}
val from: Int
get() = view.from
val to: Int
get() = view.to
internal class TimeRangeView @JvmOverloads constructor(context: Context?,
internal var from: Int = 0,
var to: Int = 0
) : View(context) {
private val paintDial = Paint()
private val paintSelected = Paint()
private val paintText = Paint()
private val bounds = RectF()
var touching: Int = 0
init {
setup()
}
private fun setup() {
paintDial.isAntiAlias = true
paintDial.style = Paint.Style.STROKE
paintDial.strokeCap = Paint.Cap.ROUND
paintDial.color = getColorFromAttr(context, android.R.attr.textColorPrimary)
paintDial.alpha = DIAL_ALPHA
paintSelected.isAntiAlias = true
paintSelected.style = Paint.Style.STROKE
paintSelected.strokeCap = Paint.Cap.ROUND
paintSelected.color = getColorFromAttr(context, R.attr.colorAccent)
paintText.isAntiAlias = true
paintText.style = Paint.Style.FILL
paintText.color =
getColorFromAttr(context, android.R.attr.textColorPrimary)
paintText.textAlign = Paint.Align.CENTER
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY
&& MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
} else if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) {
super.onMeasure(widthMeasureSpec, widthMeasureSpec)
} else if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) {
super.onMeasure(heightMeasureSpec, heightMeasureSpec)
} else if (MeasureSpec.getSize(widthMeasureSpec) < MeasureSpec.getSize(heightMeasureSpec)) {
super.onMeasure(widthMeasureSpec, widthMeasureSpec)
} else {
super.onMeasure(heightMeasureSpec, heightMeasureSpec)
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val size = height.toFloat() // square
val padding = size * 0.1f
paintDial.strokeWidth = size * 0.005f
bounds[padding, padding, size - padding] = size - padding
paintText.alpha = DIAL_ALPHA
canvas.drawArc(bounds, 0f, 360f, false, paintDial)
for (i in 0..23) {
paintDial.strokeWidth = size * 0.005f
if (i % 6 == 0) {
paintDial.strokeWidth = size * 0.01f
val textPos = radToPoint(i / 24.0f * 360f, size / 2 - 2.5f * padding)
paintText.textSize = 0.4f * padding
canvas.drawText(i.toString(), textPos.x.toFloat(),
textPos.y + (-paintText.descent() - paintText.ascent()) / 2, paintText)
}
val outer = radToPoint(i / 24.0f * 360f, size / 2 - 1.7f * padding)
val inner = radToPoint(i / 24.0f * 360f, size / 2 - 1.9f * padding)
canvas.drawLine(outer.x.toFloat(), outer.y.toFloat(), inner.x.toFloat(), inner.y.toFloat(), paintDial)
}
paintText.alpha = 255
val angleFrom = from.toFloat() / 24 * 360 - 90
val angleDistance = ((to - from + 24) % 24).toFloat() / 24 * 360
paintSelected.strokeWidth = padding / 6
paintSelected.style = Paint.Style.STROKE
canvas.drawArc(bounds, angleFrom, angleDistance, false, paintSelected)
paintSelected.style = Paint.Style.FILL
val p1 = radToPoint(angleFrom + 90, size / 2 - padding)
canvas.drawCircle(p1.x.toFloat(), p1.y.toFloat(), padding / 2, paintSelected)
val p2 = radToPoint(angleFrom + angleDistance + 90, size / 2 - padding)
canvas.drawCircle(p2.x.toFloat(), p2.y.toFloat(), padding / 2, paintSelected)
paintText.textSize = 0.6f * padding
val timeRange = if (from == to) {
context.getString(R.string.sleep_timer_always)
} else if (DateFormat.is24HourFormat(context)) {
String.format(Locale.getDefault(), "%02d:00 - %02d:00", from, to)
} else {
String.format(Locale.getDefault(), "%02d:00 %s - %02d:00 %s", from % 12,
if (from >= 12) "PM" else "AM", to % 12, if (to >= 12) "PM" else "AM")
}
canvas.drawText(timeRange, size / 2, (size - paintText.descent() - paintText.ascent()) / 2, paintText)
}
protected fun radToPoint(angle: Float, radius: Float): Point {
return Point((width / 2 + radius * sin(-angle * Math.PI / 180 + Math.PI)).toInt(),
(height / 2 + radius * cos(-angle * Math.PI / 180 + Math.PI)).toInt())
}
override fun onTouchEvent(event: MotionEvent): Boolean {
parent.requestDisallowInterceptTouchEvent(true)
val center = Point(width / 2, height / 2)
val angleRad = atan2((center.y - event.y).toDouble(), (center.x - event.x).toDouble())
var angle = (angleRad * (180 / Math.PI)).toFloat()
angle += (360 + 360 - 90).toFloat()
angle %= 360f
if (event.action == MotionEvent.ACTION_DOWN) {
val fromDistance = abs((angle - from.toFloat() / 24 * 360).toDouble()).toFloat()
val toDistance = abs((angle - to.toFloat() / 24 * 360).toDouble()).toFloat()
if (fromDistance < 15 || fromDistance > (360 - 15)) {
touching = 1
return true
} else if (toDistance < 15 || toDistance > (360 - 15)) {
touching = 2
return true
}
} else if (event.action == MotionEvent.ACTION_MOVE) {
val newTime = (24 * (angle / 360.0)).toInt()
if (from == to && touching != 0) {
// Switch which handle is focussed such that selection is the smaller arc
touching = if ((((newTime - to + 24) % 24) < 12)) 2 else 1
}
if (touching == 1) {
from = newTime
invalidate()
return true
} else if (touching == 2) {
to = newTime
invalidate()
return true
}
} else if (touching != 0) {
touching = 0
return true
}
return super.onTouchEvent(event)
}
companion object {
private const val DIAL_ALPHA = 120
}
}
}

Some files were not shown because too many files have changed in this diff Show More