diff --git a/.gitignore b/.gitignore index 56cc642..9cbbd0c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ out/ # Uncomment the following line in case you need and you don't have the release build type files in your app # release/ +.DS_Store + # Gradle files .gradle/ build/ @@ -30,6 +32,10 @@ proguard/ # Log Files *.log +# Android Studio +.idea +/.idea/ + # Android Studio Navigation editor temp files .navigation/ @@ -52,15 +58,15 @@ captures/ # Keystore files # Uncomment the following lines if you do not want to check your keystore files in. -#*.jks -#*.keystore +*.jks +*.keystore # External native build folder generated in Android Studio 2.2 and later .externalNativeBuild .cxx/ # Google Services (e.g. APIs or Firebase) -# google-services.json +google-services.json # Freeline freeline.py @@ -82,4 +88,4 @@ lint/intermediates/ lint/generated/ lint/outputs/ lint/tmp/ -# lint/reports/ +lint/reports/ diff --git a/README.md b/README.md index 24cffaa..fa3e2d9 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ -# Podcast-Player \ No newline at end of file +#### Podcast Player App + +Esse App foi desenvolvido a API [PodcastIndex.org API](https://podcastindex-org.github.io/docs-api/#overview), que é uma API gratuita que provem dados de Podcasts. + +Para poder rodar o APP, abra o seguinte arquivo: app/src/main/res/values/podcast_index_api_keys.xml. Nele você deve colocar o seu API secret e sua API key que dá para se obter gratuitamente no site acima. + +Esse APP foi desenvolvido seguindo a arquitetura MVC. Segue um diagrama demonstrando como está o app: + + + + +Seguem prints do app rodando: + + + + + + + + + + + + diff --git a/X# b/X# new file mode 100644 index 0000000..e69de29 diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..b37c23e --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,68 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-kapt' + id 'androidx.navigation.safeargs.kotlin' +} + +android { + compileSdkVersion 30 + buildToolsVersion "30.0.2" + + defaultConfig { + applicationId "br.ufpe.cin.vrvs.podcastplayer" + minSdkVersion 22 + targetSdkVersion 30 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { + dataBinding true + } +} + +dependencies { + implementation "androidx.appcompat:appcompat:$versions.appcompat" + implementation "androidx.constraintlayout:constraintlayout:$versions.constraintlayout" + implementation "androidx.core:core-ktx:$versions.core_ktx" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$versions.lifecycle" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:$versions.lifecycle" + implementation "androidx.navigation:navigation-fragment-ktx:$versions.navigation" + implementation "androidx.navigation:navigation-ui-ktx:$versions.navigation" + implementation "androidx.room:room-ktx:$versions.room" + implementation "androidx.room:room-runtime:$versions.room" + implementation "com.airbnb.android:lottie:$versions.lottie" + implementation "com.google.android.material:material:$versions.material" + implementation "com.squareup.picasso:picasso:$versions.picasso" + implementation "com.squareup.retrofit2:converter-gson:$versions.retrofit" + implementation "com.squareup.retrofit2:retrofit:$versions.retrofit" + implementation "com.squareup.okhttp3:logging-interceptor:$versions.okhttp" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.kotlin_coroutine" + implementation "org.koin:koin-android:$versions.koin_android" + implementation "org.koin:koin-android-ext:$versions.koin_android" + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + + kapt "androidx.room:room-compiler:$versions.room" + + testImplementation "junit:junit:$versions.junit" + + androidTestImplementation "androidx.test.ext:junit:$versions.ext_junit" + androidTestImplementation "androidx.test.espresso:espresso-core:$versions.espresso_core" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/br/ufpe/cin/vrvs/podcastplayer/ExampleInstrumentedTest.kt b/app/src/androidTest/java/br/ufpe/cin/vrvs/podcastplayer/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..296a46c --- /dev/null +++ b/app/src/androidTest/java/br/ufpe/cin/vrvs/podcastplayer/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package br.ufpe.cin.vrvs.podcastplayer + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("br.ufpe.cin.vrvs.podcastplayer", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f5e6bc6 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_podcast_player-playstore.png b/app/src/main/ic_podcast_player-playstore.png new file mode 100644 index 0000000..b211041 Binary files /dev/null and b/app/src/main/ic_podcast_player-playstore.png differ diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/MainActivity.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/MainActivity.kt new file mode 100644 index 0000000..3e7d9d5 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/MainActivity.kt @@ -0,0 +1,23 @@ +package br.ufpe.cin.vrvs.podcastplayer + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import androidx.appcompat.app.AppCompatDelegate +import androidx.navigation.Navigation +import androidx.navigation.fragment.NavHostFragment +import br.ufpe.cin.vrvs.podcastplayer.services.player.PodcastPlayerService.Companion.PODCAST_ID + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + val podcastId = intent.extras?.getString(PODCAST_ID) + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + if (podcastId != null) { + val action = NavGraphDirections.actionGlobalPodcastDetailsFragment(podcastId) + val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_graph_container) as NavHostFragment + val navController = navHostFragment.navController + navController.navigate(action) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/app/PodcastPlayerApplication.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/app/PodcastPlayerApplication.kt new file mode 100644 index 0000000..98554cc --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/app/PodcastPlayerApplication.kt @@ -0,0 +1,31 @@ +package br.ufpe.cin.vrvs.podcastplayer.app + +import android.app.Application +import br.ufpe.cin.vrvs.podcastplayer.di.apiModule +import br.ufpe.cin.vrvs.podcastplayer.di.databaseModule +import br.ufpe.cin.vrvs.podcastplayer.di.preferencesModule +import br.ufpe.cin.vrvs.podcastplayer.di.repositoryModule +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin + +class PodcastPlayerApplication : Application() { + + private val modules = listOf( + apiModule, + databaseModule, + preferencesModule, + repositoryModule + ) + + override fun onCreate() { + super.onCreate() + + // Start Koin + startKoin{ + androidLogger() + androidContext(this@PodcastPlayerApplication) + modules(modules) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/PodcastDao.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/PodcastDao.kt new file mode 100644 index 0000000..baad78d --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/PodcastDao.kt @@ -0,0 +1,57 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.EpisodePersisted +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.EpisodePersistedDownloaded +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.EpisodePersistedPlaying +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.PodcastPersisted + +@Dao +interface PodcastDao { + + @Query("SELECT * FROM podcast_table") + suspend fun getPodcasts(): List + + @Query("SELECT * FROM podcast_table where :id = id") + suspend fun getPodcast(id: String): PodcastPersisted + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertPodcast(podcast: PodcastPersisted) + + @Query("DELETE FROM podcast_table where :id = id") + suspend fun clearPodcast(id: String) + + @Query("SELECT EXISTS(SELECT * FROM podcast_table where :id = id)") + fun hasPodcast(id: String): Boolean + + @Query("SELECT * FROM episode_table") + suspend fun getEpisodes(): List + + @Query("SELECT * FROM episode_table where :id = id") + suspend fun getEpisode(id: String): EpisodePersisted + + @Query("SELECT * FROM episode_table where :podcastId = podcastId order by season, episode") + suspend fun getPodcastEpisodes(podcastId: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertEpisode(episode: EpisodePersisted) + + @Query("DELETE FROM episode_table where :id = id") + suspend fun clearEpisode(id: String) + + @Query("DELETE FROM episode_table where :podcastId = podcastId") + suspend fun clearPodcastEpisodes(podcastId: String) + + @Query("SELECT EXISTS(SELECT * FROM episode_table where :id = id)") + fun hasEpisode(id: String): Boolean + + @Update(entity = EpisodePersisted::class) + suspend fun updateDownloaded(downloaded: EpisodePersistedDownloaded) + + @Update(entity = EpisodePersisted::class) + suspend fun updatePlaying(downloaded: EpisodePersistedPlaying) +} diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/PodcastDatabase.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/PodcastDatabase.kt new file mode 100644 index 0000000..b942879 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/PodcastDatabase.kt @@ -0,0 +1,20 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.EpisodePersisted +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.PodcastPersisted +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.converter.MapConverter + +@Database( + entities = [ + EpisodePersisted::class, + PodcastPersisted::class + ], + version = 1 +) +@TypeConverters(MapConverter::class) +abstract class PodcastDatabase : RoomDatabase() { + abstract fun podcastDao(): PodcastDao +} diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/table/EpisodePersisted.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/table/EpisodePersisted.kt new file mode 100644 index 0000000..d4eab81 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/table/EpisodePersisted.kt @@ -0,0 +1,81 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.ForeignKey.CASCADE +import androidx.room.PrimaryKey +import br.ufpe.cin.vrvs.podcastplayer.data.model.Episode + +@Entity( + tableName = "episode_table", + foreignKeys = [ForeignKey( + entity = PodcastPersisted::class, + parentColumns = ["id"], + childColumns = ["podcastId"], + onDelete = CASCADE + )] +) +data class EpisodePersisted( + @PrimaryKey(autoGenerate = false) var id: String, + @ColumnInfo(name = "podcastId") var podcastId: String, + @ColumnInfo(name = "title") var title: String, + @ColumnInfo(name = "description") var description: String, + @ColumnInfo(name = "audioUrl") var audioUrl: String, + @ColumnInfo(name = "audioType") var audioType: String, + @ColumnInfo(name = "imageUrl") var imageUrl: String, + @ColumnInfo(name = "datePublished") var datePublished: Long, + @ColumnInfo(name = "duration") var duration: Int, + @ColumnInfo(name = "episode") var episode: Int, + @ColumnInfo(name = "season") var season: Int, + @ColumnInfo(name = "downloadId") var downloadId: Long? = null, + @ColumnInfo(name = "path") var path: String = "", + @ColumnInfo(name = "playing") var playing: Boolean = false +) + +@Entity +data class EpisodePersistedDownloaded( + @ColumnInfo(name = "id") var id: String, + @ColumnInfo(name = "downloadId") var downloadId: Long? = null, + @ColumnInfo(name = "path") var path: String = "" +) + +@Entity +data class EpisodePersistedPlaying( + @ColumnInfo(name = "id") var id: String, + @ColumnInfo(name = "playing") var playing: Boolean = false +) + +fun Episode.Companion.toEpisode(episodePersisted: EpisodePersisted) = Episode( + id = episodePersisted.id, + podcastId = episodePersisted.podcastId, + title = episodePersisted.title, + description = episodePersisted.description, + audioUrl = episodePersisted.audioUrl, + audioType = episodePersisted.audioType, + imageUrl = episodePersisted.imageUrl, + datePublished = episodePersisted.datePublished, + duration = episodePersisted.duration, + episode = episodePersisted.episode, + season = episodePersisted.season, + downloadId = episodePersisted.downloadId, + path = episodePersisted.path, + playing = episodePersisted.playing +) + +fun Episode.Companion.fromEpisode(episode: Episode) = EpisodePersisted( + id = episode.id, + podcastId = episode.podcastId, + title = episode.title, + description = episode.description, + audioUrl = episode.audioUrl, + audioType = episode.audioType, + imageUrl = episode.imageUrl, + datePublished = episode.datePublished, + duration = episode.duration, + episode = episode.episode, + season = episode.season, + downloadId = episode.downloadId, + path = episode.path, + playing = episode.playing +) \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/table/PodcastPersisted.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/table/PodcastPersisted.kt new file mode 100644 index 0000000..4ec51f0 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/table/PodcastPersisted.kt @@ -0,0 +1,38 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import br.ufpe.cin.vrvs.podcastplayer.data.model.Podcast +import br.ufpe.cin.vrvs.podcastplayer.data.model.Podcast.* + +@Entity(tableName = "podcast_table") +data class PodcastPersisted( + @PrimaryKey(autoGenerate = false) var id: String, + @ColumnInfo(name = "author") var author: String, + @ColumnInfo(name = "description") var description: String, + @ColumnInfo(name = "title") var title: String, + @ColumnInfo(name = "imageUrl") var imageUrl: String, + @ColumnInfo(name = "categories") var categories: Map, + @ColumnInfo(name = "subscribed") var subscribed: Boolean = false +) + +fun Companion.toPodcast(podcastPersisted: PodcastPersisted) = Podcast( + id = podcastPersisted.id, + author = podcastPersisted.author, + description = podcastPersisted.description, + title = podcastPersisted.title, + imageUrl = podcastPersisted.imageUrl, + categories = podcastPersisted.categories, + subscribed = podcastPersisted.subscribed +) + +fun Companion.fromPodcast(podcast: Podcast) = PodcastPersisted( + id = podcast.id, + author = podcast.author ?: "", + description = podcast.description ?: "", + title = podcast.title, + imageUrl = podcast.imageUrl, + categories = podcast.categories, + subscribed = podcast.subscribed +) \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/table/converter/MapConverter.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/table/converter/MapConverter.kt new file mode 100644 index 0000000..8a6204c --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/database/table/converter/MapConverter.kt @@ -0,0 +1,19 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.converter + +import androidx.room.TypeConverter +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +class MapConverter { + @TypeConverter + fun fromString(value: String): Map { + val mapType = object : TypeToken>() {}.type + return Gson().fromJson(value, mapType) + } + + @TypeConverter + fun fromStringMap(map: Map): String { + val gson = Gson() + return gson.toJson(map) + } +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/preference/PodcastSharedPreferences.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/preference/PodcastSharedPreferences.kt new file mode 100644 index 0000000..3c22c8b --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/preference/PodcastSharedPreferences.kt @@ -0,0 +1,8 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.preference + +interface PodcastSharedPreferences { + var podcastEpisodeId: String? + fun clearPodcastEpisodeId() + var podcastTime: Long? + fun clearPodcastTime() +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/preference/PodcastSharedPreferencesImpl.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/preference/PodcastSharedPreferencesImpl.kt new file mode 100644 index 0000000..f82ead3 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/local/preference/PodcastSharedPreferencesImpl.kt @@ -0,0 +1,58 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.preference + +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.content.SharedPreferences + +class PodcastSharedPreferencesImpl(context: Context) : PodcastSharedPreferences { + + companion object { + private const val PODCAST_SHARED_PREFERENCES = "br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.preference.PodcastSharedPreferences" + private const val PODCAST_ID = "PodcastId" + private const val PODCAST_TIME = "PodcastTime" + } + + private val sharedPreferences: SharedPreferences + + init { + sharedPreferences = context.getSharedPreferences(PODCAST_SHARED_PREFERENCES, MODE_PRIVATE) + } + + override var podcastEpisodeId: String? + get() = sharedPreferences.getStringHelper(PODCAST_ID) + set(newValue) = sharedPreferences.putStringHelper(PODCAST_ID, newValue) + override fun clearPodcastEpisodeId() = sharedPreferences.clear(PODCAST_ID) + + override var podcastTime: Long? + get() = sharedPreferences.getLongHelper(PODCAST_TIME) + set(newValue) = sharedPreferences.putLongHelper(PODCAST_TIME, newValue) + override fun clearPodcastTime() = sharedPreferences.clear(PODCAST_TIME) + + + // Helper functions + + private fun SharedPreferences.putLongHelper(key: String?, value: Long?) { + if (key.isNullOrEmpty() || value == null) return + edit().putLong(key, value).apply() + } + + private fun SharedPreferences.getLongHelper(key: String?): Long? { + return if (key.isNullOrEmpty() || contains(key).not()) null + else getLong(key, -1) + } + + private fun SharedPreferences.putStringHelper(key: String?, value: String?) { + if (key.isNullOrEmpty() || value.isNullOrEmpty()) return + edit().putString(key, value).apply() + } + + private fun SharedPreferences.getStringHelper(key: String?): String? { + return if (key.isNullOrEmpty() || contains(key).not()) null + else getString(key, null) + } + + private fun SharedPreferences.clear(key: String?) { + if (key.isNullOrEmpty() || contains(key).not()) return + else edit().remove(key).apply() + } +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/PodcastIndexApi.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/PodcastIndexApi.kt new file mode 100644 index 0000000..6b21d4f --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/PodcastIndexApi.kt @@ -0,0 +1,54 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex + +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.response.EpisodeByIdResponse +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.response.EpisodesResponse +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.response.PodcastByIdResponse +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.response.PodcastsRecentResponse +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.response.PodcastsSearchResponse +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.Query +import retrofit2.http.QueryName + +interface PodcastIndexApi { + + companion object { + const val URL = "https://api.podcastindex.org/api/1.0/" + } + + @Headers("Accept: application/json") + @GET("recent/feeds") + fun getRecentFeed( + @Query("max") max: Int, + @QueryName pretty: String = "pretty" + ) : Call + + @Headers("Accept: application/json") + @GET("podcasts/byfeedid") + fun getPodcast( + @Query("id") id: Int, + @QueryName pretty: String = "pretty" + ) : Call + + @Headers("Accept: application/json") + @GET("search/byterm") + fun searchPodcasts( + @Query("q") query: String, + @QueryName pretty: String = "pretty" + ) : Call + + @Headers("Accept: application/json") + @GET("episodes/byfeedid") + fun getEpisodes( + @Query("id") podcastId: String, + @QueryName pretty: String = "pretty" + ) : Call + + @Headers("Accept: application/json") + @GET("episodes/byid") + fun getEpisode( + @Query("id") episodeId: Int, + @QueryName pretty: String = "pretty" + ) : Call +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/PodcastIndexAuthInterceptor.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/PodcastIndexAuthInterceptor.kt new file mode 100644 index 0000000..389e9ca --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/PodcastIndexAuthInterceptor.kt @@ -0,0 +1,66 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex + +import android.content.Context +import br.ufpe.cin.vrvs.podcastplayer.R +import okhttp3.Interceptor +import okhttp3.Response +import java.security.MessageDigest +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class PodcastIndexAuthInterceptor(private val context: Context) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + var req = chain.request() + val keys = getKeys() + req = req.newBuilder() + .header("X-Auth-Date", keys.xAuthDate) + .header("X-Auth-Key", keys.xAuthKey) + .header("Authorization", keys.authorization) + .header("User-Agent", keys.userAgent) + .build() + return chain.proceed(req) + } + + private fun getKeys() : Keys { + val apiKey = context.getString(R.string.api_key) + val apiSecret = context.getString(R.string.api_secret) + val calendar: Calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.clear() + val now = Date() + calendar.time = now + val secondsSinceEpoch: Long = calendar.timeInMillis / 1000L + val apiHeaderTime = "" + secondsSinceEpoch + val data4Hash = apiKey + apiSecret + apiHeaderTime + val hashString: String? = sha1(data4Hash) + + return Keys(apiHeaderTime, apiKey, hashString ?: "") + } + + private fun sha1(clearString: String): String? { + return try { + val messageDigest: MessageDigest = MessageDigest.getInstance("SHA-1") + messageDigest.update(clearString.toByteArray(charset("UTF-8"))) + byteArrayToString(messageDigest.digest()) + } catch (ignored: java.lang.Exception) { + ignored.printStackTrace() + null + } + } + + private fun byteArrayToString(bytes: ByteArray): String? { + val buffer = java.lang.StringBuilder() + for (b in bytes) { + buffer.append(java.lang.String.format(Locale.getDefault(), "%02x", b)) + } + return buffer.toString() + } + + private data class Keys( + val xAuthDate: String, + val xAuthKey: String, + val authorization: String, + val userAgent: String = "SuperPodcastPlayer/1.4" + ) +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/response/ApiErrorResponse.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/response/ApiErrorResponse.kt new file mode 100644 index 0000000..185016f --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/response/ApiErrorResponse.kt @@ -0,0 +1,8 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.response + +import com.google.gson.annotations.SerializedName + +class ApiErrorResponse { + @SerializedName("description") + var description: String? = "" +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/response/EpisodeResponse.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/response/EpisodeResponse.kt new file mode 100644 index 0000000..1ec596b --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/response/EpisodeResponse.kt @@ -0,0 +1,67 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.response + +import br.ufpe.cin.vrvs.podcastplayer.data.model.Episode +import br.ufpe.cin.vrvs.podcastplayer.data.model.Episode.* +import com.google.gson.annotations.SerializedName + +class EpisodeResponse { + + @SerializedName("id") + var id: Int = -1 + + @SerializedName("feedId") + var podcastId: Int = -1 + + @SerializedName("title") + var title: String? = null + + @SerializedName("description") + var description: String? = null + + @SerializedName("enclosureUrl") + var audioUrl: String? = null + + @SerializedName("enclosureType") + var audioType: String? = null + + @SerializedName("feedImage") + var imageUrl: String? = null + + @SerializedName("datePublished") + var datePublished: Long = 0L + + @SerializedName("duration") + var duration: Int = 0 + + @SerializedName("episode") + var episode: Int = -1 + + @SerializedName("season") + var season: Int = -1 +} + +class EpisodesResponse { + + @SerializedName("items") + lateinit var episodes: List +} + +class EpisodeByIdResponse { + + @SerializedName("episode") + lateinit var episode: EpisodeResponse +} + +fun Companion.toEpisode(episodeResponse: EpisodeResponse) = Episode( + id = episodeResponse.id.toString(), + podcastId = episodeResponse.podcastId.toString(), + title = episodeResponse.title.orEmpty(), + description = episodeResponse.description.orEmpty(), + audioUrl = episodeResponse.audioUrl.orEmpty(), + audioType = episodeResponse.audioType.orEmpty(), + imageUrl = episodeResponse.imageUrl.orEmpty(), + datePublished = episodeResponse.datePublished, + duration = episodeResponse.duration, + episode = episodeResponse.episode, + season = episodeResponse.season +) \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/response/PodcastCompleteResponse.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/response/PodcastCompleteResponse.kt new file mode 100644 index 0000000..4e8ba22 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/response/PodcastCompleteResponse.kt @@ -0,0 +1,33 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.response + +import br.ufpe.cin.vrvs.podcastplayer.data.model.Podcast +import br.ufpe.cin.vrvs.podcastplayer.data.model.Podcast.* +import com.google.gson.annotations.SerializedName + +class PodcastCompleteResponse: PodcastRecentResponse() { + + @SerializedName("author") + var author: String? = null + + @SerializedName("description") + var description: String? = null +} + +class PodcastsSearchResponse { + + @SerializedName("feeds") + lateinit var feeds: List +} + +class PodcastByIdResponse { + + @SerializedName("feed") + lateinit var feed: PodcastCompleteResponse +} + +fun Companion.toPodcast(podcastCompleteResponse: PodcastCompleteResponse) : Podcast { + val result = Podcast.toPodcast(podcastCompleteResponse as PodcastRecentResponse) + result.author = podcastCompleteResponse.author.orEmpty() + result.description = podcastCompleteResponse.description.orEmpty() + return result +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/response/PodcastRecentResponse.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/response/PodcastRecentResponse.kt new file mode 100644 index 0000000..48512b8 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/datasource/remote/podcastindex/response/PodcastRecentResponse.kt @@ -0,0 +1,33 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.response + +import br.ufpe.cin.vrvs.podcastplayer.data.model.Podcast +import br.ufpe.cin.vrvs.podcastplayer.data.model.Podcast.* +import com.google.gson.annotations.SerializedName + +open class PodcastRecentResponse { + + @SerializedName("id") + var id: Int = -1 + + @SerializedName("title") + var title: String? = null + + @SerializedName("image") + var imageUrl: String? = null + + @SerializedName("categories") + var categories: Map? = emptyMap() +} + +class PodcastsRecentResponse { + + @SerializedName("feeds") + lateinit var feeds: List +} + +fun Companion.toPodcast(podcastRecentResponse: PodcastRecentResponse) = Podcast( + id = podcastRecentResponse.id.toString(), + title = podcastRecentResponse.title.orEmpty(), + imageUrl = podcastRecentResponse.imageUrl.orEmpty(), + categories = podcastRecentResponse.categories ?: emptyMap() +) \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/model/Episode.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/model/Episode.kt new file mode 100644 index 0000000..e95348f --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/model/Episode.kt @@ -0,0 +1,20 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.model + +data class Episode( + var id: String, + var podcastId: String, + var title: String, + var description: String, + var audioUrl: String, + var audioType: String, + var imageUrl: String, + var datePublished: Long, + var duration: Int, + var episode: Int, + var season: Int, + var downloadId: Long? = null, + var path: String = "", + var playing: Boolean = false +) { + companion object +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/model/ErrorModel.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/model/ErrorModel.kt new file mode 100644 index 0000000..1c24b9a --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/model/ErrorModel.kt @@ -0,0 +1,10 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.model + +import androidx.annotation.StringRes +import br.ufpe.cin.vrvs.podcastplayer.R +import java.lang.Exception + +data class ErrorModel( + val description: String? = null, + @StringRes val descriptionRes: Int = R.string.error_generic_text +) : Exception() \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/model/Podcast.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/model/Podcast.kt new file mode 100644 index 0000000..f483153 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/model/Podcast.kt @@ -0,0 +1,16 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.model + +data class Podcast( + var id: String, + var author: String? = null, + var description: String? = null, + var title: String, + var imageUrl: String, + var categories: Map, + var subscribed: Boolean = false, + var episodes: List = emptyList() +) { + companion object + + fun getEpisodesSorted(): List = episodes.sortedByDescending { episode -> episode.datePublished } +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/model/Result.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/model/Result.kt new file mode 100644 index 0000000..797d8a4 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/model/Result.kt @@ -0,0 +1,16 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.model + +sealed class Result { + + data class Success(val data: T) : Result() + data class Error(val error: ErrorModel) : Result() + object Loading : Result() + + override fun toString(): String { + return when (this) { + is Success<*> -> "Success[data=$data]" + is Error -> "Error[exception=$error]" + Loading -> "Loading" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/repository/PodcastRepository.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/repository/PodcastRepository.kt new file mode 100644 index 0000000..827a19a --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/repository/PodcastRepository.kt @@ -0,0 +1,21 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.repository + +import androidx.lifecycle.LiveData +import br.ufpe.cin.vrvs.podcastplayer.data.model.Episode +import br.ufpe.cin.vrvs.podcastplayer.data.model.Podcast +import br.ufpe.cin.vrvs.podcastplayer.data.model.Result + +interface PodcastRepository { + fun getPlayedPodcast() : LiveData>> + fun updatePlayedPodcast(id: String, time: Long) + fun updatePlayingPodcast(id: String, playing: Boolean) + fun clearPlayedPodcast() + fun getPodcastFeed() : LiveData>> + fun searchPodcast(query: String) : LiveData>> + fun getSubscribedPodcast() : LiveData>> + fun getPodcast(id: String) : LiveData> + fun getEpisode(id: String) : LiveData> + fun updateDownloadedEpisode(episodeId: String, podcastId: String, downloadId: Long?, path: String = ""): LiveData> + fun subscribePodcast(id: String) : LiveData> + fun unsubscribePodcast(id: String): LiveData> +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/repository/PodcastRepositoryImpl.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/repository/PodcastRepositoryImpl.kt new file mode 100644 index 0000000..481fa83 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/data/repository/PodcastRepositoryImpl.kt @@ -0,0 +1,318 @@ +package br.ufpe.cin.vrvs.podcastplayer.data.repository + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import br.ufpe.cin.vrvs.podcastplayer.R +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.PodcastDatabase +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.EpisodePersistedDownloaded +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.EpisodePersistedPlaying +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.fromEpisode +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.fromPodcast +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.toEpisode +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.table.toPodcast +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.preference.PodcastSharedPreferences +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.PodcastIndexApi +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.response.ApiErrorResponse +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.response.toEpisode +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.response.toPodcast +import br.ufpe.cin.vrvs.podcastplayer.data.model.Episode +import br.ufpe.cin.vrvs.podcastplayer.data.model.ErrorModel +import br.ufpe.cin.vrvs.podcastplayer.data.model.Podcast +import br.ufpe.cin.vrvs.podcastplayer.data.model.Result +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import org.koin.core.component.KoinApiExtension +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import retrofit2.HttpException +import retrofit2.await +import java.io.File +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.util.concurrent.Executors + +@KoinApiExtension +class PodcastRepositoryImpl : PodcastRepository, KoinComponent { + + private val podcastDatabase: PodcastDatabase by inject() + private val podcastIndexApi: PodcastIndexApi by inject() + private val podcastSharedPreferences: PodcastSharedPreferences by inject() + + private val scope = CoroutineScope(Dispatchers.IO + NonCancellable) + private val playedContext = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + + override fun getPlayedPodcast(): LiveData>> { + val podcastPlayedSong: MutableLiveData>> = MutableLiveData() + scope.launch(playedContext) { + podcastPlayedSong.postValue(Result.Loading) + try { + val pair = Pair(podcastSharedPreferences.podcastEpisodeId, podcastSharedPreferences.podcastTime) + podcastPlayedSong.postValue(Result.Success(pair)) + } catch (e: Exception) { + podcastPlayedSong.postValue(Result.Error(mapException(e))) + } + } + return podcastPlayedSong + } + + override fun updatePlayedPodcast(id: String, time: Long) { + scope.launch(playedContext) { + podcastSharedPreferences.podcastEpisodeId = id + podcastSharedPreferences.podcastTime = time + } + } + + override fun updatePlayingPodcast(id: String, playing: Boolean) { + scope.launch(playedContext) { + podcastDatabase.podcastDao().updatePlaying(EpisodePersistedPlaying(id, playing)) + } + } + + override fun clearPlayedPodcast() { + scope.launch(playedContext) { + podcastSharedPreferences.clearPodcastEpisodeId() + podcastSharedPreferences.clearPodcastTime() + } + } + + + override fun getPodcastFeed(): LiveData>> { + val podcastsFeed: MutableLiveData>> = MutableLiveData() + scope.launch { + podcastsFeed.postValue(Result.Loading) + try { + val result = podcastIndexApi.getRecentFeed(30).await() + podcastsFeed.postValue(Result.Success(result.feeds.map { + Podcast.toPodcast(it) + })) + } catch (e: Exception) { + podcastsFeed.postValue(Result.Error(mapException(e))) + } + } + return podcastsFeed + } + + override fun searchPodcast(query: String): LiveData>> { + val podcastsFeed: MutableLiveData>> = MutableLiveData() + scope.launch { + podcastsFeed.postValue(Result.Loading) + try { + val result = podcastIndexApi.searchPodcasts(query).await() + podcastsFeed.postValue(Result.Success(result.feeds.map { + Podcast.toPodcast(it) + })) + } catch (e: Exception) { + podcastsFeed.postValue(Result.Error(mapException(e))) + } + } + return podcastsFeed + } + + override fun getSubscribedPodcast(): LiveData>> { + val podcastsSubscribed: MutableLiveData>> = MutableLiveData() + scope.launch { + podcastsSubscribed.postValue(Result.Loading) + try { + val result = podcastDatabase.podcastDao().getPodcasts() + podcastsSubscribed.postValue(Result.Success(result.filter { + it.subscribed + }.map { + Podcast.toPodcast(it) + })) + } catch (e: Exception) { + podcastsSubscribed.postValue(Result.Error(mapException(e))) + } + } + return podcastsSubscribed + } + + override fun getPodcast(id: String): LiveData> { + val podcast: MutableLiveData> = MutableLiveData() + scope.launch { + podcast.postValue(Result.Loading) + try { + getPodcastAwait(id, podcast) + } catch (e: Exception) { + podcast.postValue(Result.Error(mapException(e))) + } + } + return podcast + } + + override fun getEpisode(id: String): LiveData> { + val episode: MutableLiveData> = MutableLiveData() + scope.launch { + episode.postValue(Result.Loading) + try { + episode.postValue(Result.Success(getEpisodeAwait(id))) + } catch (e: Exception) { + episode.postValue(Result.Error(mapException(e))) + } + } + return episode + } + + override fun updateDownloadedEpisode(episodeId: String, podcastId: String, downloadId: Long?, path: String): LiveData> { + val updated: MutableLiveData> = MutableLiveData() + scope.launch { + updated.postValue(Result.Loading) + try { + val db = podcastDatabase.podcastDao() + when { + db.hasEpisode(episodeId) -> { + db.updateDownloaded(EpisodePersistedDownloaded(episodeId,downloadId, path)) + } + db.hasPodcast(podcastId) -> { + getPodcastAwait(podcastId) + db.updateDownloaded(EpisodePersistedDownloaded(episodeId,downloadId, path)) + } + else -> { + subscribePodcastAwait(podcastId) + db.updateDownloaded(EpisodePersistedDownloaded(episodeId,downloadId, path)) + } + } + if (downloadId == null) { + deleteFile(path) + } + } catch (e: Exception) { + updated.postValue(Result.Error(mapException(e))) + } + } + return updated + } + + override fun subscribePodcast(id: String): LiveData> { + val subscribed: MutableLiveData> = MutableLiveData() + scope.launch { + subscribed.postValue(Result.Loading) + try { + subscribed.postValue(Result.Success(subscribePodcastAwait(id))) + } catch (e: Exception) { + subscribed.postValue(Result.Error(mapException(e))) + } + } + return subscribed + } + + override fun unsubscribePodcast(id: String): LiveData> { + val unsubscribed: MutableLiveData> = MutableLiveData() + scope.launch { + unsubscribed.postValue(Result.Loading) + try { + unsubscribed.postValue(Result.Success(unsubscribePodcastAwait(id))) + } catch (e: Exception) { + unsubscribed.postValue(Result.Error(mapException(e))) + } + } + return unsubscribed + } + + private suspend fun unsubscribePodcastAwait(id: String): String { + val db = podcastDatabase.podcastDao() + db.clearPodcast(id) + val episodes = db.getPodcastEpisodes(id) + db.clearPodcastEpisodes(id) + episodes.filter { + it.path != "" + }.forEach { + deleteFile(it.path) + } + return id + } + + private fun deleteFile(path: String) { + val file = File(path).canonicalFile + if (file.exists()) { + file.delete() + } + } + + private suspend fun subscribePodcastAwait(id: String): String { + val podcast = getPodcastAwait(id) + val db = podcastDatabase.podcastDao() + if (!db.hasPodcast(id)) { + podcast.subscribed = true + db.insertPodcast(Podcast.fromPodcast(podcast)) + podcast.episodes.forEach { + db.insertEpisode(Episode.fromEpisode(it)) + } + } + return id + } + + private suspend fun getPodcastAwait(id: String, liveData: MutableLiveData>? = null): Podcast { + val db = podcastDatabase.podcastDao() + val answer: Podcast + if (db.hasPodcast(id)) { + val podcastResult = db.getPodcast(id) + var episodeResult = db.getPodcastEpisodes(id) + answer = Podcast.toPodcast(podcastResult) + answer.episodes = episodeResult.map { + Episode.toEpisode(it) + } + liveData?.postValue(Result.Success(answer)) + try { + val episodeResultApi = podcastIndexApi.getEpisodes(id).await() + val newEpisodes = episodeResultApi.episodes.filter { episodeRemote -> + !answer.episodes.map { episodePersisted -> + episodePersisted.id + }.contains(episodeRemote.id.toString()) + } + if (newEpisodes.isNotEmpty()) { + newEpisodes.map { + db.insertEpisode(Episode.fromEpisode(Episode.toEpisode(it))) + } + } + episodeResult = db.getPodcastEpisodes(id) + answer.episodes = episodeResult.map { + Episode.toEpisode(it) + } + liveData?.postValue(Result.Success(answer)) + } catch (e: Exception) { + // Do nothing - use local values + } + } else { + val podcastResult = podcastIndexApi.getPodcast(id.toInt()).await() + val episodeResult = podcastIndexApi.getEpisodes(id).await() + answer = Podcast.toPodcast(podcastResult.feed) + answer.episodes = episodeResult.episodes.map { + Episode.toEpisode(it) + } + liveData?.postValue(Result.Success(answer)) + } + return answer + } + + private suspend fun getEpisodeAwait(id: String): Episode { + val db = podcastDatabase.podcastDao() + val answer: Episode + answer = if (db.hasEpisode(id)) { + val episodeResult = db.getEpisode(id) + Episode.toEpisode(episodeResult) + } else { + val episodeResult = podcastIndexApi.getEpisode(id.toInt()).await() + Episode.toEpisode(episodeResult.episode) + } + return answer + } + + private fun mapException(e: Exception): ErrorModel { + when (e) { + is HttpException -> { + e.response()?.errorBody()?.string()?.let { + val ans = Gson().fromJson(it, ApiErrorResponse::class.javaObjectType) + return ErrorModel(ans?.description) + } + } + is SocketTimeoutException, is ConnectException, is UnknownHostException-> { + return ErrorModel(descriptionRes = R.string.connection_error) + } + } + return ErrorModel() + } +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/di/DependencyInjectionModule.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/di/DependencyInjectionModule.kt new file mode 100644 index 0000000..adf23dd --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/di/DependencyInjectionModule.kt @@ -0,0 +1,86 @@ +package br.ufpe.cin.vrvs.podcastplayer.di + +import android.content.Context +import androidx.room.Room +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.database.PodcastDatabase +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.preference.PodcastSharedPreferences +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.local.preference.PodcastSharedPreferencesImpl +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.PodcastIndexApi +import br.ufpe.cin.vrvs.podcastplayer.data.datasource.remote.podcastindex.PodcastIndexAuthInterceptor +import br.ufpe.cin.vrvs.podcastplayer.data.repository.PodcastRepository +import br.ufpe.cin.vrvs.podcastplayer.data.repository.PodcastRepositoryImpl +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.koin.core.component.KoinApiExtension +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +val apiModule = module { + single { + provideHttpLoggingInterceptor() + } + single { + providePodcastIndexAuthInterceptor(get()) + } + single { + provideHttpClient(get(), get()) + } + single { + provideRetrofit(get()) + } + single { + providePodcastIndexApi(get()) + } +} + +val databaseModule = module { + single { + providePodcastDatabase(get()) + } +} + +val preferencesModule = module { + single { + PodcastSharedPreferencesImpl(get()) as PodcastSharedPreferences + } +} + +@OptIn(KoinApiExtension::class) +val repositoryModule = module { + single { + PodcastRepositoryImpl() as PodcastRepository + } +} + +private fun provideHttpLoggingInterceptor() = + HttpLoggingInterceptor().apply { + this.level = HttpLoggingInterceptor.Level.BODY + } + +private fun providePodcastIndexAuthInterceptor(context: Context) = + PodcastIndexAuthInterceptor(context) + +private fun provideHttpClient( + loggingInterceptor: HttpLoggingInterceptor, + podcastIndexAuthInterceptor: PodcastIndexAuthInterceptor +) = + OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .addInterceptor(podcastIndexAuthInterceptor) + .build() + +private fun provideRetrofit(okHttpClient: OkHttpClient) = + Retrofit.Builder() + .baseUrl(PodcastIndexApi.URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + +private fun providePodcastIndexApi(retrofit: Retrofit) = + retrofit.create(PodcastIndexApi::class.java) + +private fun providePodcastDatabase(context: Context) = + Room.databaseBuilder(context, PodcastDatabase::class.java, "podcast_database") + .fallbackToDestructiveMigration() + .build() \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/services/download/DownloadUtils.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/services/download/DownloadUtils.kt new file mode 100644 index 0000000..1118764 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/services/download/DownloadUtils.kt @@ -0,0 +1,148 @@ +package br.ufpe.cin.vrvs.podcastplayer.services.download + +import android.app.DownloadManager +import android.app.DownloadManager.STATUS_FAILED +import android.app.DownloadManager.STATUS_PAUSED +import android.app.DownloadManager.STATUS_PENDING +import android.app.DownloadManager.STATUS_RUNNING +import android.app.DownloadManager.STATUS_SUCCESSFUL +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Context.DOWNLOAD_SERVICE +import android.content.Intent +import android.database.Cursor +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Environment.DIRECTORY_PODCASTS +import android.webkit.MimeTypeMap +import br.ufpe.cin.vrvs.podcastplayer.data.model.Episode +import br.ufpe.cin.vrvs.podcastplayer.data.repository.PodcastRepository +import br.ufpe.cin.vrvs.podcastplayer.utils.Utils +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.File + + +object DownloadUtils: KoinComponent { + + private val podcastRepository: PodcastRepository by inject() + + fun DownloadManager.cancel( + podcastId: String, + episodeId: String, + downloadId: Long) { + this.remove(downloadId) + podcastRepository.updateDownloadedEpisode(episodeId, podcastId, null) + } + + fun Context.startPodcastDownload( + url: String, + audioType: String, + episodeTitle: String, + episodeId: String, + podcastId: String): Long? { + val fileName = getEpisodeFileName(episodeId, audioType) + val file = this.getPodcastFile(fileName) + val request = getDownloadRequest(Utils.processUrl(url), file, episodeTitle) + val result = this.enqueueDownload(request) + savePathDownload(podcastId, episodeId, result, file.canonicalPath) + return result + } + + fun Context.getDownloadManager(): DownloadManager? = + this.getSystemService(DOWNLOAD_SERVICE) as DownloadManager? + + fun getDownloadCompleteBroadcastReceiver(downloadId: Long?, block: () -> Unit): BroadcastReceiver { + return object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent + ) { + val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) + if (downloadId == id) { + block() + } + } + } + } + + fun Episode.isDownloaded(context: Context): Boolean { + this.downloadId?.let { + return context.getDownloadManager()?.isDownloadSucceed(it, this.id, this.podcastId) == true && isFileExists(this.path) + } + return false + } + + fun Episode.isInProgress(context: Context): Boolean { + this.downloadId?.let { + context.getDownloadManager()?.let{ dm -> + return dm.isDownloadInProgress(it, this.id, this.podcastId) + } + } + return false + } + + fun Episode.getDuration(context: Context): Long { + val uri = Uri.parse(this.path) + val mmr = MediaMetadataRetriever() + mmr.setDataSource(context, uri) + val durationStr = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + return durationStr?.toLong() ?: 0 + } + + private fun DownloadManager.isDownloadSucceed(downloadId: Long, episodeId: String, podcastId: String): Boolean { + val cursor: Cursor = this.query(DownloadManager.Query().setFilterById(downloadId)) + if (cursor.moveToFirst()) { + val status: Int = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) + if (status == STATUS_SUCCESSFUL) { + return true + } else if (status == STATUS_FAILED) { + podcastRepository.updateDownloadedEpisode(episodeId, podcastId, null, "") + } + } + return false + } + + private fun DownloadManager.isDownloadInProgress(downloadId: Long, episodeId: String, podcastId: String): Boolean { + val cursor: Cursor = this.query(DownloadManager.Query().setFilterById(downloadId)) + if (cursor.moveToFirst()) { + val status: Int = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) + if (status in setOf(STATUS_PENDING, STATUS_RUNNING, STATUS_PAUSED)) { + return true + } else if (status == STATUS_FAILED) { + podcastRepository.updateDownloadedEpisode(episodeId, podcastId, null, "") + } + } + return false + } + + private fun isFileExists(path: String) = File(path).exists() + + private fun getDownloadRequest(url: String, file: File, episodeTitle: String): DownloadManager.Request = + DownloadManager.Request(Uri.parse(url)) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationUri(Uri.fromFile(file)) + .setTitle(episodeTitle) + .setDescription("Downloading") + .setAllowedOverMetered(true) + .setAllowedOverRoaming(true) + + private fun getEpisodeFileName(episodeId: String, audioType: String): String = + "Episode $episodeId.${MimeTypeMap.getSingleton().getExtensionFromMimeType(audioType).orEmpty().ifBlank {audioType.replace('/', '.')}}" + + private fun Context.enqueueDownload(request: DownloadManager.Request): Long? { + val downloadManager = this.getDownloadManager() + return downloadManager?.enqueue(request) + } + + private fun savePathDownload( + podcastId: String, + episodeId: String, + downloadId: Long?, + path: String) { + podcastRepository.updateDownloadedEpisode(episodeId, podcastId, downloadId, path) + } + + private fun Context.getPodcastFile(fileName: String) = + File(this.getExternalFilesDir(DIRECTORY_PODCASTS), fileName) +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/services/player/PodcastPlayerService.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/services/player/PodcastPlayerService.kt new file mode 100644 index 0000000..e8d065d --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/services/player/PodcastPlayerService.kt @@ -0,0 +1,392 @@ +package br.ufpe.cin.vrvs.podcastplayer.services.player + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.media.MediaPlayer +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import android.support.v4.media.session.PlaybackStateCompat.ACTION_PAUSE +import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY +import android.support.v4.media.session.PlaybackStateCompat.ACTION_SEEK_TO +import android.support.v4.media.session.PlaybackStateCompat.ACTION_STOP +import android.support.v4.media.session.PlaybackStateCompat.STATE_PAUSED +import android.support.v4.media.session.PlaybackStateCompat.STATE_PLAYING +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.Observer +import androidx.media.app.NotificationCompat.MediaStyle +import br.ufpe.cin.vrvs.podcastplayer.MainActivity +import br.ufpe.cin.vrvs.podcastplayer.R +import br.ufpe.cin.vrvs.podcastplayer.data.model.Episode +import br.ufpe.cin.vrvs.podcastplayer.data.model.Result +import br.ufpe.cin.vrvs.podcastplayer.data.repository.PodcastRepository +import br.ufpe.cin.vrvs.podcastplayer.utils.Utils +import com.squareup.picasso.Picasso +import com.squareup.picasso.Target +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import java.io.FileInputStream +import java.lang.Exception + + +class PodcastPlayerService : LifecycleService() { + + companion object { + private const val CHANNEL_ID = "br.ufpe.cin.vrvs.podcastplayer.services.player.PodcastPlayerService.CHANNEL_ID" + private const val NOTIFICATION_ID = 1 + const val REQUEST_CODE = 1234 + const val PODCAST_ID = "br.ufpe.cin.vrvs.podcastplayer.services.player.PodcastPlayerService.PODCAST_ID" + const val TAG = "br.ufpe.cin.vrvs.podcastplayer.services.player.PodcastPlayerService.TAG" + const val PLAY_ACTION = "br.ufpe.cin.vrvs.podcastplayer.services.player.PodcastPlayerService.PLAY_ACTION" + const val PAUSE_ACTION = "br.ufpe.cin.vrvs.podcastplayer.services.player.PodcastPlayerService.PAUSE_ACTION" + const val STOP_ACTION = "br.ufpe.cin.vrvs.podcastplayer.services.player.PodcastPlayerService.STOP_ACTION" + } + + private val podcastRepository: PodcastRepository by inject() + private lateinit var mPlayer: MediaPlayer + private val mBinder: Binder + private var episode: Episode? + private var podcastId: String? + private lateinit var mSession: MediaSessionCompat + private lateinit var notificationManager: NotificationManager + private lateinit var mPlaybackState: PlaybackStateCompat + private var listener: PodcastPlayPauseListener? + private var bitmap: Bitmap? + + init { + mBinder = PodcastBinder() + episode = null + podcastId = null + listener = null + bitmap = null + } + + + override fun onCreate() { + super.onCreate() + mPlayer = MediaPlayer() + mSession = MediaSessionCompat(this, TAG) + mSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS) + notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + createChannel() + mPlayer.setOnCompletionListener { + stop(true) + } + registerReceiver(playReceiver, IntentFilter(PLAY_ACTION)) + registerReceiver(pauseReceiver, IntentFilter(PAUSE_ACTION)) + } + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int + ): Int { + intent?.action?.let { action -> + when (action) { + PLAY_ACTION -> { + playEpisode() + listener?.playPausePressed(podcastId) + } + PAUSE_ACTION -> { + pauseEpisode() + listener?.playPausePressed(podcastId) + } + STOP_ACTION -> { + stop(false) + } + else -> {} + } + } + return super.onStartCommand(intent, flags, startId) + } + + override fun onBind(intent: Intent?): IBinder? { + super.onBind(intent) + return mBinder + } + + fun loadEpisode(episode: Episode, podcastId: String) { + this.episode?.id?.let { id -> + podcastRepository.updatePlayingPodcast(id, false) + } + this.episode = episode + this.podcastId = podcastId + this.bitmap = null + GlobalScope.launch(Dispatchers.Main) { + getLargeIcon() + } + } + + fun loadListener(listener: PodcastPlayPauseListener?) { + this.listener = listener + podcastRepository.getPlayedPodcast().observe(this@PodcastPlayerService, Observer { result -> + if (result is Result.Success) { + result.data.first?.let { id -> + podcastRepository.getEpisode(id).observe(this@PodcastPlayerService, Observer { ep -> + if (ep is Result.Success && ep.data.playing != mPlayer.isPlaying) { + podcastRepository.updatePlayingPodcast(id, mPlayer.isPlaying) + podcastId?.let { podcastId -> + listener?.playPausePressed(podcastId) + } + } + }) + } + } + }) + } + + private fun playEpisode() { + podcastRepository.getPlayedPodcast().observe(this@PodcastPlayerService, Observer { + val fis = FileInputStream(episode?.path) + mPlayer.reset() + mPlayer.setDataSource(fis.fd) + mPlayer.prepare() + if (it is Result.Success || it is Result.Error) { + if (it is Result.Success && it.data.first == episode?.id) { + it.data.second?.let { duration -> + mPlayer.seekTo(duration.toInt()) + it.data.first?.let { id -> + podcastRepository.updatePlayedPodcast(id, duration) + podcastRepository.updatePlayingPodcast(id, true) + } + } + } else { + episode?.id?.let { id -> + podcastRepository.updatePlayedPodcast(id, 0) + podcastRepository.updatePlayingPodcast(id, true) + } + } + fis.close() + mPlayer.start() + setNotification(false) + } + }) + } + + private fun pauseEpisode() { + if (mPlayer.isPlaying) { + mPlayer.pause() + episode?.id?.let { + podcastRepository.updatePlayedPodcast(it, mPlayer.currentPosition.toLong()) + podcastRepository.updatePlayingPodcast(it, false) + } + setNotification(true) + } + } + + private fun setNotification(isNotPlaying: Boolean) { + val notificationBuilder = createNotificationBuilder() + notificationBuilder + .setLargeIcon(bitmap ?: ResourcesCompat.getDrawable(resources, R.drawable.ic_announcement_white_18dp, null)?.apply { + Utils.setTint( + resources, + this, + R.color.dark + ) + }?.toBitmap(45, 45)) + .setContentIntent(createContentIntent()) + .setContentTitle(episode?.title) + .addAction( + if (isNotPlaying) + NotificationCompat.Action( + R.drawable.ic_play_circle_filled_white_24dp, + "play", + createPlayIntent() + ) + else + NotificationCompat.Action( + R.drawable.ic_pause_circle_filled_white_24dp, + "pause", + createPauseIntent() + ) + ).addAction( + NotificationCompat.Action( + R.drawable.ic_cancel_white_24dp, + "stop", + createStopIntent() + ) + ) + .setProgress(mPlayer.duration, mPlayer.currentPosition, false) + + val notification = notificationBuilder.build() + updateData(if (!isNotPlaying) STATE_PLAYING else STATE_PAUSED) + notificationManager.notify(NOTIFICATION_ID, notification) + startForeground(NOTIFICATION_ID, notification) + listener?.playPausePressed(podcastId) + } + + private fun stop(finished: Boolean) { + episode?.id?.let { + podcastRepository.updatePlayedPodcast(it, if (finished) 0 else mPlayer.currentPosition.toLong()) + podcastRepository.updatePlayingPodcast(it, false) + } + mPlayer.stop() + mPlayer.reset() + notificationManager.cancel(NOTIFICATION_ID) + listener?.playPausePressed(podcastId) + stopForeground(true) + } + + private fun updateData(@PlaybackStateCompat.State state: Int) { + mSession.setCallback(object : MediaSessionCompat.Callback() { + override fun onSeekTo(pos: Long) { + mPlayer.seekTo(pos.toInt()) + episode?.id?.let { id -> + podcastRepository.updatePlayedPodcast(id, pos) + mSession.setMetadata(getMediaMetadata()) + if (mPlayer.isPlaying) { + playEpisode() + } else { + setNotification(true) + } + } + } + }) + mSession.setMetadata(getMediaMetadata()) + mPlaybackState = PlaybackStateCompat.Builder().setState(state, mPlayer.currentPosition.toLong(), 1.0f) + .setActions(ACTION_PLAY or ACTION_PAUSE or ACTION_SEEK_TO or ACTION_STOP) + .build() + mSession.setPlaybackState(mPlaybackState) + } + + override fun onDestroy() { + unregisterReceiver(playReceiver) + unregisterReceiver(pauseReceiver) + notificationManager.cancel(NOTIFICATION_ID) + episode?.id?.let { + podcastRepository.updatePlayedPodcast(it, mPlayer.currentPosition.toLong()) + podcastRepository.updatePlayingPodcast(it, false) + } + mPlayer.stop() + mPlayer.reset() + mPlayer.release() + super.onDestroy() + } + + private fun getLargeIcon() { + episode?.imageUrl?.let { url -> + Utils.processUrl(url).let { urlProcessed -> + if (urlProcessed.isNotEmpty()) { + Picasso + .get() + .load(urlProcessed) + .resize(400, 400) + .into( + object: Target { + override fun onPrepareLoad(placeHolderDrawable: Drawable?) {} + override fun onBitmapFailed( + e: Exception?, + errorDrawable: Drawable? + ) {} + override fun onBitmapLoaded( + bitmap: Bitmap?, + from: Picasso.LoadedFrom? + ) { + this@PodcastPlayerService.bitmap = bitmap + setNotification(!mPlayer.isPlaying) + } + } + ) + } + } + } + } + + private fun createNotificationBuilder() = + NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setStyle( + MediaStyle() + .setMediaSession(mSession.sessionToken) + .setShowActionsInCompactView(0, 1) + .setShowCancelButton(true) + .setCancelButtonIntent( + createStopIntent() + ) + ) + .setColor(ContextCompat.getColor(this, R.color.dark)) + .setSmallIcon(R.mipmap.ic_podcast_player_round) + .setDeleteIntent( + createStopIntent() + ) + .setNotificationSilent() + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val mChannel = NotificationChannel(CHANNEL_ID, "Podcast Notification Channel", NotificationManager.IMPORTANCE_DEFAULT) + notificationManager.createNotificationChannel(mChannel) + } + } + + private fun createContentIntent(): PendingIntent? { + val openUI = Intent(this, MainActivity::class.java) + openUI.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + openUI.putExtra(PODCAST_ID, podcastId) + return PendingIntent.getActivity( + this, REQUEST_CODE, openUI, PendingIntent.FLAG_CANCEL_CURRENT + ) + } + + private fun createPlayIntent(): PendingIntent? { + val play = Intent(this, PodcastPlayerService::class.java) + play.action = PLAY_ACTION + return PendingIntent.getService(this, 0, play, PendingIntent.FLAG_UPDATE_CURRENT) + } + + private fun createPauseIntent(): PendingIntent? { + val pause = Intent(this, PodcastPlayerService::class.java) + pause.action = PAUSE_ACTION + return PendingIntent.getService(this, 0, pause, PendingIntent.FLAG_UPDATE_CURRENT) + } + + private fun createStopIntent(): PendingIntent? { + val stop = Intent(this, PodcastPlayerService::class.java) + stop.action = STOP_ACTION + return PendingIntent.getService(this, 0, stop, PendingIntent.FLAG_ONE_SHOT) + } + + private fun getMediaMetadata(): MediaMetadataCompat { + val builder = MediaMetadataCompat.Builder() + builder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, episode?.title ?: "Podcast") + builder.putLong( + MediaMetadataCompat.METADATA_KEY_DURATION, mPlayer.duration.toLong() + ) + return builder.build() + } + + private val playReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + playEpisode() + } + } + + private val pauseReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + pauseEpisode() + } + } + + inner class PodcastBinder : Binder() { + internal val service: PodcastPlayerService + get() = this@PodcastPlayerService + } + + interface PodcastPlayPauseListener { + fun playPausePressed(podcastId: String?) + } +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/utils/Utils.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/utils/Utils.kt new file mode 100644 index 0000000..b1e058e --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/utils/Utils.kt @@ -0,0 +1,64 @@ +package br.ufpe.cin.vrvs.podcastplayer.utils + +import android.content.Context +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.icu.util.TimeUnit +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.DrawableCompat +import br.ufpe.cin.vrvs.podcastplayer.data.model.ErrorModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.TimeUnit.MINUTES + +object Utils { + fun processUrl(url: String): String { + if ("https" in url) + return url + return url.replace("http", "https") + } + + fun setTint(resources: Resources, drawable: Drawable, @ColorRes color: Int) { + DrawableCompat.setTint( + DrawableCompat.wrap(drawable), + ResourcesCompat.getColor( + resources, + color, + null + ) + ) + } + + fun toPrettyDate(timestamp: Long): String { + val df = SimpleDateFormat("MMMM dd, yyyy") + return df.format(Date().apply { time = timestamp*1000L}) + } + + fun toPrettyDuration(ms: Long): String { + val h = MILLISECONDS.toHours(ms) + val m = MILLISECONDS.toMinutes(ms)%60 + val s = MILLISECONDS.toSeconds(ms)%60 + return when { + h>0 -> { + "$h h $m m $s s" + } + m > 0 -> { + "$m m $s s" + } + else -> { + "$s s" + } + } + } + + fun getString(context: Context, errorModel: ErrorModel) = + errorModel.description ?: context.getString(errorModel.descriptionRes) + + inline fun safeLet(p1: T1?, p2: T2?, p3: T3?, block: (T1, T2, T3)->R?): R? { + return if (p1 != null && p2 != null && p3 != null) block(p1, p2, p3) else null + } +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/view/component/episode/EpisodeListComponent.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/view/component/episode/EpisodeListComponent.kt new file mode 100644 index 0000000..9d7a79d --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/view/component/episode/EpisodeListComponent.kt @@ -0,0 +1,68 @@ +package br.ufpe.cin.vrvs.podcastplayer.view.component.episode + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import br.ufpe.cin.vrvs.podcastplayer.R +import br.ufpe.cin.vrvs.podcastplayer.data.model.Episode +import br.ufpe.cin.vrvs.podcastplayer.view.component.episode.adapter.EpisodeAdapter +import br.ufpe.cin.vrvs.podcastplayer.view.component.image.ImageButtonComponent + +class EpisodeListComponent @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +): FrameLayout(context, attrs, defStyleAttr) { + + private val list: RecyclerView by lazy { findViewById(R.id.list_episode) } + + private val observerItem: Observer + private val observerButton: Observer> + + private val _itemClicked = MutableLiveData() + val itemClicked: LiveData = _itemClicked + private val _buttonClicked = MutableLiveData>() + val buttonClicked: LiveData> = _buttonClicked + + init { + LayoutInflater.from(context).inflate(R.layout.episode_list_component, this, true) + list.layoutManager = LinearLayoutManager(context) + observerItem = Observer { + _itemClicked.postValue(it) + } + observerButton = Observer { + _buttonClicked.postValue(it) + } + list.adapter = EpisodeAdapter(context).apply { + itemClicked.observeForever(observerItem) + buttonClicked.observeForever(observerButton) + } + } + + fun changeDataSet(dataSet: List) { + (list.adapter as EpisodeAdapter).dataSet = dataSet + } + + override fun onVisibilityChanged( + changedView: View, + visibility: Int + ) { + super.onVisibilityChanged(changedView, visibility) + (list.adapter as EpisodeAdapter).notifyDataSetChanged() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + (list.adapter as EpisodeAdapter).apply{ + itemClicked.removeObserver(observerItem) + buttonClicked.removeObserver(observerButton) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/view/component/episode/adapter/EpisodeAdapter.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/view/component/episode/adapter/EpisodeAdapter.kt new file mode 100644 index 0000000..63e68d2 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/view/component/episode/adapter/EpisodeAdapter.kt @@ -0,0 +1,95 @@ +package br.ufpe.cin.vrvs.podcastplayer.view.component.episode.adapter + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.recyclerview.widget.RecyclerView +import br.ufpe.cin.vrvs.podcastplayer.R +import br.ufpe.cin.vrvs.podcastplayer.data.model.Episode +import br.ufpe.cin.vrvs.podcastplayer.services.download.DownloadUtils.getDuration +import br.ufpe.cin.vrvs.podcastplayer.services.download.DownloadUtils.isDownloaded +import br.ufpe.cin.vrvs.podcastplayer.services.download.DownloadUtils.isInProgress +import br.ufpe.cin.vrvs.podcastplayer.utils.Utils +import br.ufpe.cin.vrvs.podcastplayer.view.component.image.ImageButtonComponent +import br.ufpe.cin.vrvs.podcastplayer.view.component.image.SquareRoundedImageComponent + +internal class EpisodeAdapter(val context: Context) : RecyclerView.Adapter() { + + var dataSet: List = emptyList() + set(value) { + field = value + notifyDataSetChanged() + } + + private val _itemClicked = MutableLiveData() + val itemClicked: LiveData = _itemClicked + private val _buttonClicked = MutableLiveData>() + val buttonClicked: LiveData> = _buttonClicked + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val title: TextView by lazy { view.findViewById(R.id.title) } + val date: TextView by lazy { view.findViewById(R.id.date) } + val squareRoundedImageComponent: SquareRoundedImageComponent by lazy { view.findViewById(R.id.image_component) } + val description: TextView by lazy { view.findViewById(R.id.description) } + val duration: TextView by lazy { view.findViewById(R.id.duration) } + val playPause: ImageButtonComponent by lazy { view.findViewById(R.id.play_pause) } + val downloadStopDelete: ImageButtonComponent by lazy { view.findViewById(R.id.download_stop_delete) } + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int) = + ViewHolder( + LayoutInflater.from(viewGroup.context) + .inflate( + R.layout.episode_component, + viewGroup, + false) + ) + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val data = dataSet[position] + viewHolder.apply { + title.text = data.title + date.text = Utils.toPrettyDate(data.datePublished) + squareRoundedImageComponent.render(data.imageUrl) + description.text = data.description + + if (data.isDownloaded(context)) { + playPause.setOnClickListener { + _buttonClicked.postValue(Pair(data, playPause.state)) + playPause.nextState() + } + duration.text = Utils.toPrettyDuration(data.getDuration(context)) + downloadStopDelete.state = ImageButtonComponent.Type.DOWNLOADED + playPause.isEnabled = true + } else { + if (data.isInProgress(context)) { + downloadStopDelete.state = ImageButtonComponent.Type.STOP + } else { + downloadStopDelete.state = ImageButtonComponent.Type.DOWNLOAD + } + playPause.isEnabled = false + } + playPause.state = if (data.playing) { + ImageButtonComponent.Type.PAUSE + } else { + ImageButtonComponent.Type.PLAY + } + + downloadStopDelete.setOnClickListener { + _buttonClicked.postValue(Pair(data, downloadStopDelete.state)) + downloadStopDelete.nextState() + } + + // clicked item action + itemView.setOnClickListener { + _itemClicked.postValue(data.id) + } + } + } + + override fun getItemCount() = dataSet.size +} \ No newline at end of file diff --git a/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/view/component/error/ErrorComponent.kt b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/view/component/error/ErrorComponent.kt new file mode 100644 index 0000000..b5fec99 --- /dev/null +++ b/app/src/main/java/br/ufpe/cin/vrvs/podcastplayer/view/component/error/ErrorComponent.kt @@ -0,0 +1,45 @@ +package br.ufpe.cin.vrvs.podcastplayer.view.component.error + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.ImageButton +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import br.ufpe.cin.vrvs.podcastplayer.R +import com.google.android.material.button.MaterialButton + +class ErrorComponent @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +): RelativeLayout(context, attrs, defStyleAttr) { + + enum class Button { + CLOSE, + TRY_AGAIN + } + + private val errorText: TextView by lazy { findViewById(R.id.text_info) } + private val closeButton: ImageButton by lazy { findViewById(R.id.close_button) } + private val buttonError: MaterialButton by lazy { findViewById(R.id.button_error) } + + private val _buttonClicked = MutableLiveData