add activity to import events from Fahrplan-App

This commit is contained in:
Felix 2019-12-29 00:44:23 +01:00
parent 0441c4d4e9
commit f92cf0e31c
15 changed files with 378 additions and 36 deletions

View file

@ -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'

View file

@ -0,0 +1,6 @@
package de.nicidienase.chaosflix.common.eventimport
data class FahrplanExport(
val conference: String,
val lectures: List<FahrplanLecture>
)

View file

@ -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
}
}

View file

@ -55,6 +55,12 @@ abstract class EventDao : BaseDao<Event>() {
@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)

View file

@ -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)

View file

@ -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<String, List<String>>
}

View file

@ -21,6 +21,9 @@ interface RecordingService {
@GET("public/conferences/{name}")
fun getConferenceByName(@Path("name") name: String): Call<ConferenceDto>
@GET("public/conferences/{name}")
suspend fun getConferenceByNameSuspending(@Path("name") name: String): ConferenceDto?
@GET("public/events/{id}")
fun getEvent(@Path("id") id: Long): Call<EventDto>

View file

@ -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<LiveEvent<DownloaderState, List<Event>, String>> {
val updateState = SingleLiveEvent<LiveEvent<DownloaderState, List<Event>, String>>()
updateState.postValue(LiveEvent(DownloaderState.RUNNING))
threadHandler.runOnBackgroundThread {
val response: Response<ConferenceDto>?
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<Event> {
val conferenceByName = recordingApi.getConferenceByNameSuspending(conference.acronym)
val events = conferenceByName?.events
return if (events != null) {
saveEvents(conference, events)
} else {
emptyList()
}
}
fun updateRecordingsForEvent(event: Event):
LiveData<LiveEvent<DownloaderState, List<Recording>, String>> {
val updateState = SingleLiveEvent<LiveEvent<DownloaderState, List<Recording>, String>>()

View file

@ -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<LiveEvent<State, List<Event>, Exception>> = SingleLiveEvent()
fun handleUrls(urls: List<String>) {
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<Event>
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
}
}

View file

@ -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
@ -24,27 +23,51 @@ class ViewModelFactory private constructor(context: Context) : ViewModelProvider
private val preferencesManager =
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 <T : ViewModel?> create(modelClass: Class<T>): 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. " +
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}")
" Request: ${modelClass.canonicalName}"
)
}
}
companion object : SingletonHolder<ViewModelFactory, Context>(::ViewModelFactory)
}
companion object : SingletonHolder<ViewModelFactory, Context>(::ViewModelFactory)
}

View file

@ -59,6 +59,13 @@
android:parentActivityName=".browse.BrowseActivity"/>
<activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" />
<activity android:name=".FavoritesImportActivity" >
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/json" />
</intent-filter>
</activity>
<service
android:name="de.nicidienase.chaosflix.common.mediadata.sync.DownloadJobService"

View file

@ -0,0 +1,20 @@
package de.nicidienase.chaosflix.touch
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
class FavoritesImportActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_favorites_import)
val toolbar = findViewById<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
supportActionBar?.title = "Import"
}
companion object {
private val TAG = FavoritesImportActivity::class.java.simpleName
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/import_root"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:orientation="vertical">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include
android:id="@+id/inc_toolbar"
layout="@layout/toolbar"/>
</com.google.android.material.appbar.AppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<fragment
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="de.nicidienase.chaosflix.touch.FavoritesImportFragment"/>
</LinearLayout>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/import_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:listitem="@layout/item_event_cardview" />
<include
android:id="@+id/inc_overlay"
layout="@layout/loading_overlay"/>
</FrameLayout>
</layout>