diff --git a/common/build.gradle b/common/build.gradle index ff92e6b8..b04c6c76 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -103,8 +103,10 @@ dependencies { api 'androidx.appcompat:appcompat:1.1.0' - api "androidx.lifecycle:lifecycle-extensions:2.1.0" - api 'androidx.lifecycle:lifecycle-common-java8:2.1.0' + def lifecycle_version = "2.1.0" + api "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" + api "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" + api "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" api 'androidx.recyclerview:recyclerview:1.1.0' @@ -116,6 +118,7 @@ dependencies { implementation 'com.squareup.retrofit2:retrofit:2.6.2' implementation 'com.squareup.retrofit2:converter-gson:2.6.2' + api "com.google.code.gson:gson:2.8.6" api 'com.google.android.exoplayer:exoplayer:2.9.6' api 'com.github.bumptech.glide:glide:4.9.0' diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/eventimport/FahrplanExport.kt b/common/src/main/java/de/nicidienase/chaosflix/common/eventimport/FahrplanExport.kt new file mode 100644 index 00000000..08cdfe49 --- /dev/null +++ b/common/src/main/java/de/nicidienase/chaosflix/common/eventimport/FahrplanExport.kt @@ -0,0 +1,6 @@ +package de.nicidienase.chaosflix.common.eventimport + +data class FahrplanExport( + val conference: String, + val lectures: List +) \ No newline at end of file diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/eventimport/FahrplanLecture.kt b/common/src/main/java/de/nicidienase/chaosflix/common/eventimport/FahrplanLecture.kt new file mode 100644 index 00000000..de7ba99f --- /dev/null +++ b/common/src/main/java/de/nicidienase/chaosflix/common/eventimport/FahrplanLecture.kt @@ -0,0 +1,35 @@ +package de.nicidienase.chaosflix.common.eventimport + +import com.google.gson.annotations.SerializedName + +data class FahrplanLecture( + var lectureId: String = "", + var title: String, + var subtitle: String = "", + var day: Int = 0, + var room: String? = null, + var slug: String? = null, + var url: String? = null, + var startTime: Int = 0, + var duration: Int = 0, + var speakers: String? = null, + var track: String? = null, + var type: String? = null, + var lang: String? = null, + @SerializedName("abstractt") + var abstract: String = "", + var description: String = "", + var relStartTime: Int = 0, + var links: String? = null, + var date: String? = null, + var dateUTC: Long = 0, + var roomIndex: Int = 0, + var recordingLicense: String? = null, + var recordingOptOut: Boolean = false +) { + + companion object { + val RECORDING_OPTOUT_ON = true + val RECORDING_OPTOUT_OFF = false + } +} diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/persistence/EventDao.kt b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/persistence/EventDao.kt index 0ec0b8da..cba9d17d 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/persistence/EventDao.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/persistence/EventDao.kt @@ -55,6 +55,12 @@ abstract class EventDao : BaseDao() { @Query("DElETE FROM event") abstract fun delete() + @Query("SELECT * FROM event WHERE link = :url LIMIT 1") + abstract suspend fun findEventByFahrplanUrl(url: String): Event? + + @Query("SELECT * FROM event WHERE title LIKE :search LIMIT 1") + abstract suspend fun findEventByTitleSuspend(search: String): Event? + override fun updateOrInsertInternal(item: Event) { if (item.id != 0L) { update(item) diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/ApiFactory.kt b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/ApiFactory.kt index 82e7fefd..1c578b40 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/ApiFactory.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/ApiFactory.kt @@ -41,6 +41,15 @@ class ApiFactory private constructor(val res: Resources) { .build() .create(StreamingApi::class.java) } + val fahrplanMappingApi: FahrplanMappingService by lazy { + Retrofit.Builder() + .baseUrl("https://gist.githubusercontent.com") + .client(client) + .addConverterFactory(gsonConverterFactory) + .build() + .create(FahrplanMappingService::class.java) + } + private val useragentInterceptor: Interceptor = Interceptor { chain -> val requestWithUseragent = chain.request().newBuilder() .header("User-Agent", chaosflixUserAgent) diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/FahrplanMappingService.kt b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/FahrplanMappingService.kt new file mode 100644 index 00000000..fb1005b2 --- /dev/null +++ b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/FahrplanMappingService.kt @@ -0,0 +1,9 @@ +package de.nicidienase.chaosflix.common.mediadata.network + +import retrofit2.http.GET + +interface FahrplanMappingService { + + @GET("NiciDieNase/d8bbb9f7b73efddd0cf6e6c4aa93f3ba/raw/02f8604bac4a9a150037eb82aeaf5bc7194d2682/fahrplan_mappings.json") + suspend fun getFahrplanMappings(): Map> +} \ No newline at end of file diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/RecordingService.kt b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/RecordingService.kt index 17ad1485..9a0e6c6d 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/RecordingService.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/RecordingService.kt @@ -21,6 +21,9 @@ interface RecordingService { @GET("public/conferences/{name}") fun getConferenceByName(@Path("name") name: String): Call + @GET("public/conferences/{name}") + suspend fun getConferenceByNameSuspending(@Path("name") name: String): ConferenceDto? + @GET("public/events/{id}") fun getEvent(@Path("id") id: Long): Call diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/sync/Downloader.kt b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/sync/Downloader.kt index a86a21fe..f4ae43f8 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/sync/Downloader.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/sync/Downloader.kt @@ -2,7 +2,6 @@ package de.nicidienase.chaosflix.common.mediadata.sync import androidx.lifecycle.LiveData import de.nicidienase.chaosflix.common.ChaosflixDatabase -import de.nicidienase.chaosflix.common.mediadata.entities.recording.ConferenceDto import de.nicidienase.chaosflix.common.mediadata.entities.recording.ConferencesWrapper import de.nicidienase.chaosflix.common.mediadata.entities.recording.EventDto import de.nicidienase.chaosflix.common.mediadata.entities.recording.RecordingDto @@ -16,6 +15,10 @@ import de.nicidienase.chaosflix.common.util.ConferenceUtil import de.nicidienase.chaosflix.common.util.LiveEvent import de.nicidienase.chaosflix.common.util.SingleLiveEvent import de.nicidienase.chaosflix.common.util.ThreadHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import retrofit2.Response import java.io.IOException @@ -26,6 +29,9 @@ class Downloader( private val threadHandler = ThreadHandler() + private val supervisorJob = SupervisorJob() + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO + supervisorJob) + enum class DownloaderState { RUNNING, DONE } @@ -62,23 +68,13 @@ class Downloader( fun updateEventsForConference(conference: Conference): LiveData, String>> { val updateState = SingleLiveEvent, String>>() updateState.postValue(LiveEvent(DownloaderState.RUNNING)) - threadHandler.runOnBackgroundThread { - val response: Response? + coroutineScope.launch { try { - response = recordingApi.getConferenceByName(conference.acronym).execute() + val list = + updateEventsForConferencesSuspending(conference) + updateState.postValue(LiveEvent(DownloaderState.DONE, data = list)) } catch (e: IOException) { updateState.postValue(LiveEvent(DownloaderState.DONE, error = e.message)) - return@runOnBackgroundThread - } - if (!response.isSuccessful) { - updateState.postValue(LiveEvent(DownloaderState.DONE, error = response.message())) - return@runOnBackgroundThread - } - try { - val persistentEvents = response.body()?.events?.let { events -> - return@let saveEvents(conference, events) - } - updateState.postValue(LiveEvent(DownloaderState.DONE, data = persistentEvents)) } catch (e: Exception) { updateState.postValue(LiveEvent(DownloaderState.DONE, error = "Error updating Events for ${conference.acronym}")) e.printStackTrace() @@ -87,6 +83,16 @@ class Downloader( return updateState } + internal suspend fun updateEventsForConferencesSuspending(conference: Conference): List { + val conferenceByName = recordingApi.getConferenceByNameSuspending(conference.acronym) + val events = conferenceByName?.events + return if (events != null) { + saveEvents(conference, events) + } else { + emptyList() + } + } + fun updateRecordingsForEvent(event: Event): LiveData, String>> { val updateState = SingleLiveEvent, String>>() diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/FavoritesImportViewModel.kt b/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/FavoritesImportViewModel.kt new file mode 100644 index 00000000..dd6b0ed3 --- /dev/null +++ b/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/FavoritesImportViewModel.kt @@ -0,0 +1,76 @@ +package de.nicidienase.chaosflix.common.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.gson.Gson +import de.nicidienase.chaosflix.common.eventimport.FahrplanExport +import de.nicidienase.chaosflix.common.mediadata.entities.recording.persistence.ConferenceDao +import de.nicidienase.chaosflix.common.mediadata.entities.recording.persistence.Event +import de.nicidienase.chaosflix.common.mediadata.entities.recording.persistence.EventDao +import de.nicidienase.chaosflix.common.mediadata.network.FahrplanMappingService +import de.nicidienase.chaosflix.common.mediadata.sync.Downloader +import de.nicidienase.chaosflix.common.util.LiveEvent +import de.nicidienase.chaosflix.common.util.SingleLiveEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class FavoritesImportViewModel( + private val conferenceDao: ConferenceDao, + private val eventDao: EventDao, + private val downloader: Downloader, + private val mappingService: FahrplanMappingService +) : ViewModel() { + + val state: SingleLiveEvent, Exception>> = SingleLiveEvent() + + fun handleUrls(urls: List) { + viewModelScope.launch { + val events = urls.mapNotNull { eventDao.findEventByFahrplanUrl(it) } + state.postValue(LiveEvent(State.EVENTS_FOUND, events, null)) + } + } + + fun handleLectures(string: String) { + state.postValue(LiveEvent(State.WORKING)) + viewModelScope.launch(Dispatchers.IO) { + val export = Gson().fromJson(string, FahrplanExport::class.java) + updateConferences(export.conference) + val events: List + try { + events = export.lectures.mapNotNull { eventDao.findEventByTitleSuspend(it.title) } + state.postValue(LiveEvent(State.EVENTS_FOUND, events, null)) + } catch (e: Exception) { + state.postValue(LiveEvent(State.ERROR, null, e)) + } + } + } + + private suspend fun updateConferences(conference: String) { + val fahrplanMappings = mappingService.getFahrplanMappings() + Log.d(TAG, "Updating conferences for $conference, mappings=$fahrplanMappings") + if (fahrplanMappings.containsKey(conference)) { + fahrplanMappings[conference]?.let { keys -> + for (conf in keys) { + conferenceDao.findConferenceByAcronymSync(conf)?.let { conf -> + val list = + downloader.updateEventsForConferencesSuspending(conf) + Log.d(TAG, "updated ${conf.acronym}, got ${list.size} events") + } + } + } + } else { + Log.d(TAG, "Did not update any conferences") + } + } + + enum class State { + WORKING, + EVENTS_FOUND, + ERROR + } + + companion object { + private val TAG = FavoritesImportViewModel::class.java.simpleName + } +} diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/ViewModelFactory.kt b/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/ViewModelFactory.kt index 590e8ef7..b13e22ed 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/ViewModelFactory.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/ViewModelFactory.kt @@ -1,11 +1,10 @@ package de.nicidienase.chaosflix.common.viewmodel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import android.content.Context -import android.content.res.Resources import android.os.Environment import android.preference.PreferenceManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import de.nicidienase.chaosflix.common.ChaosflixDatabase import de.nicidienase.chaosflix.common.OfflineItemManager import de.nicidienase.chaosflix.common.PreferencesManager @@ -22,29 +21,53 @@ class ViewModelFactory private constructor(context: Context) : ViewModelProvider private val recordingApi = apiFactory.recordingApi private val streamingApi = apiFactory.streamingApi private val preferencesManager = - PreferencesManager(PreferenceManager.getDefaultSharedPreferences(context.applicationContext)) + PreferencesManager(PreferenceManager.getDefaultSharedPreferences(context.applicationContext)) private val offlineItemManager = - OfflineItemManager(context.applicationContext, database.offlineEventDao(), preferencesManager) + OfflineItemManager( + context.applicationContext, + database.offlineEventDao(), + preferencesManager + ) private val downloader by lazy { Downloader(recordingApi, database) } private val externalFilesDir = Environment.getExternalStorageDirectory() private val resourcesFacade by lazy { ResourcesFacade(context) } @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(BrowseViewModel::class.java)) { - return BrowseViewModel(offlineItemManager, database, recordingApi, streamingApi, preferencesManager, resourcesFacade) as T - } else if (modelClass.isAssignableFrom(PlayerViewModel::class.java)) { - return PlayerViewModel(database) as T - } else if (modelClass.isAssignableFrom(DetailsViewModel::class.java)) { - return DetailsViewModel(database, offlineItemManager, preferencesManager, downloader) as T - } else if (modelClass.isAssignableFrom(PreferencesViewModel::class.java)) { - return PreferencesViewModel(downloader, database.watchlistItemDao(), externalFilesDir) as T - } else { - throw UnsupportedOperationException("The requested ViewModel is currently unsupported. " + - "Please make sure to implement are correct creation of it. " + - " Request: ${modelClass.canonicalName}") + return when (modelClass) { + BrowseViewModel::class.java -> BrowseViewModel( + offlineItemManager, + database, + recordingApi, + streamingApi, + preferencesManager, + resourcesFacade + ) as T + PlayerViewModel::class.java -> PlayerViewModel(database) as T + DetailsViewModel::class.java -> DetailsViewModel( + database, + offlineItemManager, + preferencesManager, + downloader + ) as T + PreferencesViewModel::class.java -> PreferencesViewModel( + downloader, + database.watchlistItemDao(), + externalFilesDir + ) as T + FavoritesImportViewModel::class.java -> FavoritesImportViewModel( + database.conferenceDao(), + database.eventDao(), + downloader, + apiFactory.fahrplanMappingApi + ) as T + else -> throw UnsupportedOperationException( + "The requested ViewModel is currently unsupported. " + + "Please make sure to implement are correct creation of it. " + + " Request: ${modelClass.canonicalName}" + ) } } - companion object : SingletonHolder(::ViewModelFactory) - } \ No newline at end of file + companion object : SingletonHolder(::ViewModelFactory) +} \ No newline at end of file diff --git a/touch/src/main/AndroidManifest.xml b/touch/src/main/AndroidManifest.xml index 87cc5f60..61e10bdc 100644 --- a/touch/src/main/AndroidManifest.xml +++ b/touch/src/main/AndroidManifest.xml @@ -59,6 +59,13 @@ android:parentActivityName=".browse.BrowseActivity"/> + + + + + + + (R.id.toolbar) + setSupportActionBar(toolbar) + supportActionBar?.title = "Import" + } + + companion object { + private val TAG = FavoritesImportActivity::class.java.simpleName + } +} \ No newline at end of file diff --git a/touch/src/main/java/de/nicidienase/chaosflix/touch/FavoritesImportFragment.kt b/touch/src/main/java/de/nicidienase/chaosflix/touch/FavoritesImportFragment.kt new file mode 100644 index 00000000..528da11e --- /dev/null +++ b/touch/src/main/java/de/nicidienase/chaosflix/touch/FavoritesImportFragment.kt @@ -0,0 +1,90 @@ +package de.nicidienase.chaosflix.touch + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.LinearLayoutManager +import de.nicidienase.chaosflix.common.viewmodel.FavoritesImportViewModel +import de.nicidienase.chaosflix.common.viewmodel.ViewModelFactory +import de.nicidienase.chaosflix.touch.browse.adapters.EventRecyclerViewAdapter +import de.nicidienase.chaosflix.touch.databinding.FragmentFavoritesImportBinding + +class FavoritesImportFragment : Fragment() { + + private lateinit var viewModel: FavoritesImportViewModel + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val binding = FragmentFavoritesImportBinding.inflate(inflater, container, false) + + viewModel = ViewModelProviders.of(this, ViewModelFactory.getInstance(requireContext())).get(FavoritesImportViewModel::class.java) + + binding.importList.layoutManager = LinearLayoutManager(context) + val eventRecyclerViewAdapter = EventRecyclerViewAdapter { + } + binding.importList.adapter = eventRecyclerViewAdapter + + viewModel.state.observe(this, Observer { + when (it.state) { + FavoritesImportViewModel.State.EVENTS_FOUND -> { + it.data?.let { events -> + eventRecyclerViewAdapter.items = events + eventRecyclerViewAdapter.notifyDataSetChanged() + } + binding.incOverlay.loadingOverlay.visibility = View.GONE + } + FavoritesImportViewModel.State.WORKING -> { + binding.incOverlay.loadingOverlay.visibility = View.VISIBLE + } + FavoritesImportViewModel.State.ERROR -> { + Log.e(TAG, "Error", it.error) + } + } + }) + + val intent = activity?.intent + when { + intent?.action == Intent.ACTION_SEND -> { + when (intent.type) { + "text/plain" -> handleSendText(intent) + "text/json" -> handleJson(intent) + } + } + else -> { + // Handle other intents, such as being started from the home screen + } + } + + return binding.root + } + + private fun handleSendText(intent: Intent) { + val extra = intent.getStringExtra(Intent.EXTRA_TEXT) + Log.d(TAG, extra) + if (extra != null) { + val urls = extra.split("\n").filter { it.startsWith("http") } + Log.d(TAG, "Urls: $urls") + viewModel.handleUrls(urls) + } + } + + private fun handleJson(intent: Intent) { + val extra = intent.getStringExtra(Intent.EXTRA_TEXT) + if (extra.isNotEmpty()) { + viewModel.handleLectures(extra) + } + } + + companion object { + private val TAG = FavoritesImportFragment::class.java.simpleName + } +} \ No newline at end of file diff --git a/touch/src/main/res/layout/activity_favorites_import.xml b/touch/src/main/res/layout/activity_favorites_import.xml new file mode 100644 index 00000000..836f47fc --- /dev/null +++ b/touch/src/main/res/layout/activity_favorites_import.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + diff --git a/touch/src/main/res/layout/fragment_favorites_import.xml b/touch/src/main/res/layout/fragment_favorites_import.xml new file mode 100644 index 00000000..3078938c --- /dev/null +++ b/touch/src/main/res/layout/fragment_favorites_import.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file