├── .gitignore ├── LICENSE ├── PRIVACY_POLICY.md ├── README.md ├── app ├── build.gradle ├── proguard-rules.pro ├── schemas │ └── de.stefanmedack.ccctv.persistence.C3Db │ │ ├── 4.json │ │ └── 5.json └── src │ ├── androidTest │ └── java │ │ └── de │ │ └── stefanmedack │ │ └── ccctv │ │ └── persistence │ │ ├── BaseDbTest.kt │ │ ├── BookmarkDaoTest.kt │ │ ├── ConferenceDaoTest.kt │ │ ├── EventDaoTest.kt │ │ └── PlayPositionDaoTest.kt │ ├── debug │ └── res │ │ ├── mipmap-hdpi │ │ └── app_banner.png │ │ ├── mipmap-mdpi │ │ └── app_banner.png │ │ ├── mipmap-xhdpi │ │ └── app_banner.png │ │ ├── mipmap-xxhdpi │ │ └── app_banner.png │ │ └── mipmap-xxxhdpi │ │ └── app_banner.png │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-web.png │ ├── java │ │ └── de │ │ │ └── stefanmedack │ │ │ └── ccctv │ │ │ ├── C3TVApp.kt │ │ │ ├── di │ │ │ ├── AppComponent.kt │ │ │ ├── C3ViewModelFactory.kt │ │ │ ├── Scopes.kt │ │ │ └── modules │ │ │ │ ├── ActivityBuilderModule.kt │ │ │ │ ├── C3MediaModule.kt │ │ │ │ ├── DatabaseModule.kt │ │ │ │ └── ViewModelModule.kt │ │ │ ├── model │ │ │ ├── ConferenceGroup.kt │ │ │ └── Resource.kt │ │ │ ├── persistence │ │ │ ├── C3Db.kt │ │ │ ├── C3TypeConverters.kt │ │ │ ├── EntityMapper.kt │ │ │ ├── daos │ │ │ │ ├── BookmarkDao.kt │ │ │ │ ├── ConferenceDao.kt │ │ │ │ ├── EventDao.kt │ │ │ │ └── PlayPositionDao.kt │ │ │ └── entities │ │ │ │ ├── Bookmark.kt │ │ │ │ ├── Conference.kt │ │ │ │ ├── ConferenceWithEvents.kt │ │ │ │ ├── Event.kt │ │ │ │ ├── LanguageList.kt │ │ │ │ └── PlayPosition.kt │ │ │ ├── repository │ │ │ ├── ConferenceRepository.kt │ │ │ ├── EventRepository.kt │ │ │ ├── NetworkBoundResource.kt │ │ │ └── StreamingRepository.kt │ │ │ ├── service │ │ │ ├── C3BroadcastReceiver.kt │ │ │ └── ContentUpdateService.kt │ │ │ ├── ui │ │ │ ├── about │ │ │ │ ├── AboutDescriptionPresenter.kt │ │ │ │ └── AboutFragment.kt │ │ │ ├── base │ │ │ │ ├── BaseDisposableViewModel.kt │ │ │ │ └── BaseInjectableActivity.kt │ │ │ ├── cards │ │ │ │ ├── ConferenceCardPresenter.kt │ │ │ │ ├── EventCardPresenter.kt │ │ │ │ ├── SpeakerCardPresenter.kt │ │ │ │ ├── SpeakerCardView.kt │ │ │ │ └── StreamCardPresenter.kt │ │ │ ├── detail │ │ │ │ ├── DetailActivity.kt │ │ │ │ ├── DetailContract.kt │ │ │ │ ├── DetailDescriptionPresenter.kt │ │ │ │ ├── DetailFragment.kt │ │ │ │ ├── DetailModule.kt │ │ │ │ ├── DetailViewModel.kt │ │ │ │ ├── playback │ │ │ │ │ ├── BaseExoPlayerAdapter.kt │ │ │ │ │ ├── ExoPlayerAdapter.kt │ │ │ │ │ ├── VideoMediaPlayerGlue.kt │ │ │ │ │ └── actions │ │ │ │ │ │ └── AspectRatioAction.kt │ │ │ │ └── uiModels │ │ │ │ │ ├── DetailUiModel.kt │ │ │ │ │ ├── SpeakerUiModel.kt │ │ │ │ │ └── VideoPlaybackUiModel.kt │ │ │ ├── events │ │ │ │ ├── EventsActivity.kt │ │ │ │ ├── EventsFragment.kt │ │ │ │ ├── EventsModule.kt │ │ │ │ └── EventsViewModel.kt │ │ │ ├── main │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── MainFragment.kt │ │ │ │ ├── MainFragmentFactory.kt │ │ │ │ ├── MainModule.kt │ │ │ │ ├── MainViewModel.kt │ │ │ │ ├── conferences │ │ │ │ │ ├── ConferencesFragment.kt │ │ │ │ │ └── ConferencesViewModel.kt │ │ │ │ ├── home │ │ │ │ │ ├── HomeContract.kt │ │ │ │ │ ├── HomeFragment.kt │ │ │ │ │ ├── HomeViewModel.kt │ │ │ │ │ └── uiModel │ │ │ │ │ │ └── HomeUiModel.kt │ │ │ │ └── streaming │ │ │ │ │ ├── LiveStreamingFragment.kt │ │ │ │ │ └── LiveStreamingViewModel.kt │ │ │ ├── search │ │ │ │ ├── SearchActivity.kt │ │ │ │ ├── SearchFragment.kt │ │ │ │ ├── SearchModule.kt │ │ │ │ ├── SearchViewModel.kt │ │ │ │ └── uiModels │ │ │ │ │ └── SearchResultUiModel.kt │ │ │ └── streaming │ │ │ │ ├── StreamingMediaPlayerGlue.kt │ │ │ │ ├── StreamingPlayerActivity.kt │ │ │ │ ├── StreamingPlayerAdapter.kt │ │ │ │ └── StreamingPlayerFragment.kt │ │ │ └── util │ │ │ ├── AndroidExtensions.kt │ │ │ ├── Constants.kt │ │ │ ├── LeanbackExtensions.kt │ │ │ ├── ModelExtensions.kt │ │ │ ├── RxExtensions.kt │ │ │ └── VideoHelper.kt │ └── res │ │ ├── drawable │ │ ├── about_cover.png │ │ ├── ic_aspect_ratio.xml │ │ ├── ic_bookmark_check.xml │ │ ├── ic_bookmark_plus.xml │ │ ├── ic_related.xml │ │ ├── ic_restart.xml │ │ ├── ic_speaker.xml │ │ ├── ic_watch.xml │ │ ├── qr_github.xml │ │ └── voctocat.png │ │ ├── layout │ │ ├── activity_video_example.xml │ │ ├── fragment_activity.xml │ │ ├── grid_fragment.xml │ │ └── speaker_card.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── app_banner.png │ │ ├── ic_launcher.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-mdpi │ │ ├── app_banner.png │ │ ├── ic_launcher.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xhdpi │ │ ├── app_banner.png │ │ ├── ic_launcher.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xxhdpi │ │ ├── app_banner.png │ │ ├── ic_launcher.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xxxhdpi │ │ ├── app_banner.png │ │ ├── ic_launcher.png │ │ └── ic_launcher_foreground.png │ │ ├── values-de │ │ └── strings.xml │ │ └── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── test-common │ └── java │ │ └── de │ │ └── stefanmedack │ │ └── ccctv │ │ ├── ModelTestUtil.kt │ │ └── RxTestUtils.kt │ └── test │ └── java │ └── de │ └── stefanmedack │ └── ccctv │ ├── repository │ ├── ConferenceRepositoryTest.kt │ ├── EventRepositoryTest.kt │ └── NetworkBoundResourceTest.kt │ ├── ui │ └── detail │ │ └── DetailViewModelTest.kt │ └── util │ └── EventExtensionsTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # Files for the ART/Dalvik VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # Generated files 12 | bin/ 13 | gen/ 14 | out/ 15 | 16 | # Gradle files 17 | .gradle/ 18 | build/ 19 | 20 | # Local configuration file (sdk path, etc) 21 | local.properties 22 | 23 | # Proguard folder generated by Eclipse 24 | proguard/ 25 | 26 | # Log Files 27 | *.log 28 | 29 | # Android Studio Navigation editor temp files 30 | .navigation/ 31 | 32 | # Android Studio captures folder 33 | captures/ 34 | 35 | # Intellij 36 | *.iml 37 | .idea/* 38 | .idea/workspace.xml 39 | .idea/tasks.xml 40 | .idea/gradle.xml 41 | .idea/dictionaries 42 | .idea/libraries 43 | .idea/encodings.xml 44 | .idea/misc.xml 45 | .idea/modules.xml 46 | .idea/runConfigurations.xml 47 | .idea/vcs.xml 48 | 49 | # Keystore files 50 | *.jks 51 | 52 | # External native build folder generated in Android Studio 2.2 and later 53 | .externalNativeBuild 54 | 55 | # Google Services (e.g. APIs or Firebase) 56 | google-services.json 57 | 58 | # Freeline 59 | freeline.py 60 | freeline/ 61 | freeline_project_description.json 62 | 63 | # Play Services Third Party Licenses 64 | third_party_license_metadata 65 | third_party_licenses 66 | -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | # cccTV - Privacy Policy 2 | 3 | While browsing through the App normally we do not save any information which could be used to identify a person. 4 | The log files are anonymized before they are saved and we do not have a de-anonymized version of the them. 5 | The anonymized log-files may be used to plot usage statistics. 6 | 7 | Besides this, we use the media.ccc.de API offered by [Chaos Computer Club e.V.][ccc]. 8 | The Privacy Policy for this API can be found [here][media-ccc-policy] 9 | 10 | [ccc]: https://ccc.de/ 11 | [media-ccc-policy]: https://media.ccc.de/about.html 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Apache License](http://img.shields.io/badge/license-Apache%20License%202.0-blue.svg)](http://choosealicense.com/licenses/apache-2.0/) 2 | 3 | [Get it on Google Play][play]     **OR**     [Available at Amazon AppStore][amazon]     **OR**     [Download an APK][releases] 4 | 5 | # cccTV 6 | 7 | An Android TV App for the media API of the Chaos Computer Club e.V. (CCC) written in Kotlin. 8 | 9 | * https://media.ccc.de 10 | 11 | This App uses [c3media-base][c3media-base-orig] by [Tobias Preuss][tobias-preuss]. 12 | In case Gradle can not find this dependency, check out the sources and deploy them locally. 13 | 14 | The Logo ["Voctocat"][voctocat] is kindly provided by [Blinry][blinry] under CC BY-NC-SA 4.0 License. 15 | 16 | ## Screenshots 17 | 18 | Screenshot #1 Screenshot #2 21 | 22 | Screenshot #3 Screenshot #4 25 | 26 | ## Setup 27 | 28 | In the root project you can find `gradle.properties` defining the signing configuration for the `release`-Build. 29 | If you want to build a `release` version, it is important to replace the placeholders defined there by correct signing credentials. 30 | 31 | ## Author 32 | 33 | * [Stefan Medack][stefan] 34 | 35 | ## License 36 | 37 | Copyright 2017 Stefan Medack 38 | 39 | Licensed under the Apache License, Version 2.0 (the "License"); 40 | you may not use this file except in compliance with the License. 41 | You may obtain a copy of the License at 42 | 43 | http://www.apache.org/licenses/LICENSE-2.0 44 | 45 | Unless required by applicable law or agreed to in writing, software 46 | distributed under the License is distributed on an "AS IS" BASIS, 47 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 48 | See the License for the specific language governing permissions and 49 | limitations under the License. 50 | 51 | [c3media-base-orig]: https://github.com/johnjohndoe/c3media-base 52 | [tobias-preuss]: https://github.com/johnjohndoe 53 | [blinry]: https://github.com/blinry 54 | [stefan]: https://twitter.com/Zonic03 55 | [voctocat]: https://morr.cc/voctocat/ 56 | 57 | [play]: https://play.google.com/store/apps/details?id=de.stefanmedack.ccctv 58 | [amazon]: https://www.amazon.de/cccTV-Chaos-Computer-Club-Videos/dp/B0787JP7RF 59 | [releases]: https://github.com/stefanmedack/cccTV/releases 60 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /Users/stefanmedack/Library/Android/sdk/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | 19 | # Uncomment this to preserve the line number information for 20 | # debugging stack traces. 21 | #-keepattributes SourceFile,LineNumberTable 22 | 23 | # If you keep the line number information, uncomment this to 24 | # hide the original source file name. 25 | #-renamesourcefileattribute SourceFile 26 | -------------------------------------------------------------------------------- /app/src/androidTest/java/de/stefanmedack/ccctv/persistence/BaseDbTest.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence 2 | 3 | import android.arch.core.executor.testing.InstantTaskExecutorRule 4 | import android.arch.persistence.room.Room 5 | import android.support.test.InstrumentationRegistry 6 | import de.stefanmedack.ccctv.minimalConferenceEntity 7 | import de.stefanmedack.ccctv.minimalEventEntity 8 | import org.junit.After 9 | import org.junit.Before 10 | import org.junit.Rule 11 | 12 | abstract class BaseDbTest { 13 | 14 | @get:Rule 15 | var instantTaskExecutorRule = InstantTaskExecutorRule() 16 | 17 | protected lateinit var db: C3Db 18 | 19 | @Before 20 | fun initDb() { 21 | db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getContext(), C3Db::class.java) 22 | .allowMainThreadQueries() 23 | .build() 24 | } 25 | 26 | @After 27 | fun closeDb() { 28 | db.close() 29 | } 30 | 31 | val bookmarkDao get() = db.bookmarkDao() 32 | val conferenceDao get() = db.conferenceDao() 33 | val eventDao get() = db.eventDao() 34 | val playPositionDao get() = db.playPositionDao() 35 | 36 | fun initDbWithConference(conferenceAcronym: String) { 37 | conferenceDao.insert(minimalConferenceEntity.copy(acronym = conferenceAcronym)) 38 | } 39 | 40 | fun initDbWithConferenceAndEvent(conferenceAcronym: String, eventId: String) { 41 | conferenceDao.insert(minimalConferenceEntity.copy(acronym = conferenceAcronym)) 42 | eventDao.insert(minimalEventEntity.copy(id = eventId, conferenceAcronym = conferenceAcronym)) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /app/src/androidTest/java/de/stefanmedack/ccctv/persistence/BookmarkDaoTest.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence 2 | 3 | import android.database.sqlite.SQLiteException 4 | import android.support.test.runner.AndroidJUnit4 5 | import de.stefanmedack.ccctv.getSingleTestResult 6 | import de.stefanmedack.ccctv.minimalEventEntity 7 | import de.stefanmedack.ccctv.persistence.entities.Bookmark 8 | import org.amshove.kluent.shouldBeInstanceOf 9 | import org.amshove.kluent.shouldEqual 10 | import org.amshove.kluent.shouldNotEqual 11 | import org.junit.Before 12 | import org.junit.Test 13 | import org.junit.runner.RunWith 14 | import org.threeten.bp.OffsetDateTime 15 | 16 | @RunWith(AndroidJUnit4::class) 17 | class BookmarkDaoTest : BaseDbTest() { 18 | 19 | private val testConferenceAcronym = "34c3" 20 | 21 | @Before 22 | fun setup() { 23 | initDbWithConferenceAndEvent(conferenceAcronym = testConferenceAcronym, eventId = "8") 24 | } 25 | 26 | @Test 27 | fun get_bookmarked_events_from_empty_table_returns_empty_list() { 28 | 29 | val emptyList = bookmarkDao.getBookmarkedEvents().getSingleTestResult() 30 | 31 | emptyList shouldEqual listOf() 32 | } 33 | 34 | @Test 35 | fun insert_bookmark_without_matching_event_throws_exception() { 36 | val exception = try { 37 | bookmarkDao.insert(Bookmark("42")) 38 | } catch (ex: Exception) { 39 | ex 40 | } 41 | 42 | exception shouldNotEqual null 43 | exception shouldBeInstanceOf SQLiteException::class 44 | } 45 | 46 | @Test 47 | fun insert_and_retrieve_bookmarked_event() { 48 | val eventId = "8" 49 | bookmarkDao.insert(Bookmark(eventId)) 50 | 51 | val bookmarkedEvents = bookmarkDao.getBookmarkedEvents().getSingleTestResult() 52 | 53 | bookmarkedEvents.size shouldEqual 1 54 | bookmarkedEvents.first().id shouldEqual eventId 55 | } 56 | 57 | @Test 58 | fun loading_bookmarked_events_filters_not_bookmarked_events() { 59 | val eventId = "42" 60 | eventDao.insert(minimalEventEntity.copy(conferenceAcronym = testConferenceAcronym, id = eventId)) 61 | bookmarkDao.insert(Bookmark(eventId)) 62 | 63 | val bookmarkedEvents = bookmarkDao.getBookmarkedEvents().getSingleTestResult() 64 | 65 | bookmarkedEvents.size shouldEqual 1 66 | bookmarkedEvents.first().id shouldEqual eventId 67 | } 68 | 69 | @Test 70 | fun loading_bookmarked_events_should_deliver_the_latest_bookmarks_first() { 71 | for (i in 42..44) { 72 | eventDao.insert(minimalEventEntity.copy(conferenceAcronym = testConferenceAcronym, id = "$i")) 73 | } 74 | bookmarkDao.insert(Bookmark(eventId = "42", createdAt = OffsetDateTime.now().minusDays(1))) 75 | bookmarkDao.insert(Bookmark(eventId = "43", createdAt = OffsetDateTime.now())) 76 | bookmarkDao.insert(Bookmark(eventId = "44", createdAt = OffsetDateTime.now().minusDays(2))) 77 | 78 | val bookmarkedEvents = bookmarkDao.getBookmarkedEvents().getSingleTestResult() 79 | 80 | bookmarkedEvents[0].id shouldEqual "43" 81 | bookmarkedEvents[1].id shouldEqual "42" 82 | bookmarkedEvents[2].id shouldEqual "44" 83 | } 84 | 85 | @Test 86 | fun isBookmarked_returns_false_for_not_bookmarked_events() { 87 | 88 | val isBookmarked = bookmarkDao.isBookmarked("8").getSingleTestResult() 89 | 90 | isBookmarked shouldEqual false 91 | } 92 | 93 | @Test 94 | fun isBookmarked_returns_true_for_bookmarked_events() { 95 | val eventId = "8" 96 | bookmarkDao.insert(Bookmark(eventId)) 97 | 98 | val isBookmarked = bookmarkDao.isBookmarked(eventId).getSingleTestResult() 99 | 100 | isBookmarked shouldEqual true 101 | } 102 | 103 | @Test 104 | fun changing_bookmarked_state_should_emit_events() { 105 | val eventId = "8" 106 | val isBookmarkedStream = bookmarkDao.isBookmarked(eventId).test() 107 | 108 | bookmarkDao.insert(Bookmark(eventId)) 109 | bookmarkDao.delete(Bookmark(eventId)) 110 | 111 | isBookmarkedStream.values().let { isBookmarkedValues -> 112 | isBookmarkedValues.size shouldEqual 3 113 | isBookmarkedValues[0] shouldEqual false 114 | isBookmarkedValues[1] shouldEqual true 115 | isBookmarkedValues[2] shouldEqual false 116 | } 117 | } 118 | 119 | @Test 120 | fun delete_bookmark_removes_existing_bookmarks() { 121 | val eventId = "8" 122 | bookmarkDao.insert(Bookmark(eventId)) 123 | bookmarkDao.isBookmarked(eventId).getSingleTestResult() shouldEqual true 124 | 125 | bookmarkDao.delete(Bookmark(eventId)) 126 | bookmarkDao.isBookmarked(eventId).getSingleTestResult() shouldEqual false 127 | } 128 | 129 | @Test 130 | fun delete_bookmark_without_matching_event_does_nothing() { 131 | bookmarkDao.delete(Bookmark("42")) 132 | bookmarkDao.delete(Bookmark("43")) 133 | } 134 | 135 | @Test 136 | fun updating_a_bookmarked_event_does_not_change_bookmark_state() { 137 | val eventId = "8" 138 | val updatedEvent = minimalEventEntity.copy(conferenceAcronym = testConferenceAcronym, id = eventId, title = "updated") 139 | bookmarkDao.insert(Bookmark(eventId)) 140 | eventDao.insert(updatedEvent) 141 | 142 | val bookmarkedEvents = bookmarkDao.getBookmarkedEvents().getSingleTestResult() 143 | 144 | bookmarkedEvents.size shouldEqual 1 145 | bookmarkedEvents.first() shouldEqual updatedEvent 146 | } 147 | 148 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/de/stefanmedack/ccctv/persistence/PlayPositionDaoTest.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence 2 | 3 | import android.arch.persistence.room.EmptyResultSetException 4 | import android.database.sqlite.SQLiteException 5 | import android.support.test.runner.AndroidJUnit4 6 | import de.stefanmedack.ccctv.getSingleTestResult 7 | import de.stefanmedack.ccctv.minimalEventEntity 8 | import de.stefanmedack.ccctv.persistence.entities.PlayPosition 9 | import org.amshove.kluent.shouldBeInstanceOf 10 | import org.amshove.kluent.shouldEqual 11 | import org.amshove.kluent.shouldNotEqual 12 | import org.junit.Before 13 | import org.junit.Test 14 | import org.junit.runner.RunWith 15 | import org.threeten.bp.OffsetDateTime 16 | 17 | @RunWith(AndroidJUnit4::class) 18 | class PlayPositionDaoTest : BaseDbTest() { 19 | 20 | private val testConferenceAcronym = "34c3" 21 | 22 | @Before 23 | fun setup() { 24 | initDbWithConferenceAndEvent(conferenceAcronym = testConferenceAcronym, eventId = "8") 25 | } 26 | 27 | @Test 28 | fun get_played_events_from_empty_table_returns_empty_list() { 29 | 30 | val emptyList = playPositionDao.getPlayedEvents().getSingleTestResult() 31 | 32 | emptyList shouldEqual listOf() 33 | } 34 | 35 | @Test 36 | fun insert_play_position_without_matching_event_throws_exception() { 37 | val exception = try { 38 | playPositionDao.insert(PlayPosition(eventId = "42")) 39 | } catch (ex: SQLiteException) { 40 | ex 41 | } 42 | 43 | exception shouldNotEqual null 44 | exception shouldBeInstanceOf SQLiteException::class 45 | } 46 | 47 | @Test 48 | fun insert_and_retrieve_played_event() { 49 | val eventId = "8" 50 | playPositionDao.insert(PlayPosition(eventId = eventId)) 51 | 52 | val playedEvents = playPositionDao.getPlayedEvents().getSingleTestResult() 53 | 54 | playedEvents.size shouldEqual 1 55 | playedEvents.first().id shouldEqual eventId 56 | } 57 | 58 | @Test 59 | fun loading_played_events_filters_not_played_events() { 60 | val eventId = "42" 61 | eventDao.insert(minimalEventEntity.copy(conferenceAcronym = testConferenceAcronym, id = eventId)) 62 | playPositionDao.insert(PlayPosition(eventId = eventId)) 63 | 64 | val playedEvents = playPositionDao.getPlayedEvents().getSingleTestResult() 65 | 66 | playedEvents.size shouldEqual 1 67 | playedEvents.first().id shouldEqual eventId 68 | } 69 | 70 | @Test 71 | fun loading_played_events_should_deliver_the_latest_played_events_first() { 72 | for (i in 42..44) { 73 | eventDao.insert(minimalEventEntity.copy(conferenceAcronym = testConferenceAcronym, id = "$i")) 74 | } 75 | playPositionDao.insert(PlayPosition(eventId = "42", createdAt = OffsetDateTime.now().minusDays(1))) 76 | playPositionDao.insert(PlayPosition(eventId = "43", createdAt = OffsetDateTime.now())) 77 | playPositionDao.insert(PlayPosition(eventId = "44", createdAt = OffsetDateTime.now().minusDays(2))) 78 | 79 | val playedEvents = playPositionDao.getPlayedEvents().getSingleTestResult() 80 | 81 | playedEvents[0].id shouldEqual "43" 82 | playedEvents[1].id shouldEqual "42" 83 | playedEvents[2].id shouldEqual "44" 84 | } 85 | 86 | @Test 87 | fun loading_playback_seconds_errors_for_not_played_events() { 88 | 89 | val seconds = playPositionDao.getPlaybackSeconds("8").test().errors() 90 | 91 | seconds.first() shouldBeInstanceOf EmptyResultSetException::class.java 92 | } 93 | 94 | @Test 95 | fun loading_playback_seconds_returns_same_seconds_for_played_events() { 96 | val eventId = "8" 97 | playPositionDao.insert(PlayPosition(eventId = eventId, seconds = 123)) 98 | 99 | val seconds = playPositionDao.getPlaybackSeconds(eventId).getSingleTestResult() 100 | 101 | seconds shouldEqual 123 102 | } 103 | 104 | @Test 105 | fun delete_play_position_removes_existing_play_position() { 106 | val eventId = "8" 107 | playPositionDao.insert(PlayPosition(eventId = eventId, seconds = 123)) 108 | playPositionDao.getPlayedEvents().getSingleTestResult().size shouldEqual 1 109 | playPositionDao.getPlaybackSeconds(eventId).getSingleTestResult() shouldEqual 123 110 | 111 | playPositionDao.delete(PlayPosition(eventId = eventId)) 112 | playPositionDao.getPlayedEvents().getSingleTestResult().size shouldEqual 0 113 | playPositionDao.getPlaybackSeconds(eventId).test().errorCount() shouldEqual 1 114 | } 115 | 116 | @Test 117 | fun delete_play_position_without_matching_event_does_nothing() { 118 | playPositionDao.delete(PlayPosition(eventId = "42")) 119 | playPositionDao.delete(PlayPosition(eventId = "43")) 120 | } 121 | 122 | @Test 123 | fun updating_a_played_event_does_not_change_play_position() { 124 | val eventId = "8" 125 | val updatedEvent = minimalEventEntity.copy(conferenceAcronym = testConferenceAcronym, id = eventId, title = "updated") 126 | playPositionDao.insert(PlayPosition(eventId = eventId)) 127 | eventDao.insert(updatedEvent) 128 | 129 | val playedEvents = playPositionDao.getPlayedEvents().getSingleTestResult() 130 | 131 | playedEvents.size shouldEqual 1 132 | playedEvents.first() shouldEqual updatedEvent 133 | } 134 | 135 | } -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-hdpi/app_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/debug/res/mipmap-hdpi/app_banner.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-mdpi/app_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/debug/res/mipmap-mdpi/app_banner.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xhdpi/app_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/debug/res/mipmap-xhdpi/app_banner.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xxhdpi/app_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/debug/res/mipmap-xxhdpi/app_banner.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xxxhdpi/app_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/debug/res/mipmap-xxxhdpi/app_banner.png -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | 22 | 26 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 58 | 62 | 63 | 73 | 74 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 89 | 90 | 91 | 92 | 93 | 94 | 98 | 102 | 103 | 111 | 112 | 116 | 117 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/C3TVApp.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv 2 | 3 | import android.util.Log 4 | import com.crashlytics.android.Crashlytics 5 | import dagger.android.DaggerApplication 6 | import de.stefanmedack.ccctv.di.DaggerAppComponent 7 | import de.stefanmedack.ccctv.service.ContentUpdateService 8 | import io.fabric.sdk.android.Fabric 9 | import timber.log.Timber 10 | import timber.log.Timber.DebugTree 11 | 12 | class C3TVApp : DaggerApplication() { 13 | 14 | override fun applicationInjector() = DaggerAppComponent.builder() 15 | .application(this) 16 | .build() 17 | 18 | override fun onCreate() { 19 | super.onCreate() 20 | 21 | if (BuildConfig.DEBUG) { 22 | Timber.plant(DebugTree()) 23 | } 24 | 25 | Fabric.with(this, Crashlytics()) 26 | Timber.plant(CrashlyticsTree()) 27 | 28 | ContentUpdateService.schedulePeriodicContentUpdates(this) 29 | } 30 | 31 | private class CrashlyticsTree : Timber.Tree() { 32 | override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { 33 | if (priority < Log.WARN) { 34 | return 35 | } 36 | 37 | Crashlytics.log(message) 38 | if (t != null) { 39 | Crashlytics.logException(t) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/di/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.di 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import dagger.BindsInstance 6 | import dagger.Component 7 | import dagger.android.AndroidInjector 8 | import dagger.android.support.AndroidSupportInjectionModule 9 | import de.stefanmedack.ccctv.C3TVApp 10 | import de.stefanmedack.ccctv.di.Scopes.ApplicationContext 11 | import de.stefanmedack.ccctv.di.modules.ActivityBuilderModule 12 | import de.stefanmedack.ccctv.di.modules.C3MediaModule 13 | import de.stefanmedack.ccctv.di.modules.DatabaseModule 14 | import javax.inject.Singleton 15 | 16 | @Singleton 17 | @Component( 18 | modules = [ 19 | AndroidSupportInjectionModule::class, 20 | C3MediaModule::class, 21 | DatabaseModule::class, 22 | ActivityBuilderModule::class 23 | ] 24 | ) 25 | interface AppComponent : AndroidInjector { 26 | 27 | @Component.Builder 28 | interface Builder { 29 | fun build(): AndroidInjector 30 | @BindsInstance fun application(@ApplicationContext context: Context): Builder 31 | } 32 | 33 | fun inject(application: Application) 34 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/di/C3ViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.di 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import android.arch.lifecycle.ViewModelProvider 5 | 6 | import javax.inject.Inject 7 | import javax.inject.Provider 8 | import javax.inject.Singleton 9 | 10 | @Singleton 11 | class C3ViewModelFactory @Inject constructor( 12 | private val creators: Map, @JvmSuppressWildcards Provider> 13 | ) : ViewModelProvider.Factory { 14 | 15 | override fun create(modelClass: Class): T { 16 | var creator: Provider? = creators[modelClass] 17 | if (creator == null) { 18 | for ((key, value) in creators) { 19 | if (modelClass.isAssignableFrom(key)) { 20 | creator = value 21 | break 22 | } 23 | } 24 | } 25 | if (creator == null) { 26 | throw IllegalArgumentException("unknown model class $modelClass") 27 | } 28 | try { 29 | @Suppress("UNCHECKED_CAST") 30 | return creator.get() as T 31 | } catch (e: Exception) { 32 | throw RuntimeException(e) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/di/Scopes.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.di 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import dagger.MapKey 5 | import javax.inject.Qualifier 6 | import kotlin.reflect.KClass 7 | 8 | object Scopes { 9 | 10 | @Qualifier 11 | @Retention(AnnotationRetention.RUNTIME) 12 | internal annotation class ApplicationContext 13 | 14 | @MustBeDocumented 15 | @Target(AnnotationTarget.FUNCTION) 16 | @Retention(AnnotationRetention.RUNTIME) 17 | @MapKey 18 | annotation class ViewModelKey(val value: KClass) 19 | 20 | @Qualifier 21 | @Retention(AnnotationRetention.RUNTIME) 22 | annotation class CacheDir 23 | 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/di/modules/ActivityBuilderModule.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.di.modules 2 | 3 | import dagger.Module 4 | import dagger.android.ContributesAndroidInjector 5 | import de.stefanmedack.ccctv.service.ContentUpdateService 6 | import de.stefanmedack.ccctv.ui.detail.DetailActivity 7 | import de.stefanmedack.ccctv.ui.detail.DetailModule 8 | import de.stefanmedack.ccctv.ui.events.EventsActivity 9 | import de.stefanmedack.ccctv.ui.events.EventsModule 10 | import de.stefanmedack.ccctv.ui.main.MainActivity 11 | import de.stefanmedack.ccctv.ui.main.MainModule 12 | import de.stefanmedack.ccctv.ui.search.SearchActivity 13 | import de.stefanmedack.ccctv.ui.search.SearchModule 14 | 15 | @Module(includes = [ 16 | ViewModelModule::class 17 | ]) 18 | abstract class ActivityBuilderModule { 19 | 20 | @ContributesAndroidInjector(modules = [MainModule::class]) 21 | abstract fun contributeMain(): MainActivity 22 | 23 | @ContributesAndroidInjector(modules = [EventsModule::class]) 24 | abstract fun contributeEvents(): EventsActivity 25 | 26 | @ContributesAndroidInjector(modules = [DetailModule::class]) 27 | abstract fun contributeDetail(): DetailActivity 28 | 29 | @ContributesAndroidInjector(modules = [SearchModule::class]) 30 | abstract fun contributeSearch(): SearchActivity 31 | 32 | @ContributesAndroidInjector 33 | abstract fun contributeContentUpdateService(): ContentUpdateService 34 | 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/di/modules/C3MediaModule.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.di.modules 2 | 3 | import android.content.Context 4 | import dagger.Module 5 | import dagger.Provides 6 | import de.stefanmedack.ccctv.BuildConfig 7 | import de.stefanmedack.ccctv.di.Scopes.ApplicationContext 8 | import de.stefanmedack.ccctv.di.Scopes.CacheDir 9 | import de.stefanmedack.ccctv.util.CACHE_MAX_SIZE_HTTP 10 | import info.metadude.java.library.brockman.ApiModule.provideStreamsService 11 | import info.metadude.java.library.brockman.StreamsService 12 | import info.metadude.kotlin.library.c3media.RxApiModule.provideRxC3MediaService 13 | import info.metadude.kotlin.library.c3media.RxC3MediaService 14 | import okhttp3.Cache 15 | import okhttp3.OkHttpClient 16 | import okhttp3.logging.HttpLoggingInterceptor 17 | import java.io.File 18 | import javax.inject.Singleton 19 | 20 | @Module 21 | class C3MediaModule { 22 | 23 | @Provides 24 | @Singleton 25 | fun provideC3MediaService(okHttpClient: OkHttpClient): RxC3MediaService { 26 | return provideRxC3MediaService("https://api.media.ccc.de", okHttpClient) 27 | } 28 | 29 | @Provides 30 | @Singleton 31 | fun provideStreamsService(okHttpClient: OkHttpClient): StreamsService { 32 | return provideStreamsService(BuildConfig.STREAMING_API_BASE_URL, okHttpClient); 33 | } 34 | 35 | @Provides 36 | @Singleton 37 | fun provideHttpCient(@CacheDir cacheDir: File?): OkHttpClient { 38 | val interceptor = HttpLoggingInterceptor() 39 | interceptor.level = HttpLoggingInterceptor.Level.HEADERS 40 | return OkHttpClient.Builder().run { 41 | addNetworkInterceptor(interceptor) 42 | if (null != cacheDir) { 43 | val responseCache = File(cacheDir, "HttpResponseCache") 44 | if (!responseCache.exists()) { 45 | responseCache.mkdirs() 46 | } 47 | cache(Cache(responseCache, CACHE_MAX_SIZE_HTTP)) 48 | } 49 | build() 50 | } 51 | } 52 | 53 | @Provides 54 | @CacheDir 55 | @Singleton 56 | fun provideCacheDir(@ApplicationContext context: Context): File = context.cacheDir 57 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/di/modules/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.di.modules 2 | 3 | import android.arch.persistence.room.Room 4 | import android.content.Context 5 | import dagger.Module 6 | import dagger.Provides 7 | import de.stefanmedack.ccctv.di.Scopes.ApplicationContext 8 | import de.stefanmedack.ccctv.persistence.C3Db 9 | import de.stefanmedack.ccctv.persistence.daos.BookmarkDao 10 | import de.stefanmedack.ccctv.persistence.daos.ConferenceDao 11 | import de.stefanmedack.ccctv.persistence.daos.EventDao 12 | import de.stefanmedack.ccctv.persistence.daos.PlayPositionDao 13 | import javax.inject.Singleton 14 | 15 | @Module 16 | class DatabaseModule { 17 | 18 | @Provides 19 | @Singleton 20 | fun provideDb(@ApplicationContext context: Context): C3Db = Room 21 | .databaseBuilder(context, C3Db::class.java, "ccc.db") 22 | .fallbackToDestructiveMigration() 23 | // .inMemoryDatabaseBuilder(context, C3Db::class.java) 24 | .build() 25 | 26 | @Provides 27 | @Singleton 28 | fun provideBookmarkDao(db: C3Db): BookmarkDao = db.bookmarkDao() 29 | 30 | @Provides 31 | @Singleton 32 | fun provideConferenceDao(db: C3Db): ConferenceDao = db.conferenceDao() 33 | 34 | @Provides 35 | @Singleton 36 | fun provideEventDao(db: C3Db): EventDao = db.eventDao() 37 | 38 | @Provides 39 | @Singleton 40 | fun providePlayPositionDao(db: C3Db): PlayPositionDao = db.playPositionDao() 41 | 42 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/di/modules/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.di.modules 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import android.arch.lifecycle.ViewModelProvider 5 | import dagger.Binds 6 | import dagger.Module 7 | import dagger.multibindings.IntoMap 8 | import de.stefanmedack.ccctv.di.C3ViewModelFactory 9 | import de.stefanmedack.ccctv.di.Scopes.ViewModelKey 10 | import de.stefanmedack.ccctv.ui.detail.DetailViewModel 11 | import de.stefanmedack.ccctv.ui.events.EventsViewModel 12 | import de.stefanmedack.ccctv.ui.main.MainViewModel 13 | import de.stefanmedack.ccctv.ui.main.conferences.ConferencesViewModel 14 | import de.stefanmedack.ccctv.ui.main.home.HomeViewModel 15 | import de.stefanmedack.ccctv.ui.main.streaming.LiveStreamingViewModel 16 | import de.stefanmedack.ccctv.ui.search.SearchViewModel 17 | 18 | @Module 19 | abstract class ViewModelModule { 20 | 21 | @Binds 22 | @IntoMap 23 | @ViewModelKey(MainViewModel::class) 24 | abstract fun bindMainViewModel(viewModel: MainViewModel): ViewModel 25 | 26 | @Binds 27 | @IntoMap 28 | @ViewModelKey(HomeViewModel::class) 29 | abstract fun bindHomeViewModel(viewModel: HomeViewModel): ViewModel 30 | 31 | @Binds 32 | @IntoMap 33 | @ViewModelKey(ConferencesViewModel::class) 34 | abstract fun bindConferencesViewModel(viewModel: ConferencesViewModel): ViewModel 35 | 36 | @Binds 37 | @IntoMap 38 | @ViewModelKey(LiveStreamingViewModel::class) 39 | abstract fun bindLiveStreamingViewModel(viewModel: LiveStreamingViewModel): ViewModel 40 | 41 | @Binds 42 | @IntoMap 43 | @ViewModelKey(DetailViewModel::class) 44 | abstract fun bindDetailViewModel(viewModel: DetailViewModel): ViewModel 45 | 46 | @Binds 47 | @IntoMap 48 | @ViewModelKey(EventsViewModel::class) 49 | abstract fun bindEventsViewModel(viewModel: EventsViewModel): ViewModel 50 | 51 | @Binds 52 | @IntoMap 53 | @ViewModelKey(SearchViewModel::class) 54 | abstract fun bindSearchViewModel(viewModel: SearchViewModel): ViewModel 55 | 56 | @Binds 57 | abstract fun bindViewModelFactory(factory: C3ViewModelFactory): ViewModelProvider.Factory 58 | 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/model/ConferenceGroup.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.model 2 | 3 | enum class ConferenceGroup(val slugPrefix: String) { 4 | 5 | CONGRESS("congress"), 6 | CAMP("conferences/camp"), 7 | 8 | // Think about creating a category for this (and more) CCC related formats 9 | // CHAOS_OPENCHAOS("events/openchaos"), 10 | // CHAOS_DATENGARTEN("events/datengarten"), 11 | // CHAOS_RADIO("broadcast/chaosradio"), 12 | 13 | CRYPTOCON("conferences/cryptocon"), 14 | DATENSPUREN("conferences/datenspuren"), 15 | DENOG("conferences/denog"), 16 | EH("conferences/eh"), 17 | FIFFKON("conferences/fiffkon"), 18 | FROSCON("conferences/froscon"), 19 | GPN("conferences/gpn"), 20 | HACKOVER("conferences/hackover"), 21 | JUGENDHACKT("events/jugendhackt"), 22 | MRMCD("conferences/mrmcd"), 23 | NETZPOLITIK("conferences/netzpolitik"), 24 | OSC("conferences/osc"), 25 | SIGINT("conferences/sigint"), 26 | VCFB("conferences/vcfb"), 27 | 28 | OTHER_CONFERENCES("conferences"), 29 | // OTHER_EVENTS("events"), // for now we merge OTHER and OTHER_EVENTS 30 | OTHER("") 31 | 32 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/model/Resource.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.model 2 | 3 | sealed class Resource { 4 | open val data: T? = null 5 | 6 | data class Error(val msg: String, override val data: T? = null) : Resource() 7 | data class Loading(override val data: T? = null) : Resource() 8 | data class Success(override val data: T) : Resource() 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/C3Db.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence 2 | 3 | import android.arch.persistence.room.Database 4 | import android.arch.persistence.room.RoomDatabase 5 | import android.arch.persistence.room.TypeConverters 6 | import de.stefanmedack.ccctv.persistence.daos.BookmarkDao 7 | import de.stefanmedack.ccctv.persistence.daos.ConferenceDao 8 | import de.stefanmedack.ccctv.persistence.daos.EventDao 9 | import de.stefanmedack.ccctv.persistence.daos.PlayPositionDao 10 | import de.stefanmedack.ccctv.persistence.entities.Bookmark 11 | import de.stefanmedack.ccctv.persistence.entities.Conference 12 | import de.stefanmedack.ccctv.persistence.entities.Event 13 | import de.stefanmedack.ccctv.persistence.entities.PlayPosition 14 | 15 | @Database( 16 | entities = [ 17 | Bookmark::class, 18 | Conference::class, 19 | Event::class, 20 | PlayPosition::class 21 | ], 22 | version = 5) 23 | @TypeConverters(C3TypeConverters::class) 24 | abstract class C3Db : RoomDatabase() { 25 | 26 | abstract fun bookmarkDao(): BookmarkDao 27 | abstract fun conferenceDao(): ConferenceDao 28 | abstract fun eventDao(): EventDao 29 | abstract fun playPositionDao(): PlayPositionDao 30 | 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/C3TypeConverters.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence 2 | 3 | import android.arch.persistence.room.TypeConverter 4 | import com.google.gson.Gson 5 | import com.google.gson.reflect.TypeToken 6 | import de.stefanmedack.ccctv.model.ConferenceGroup 7 | import de.stefanmedack.ccctv.persistence.entities.LanguageList 8 | import de.stefanmedack.ccctv.util.EMPTY_STRING 9 | import info.metadude.kotlin.library.c3media.models.AspectRatio 10 | import info.metadude.kotlin.library.c3media.models.RelatedEvent 11 | import org.threeten.bp.DateTimeException 12 | import org.threeten.bp.LocalDate 13 | import org.threeten.bp.OffsetDateTime 14 | import org.threeten.bp.format.DateTimeFormatter 15 | import org.threeten.bp.format.DateTimeParseException 16 | 17 | 18 | class C3TypeConverters { 19 | 20 | private val gson = Gson() 21 | private val offsetDateTimeFormatter = DateTimeFormatter.ISO_DATE_TIME 22 | private val localDateFormatter = DateTimeFormatter.ISO_DATE 23 | 24 | // ********************************************************* 25 | // *** List **************************************** 26 | // ********************************************************* 27 | 28 | @TypeConverter 29 | fun fromStringList(listString: String?): List = 30 | if (listString.isNullOrEmpty()) { 31 | listOf() 32 | } else { 33 | gson.fromJson(listString, object : TypeToken>() {}.type) 34 | } 35 | 36 | @TypeConverter 37 | fun toStringList(list: List?): String? = gson.toJson(list) 38 | 39 | // ********************************************************* 40 | // *** List ************************************** 41 | // ********************************************************* 42 | 43 | @TypeConverter 44 | fun fromLanguageList(listString: String?): LanguageList = 45 | if (listString.isNullOrEmpty()) { 46 | LanguageList() 47 | } else { 48 | gson.fromJson(listString, object : TypeToken() {}.type) 49 | } 50 | 51 | @TypeConverter 52 | fun toLanguageList(languageList: LanguageList): String? = gson.toJson(languageList) 53 | 54 | // ********************************************************* 55 | // *** Metadata ******************************************** 56 | // ********************************************************* 57 | 58 | @TypeConverter 59 | fun fromRelatedEventsString(metadataString: String?): List? = gson 60 | .fromJson(metadataString, object : TypeToken>() {}.type) 61 | 62 | @TypeConverter 63 | fun toRelatedEventsString(metadata: List?) = metadata?.let { gson.toJson(it) } 64 | 65 | // ********************************************************* 66 | // *** AspectRatio ***************************************** 67 | // ********************************************************* 68 | 69 | @TypeConverter 70 | fun fromAspectRatioString(aspectRatioString: String?) = AspectRatio.toAspectRatio(aspectRatioString) 71 | 72 | @TypeConverter 73 | fun toAspectRatioString(aspectRatio: AspectRatio) = AspectRatio.toText(aspectRatio) ?: EMPTY_STRING 74 | 75 | // ********************************************************* 76 | // *** OffsetDateTime ************************************** 77 | // ********************************************************* 78 | 79 | @TypeConverter 80 | fun fromOffsetDateTimeString(dateTimeString: String?): OffsetDateTime? = dateTimeString?.let { 81 | try { 82 | OffsetDateTime.parse(dateTimeString, offsetDateTimeFormatter) 83 | } catch (e: DateTimeParseException) { 84 | null 85 | } 86 | } 87 | 88 | @TypeConverter 89 | fun toOffsetDateTimeString(offsetDateTime: OffsetDateTime?) = offsetDateTime?.let { 90 | try { 91 | it.format(offsetDateTimeFormatter) 92 | } catch (e: DateTimeException) { 93 | null 94 | } 95 | } 96 | 97 | // ********************************************************* 98 | // *** LocalDate ******************************************* 99 | // ********************************************************* 100 | 101 | @TypeConverter 102 | fun fromLocalDateString(text: String?): LocalDate? = text?.let { 103 | try { 104 | LocalDate.parse(text, localDateFormatter) 105 | } catch (e: DateTimeParseException) { 106 | null 107 | } 108 | } 109 | 110 | @TypeConverter 111 | fun toLocalDateString(localDate: LocalDate?) = localDate?.let { 112 | try { 113 | it.format(localDateFormatter) 114 | } catch (e: DateTimeException) { 115 | null 116 | } 117 | } 118 | 119 | // ********************************************************* 120 | // *** ConferenceGroup ******************************************* 121 | // ********************************************************* 122 | 123 | @TypeConverter 124 | fun fromConferenceGroupString(text: String?): ConferenceGroup? = text?.let { ConferenceGroup.valueOf(it) } 125 | 126 | @TypeConverter 127 | fun toConferenceGroupString(conferenceGroup: ConferenceGroup?) = conferenceGroup?.name 128 | 129 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/EntityMapper.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence 2 | 3 | import de.stefanmedack.ccctv.model.ConferenceGroup 4 | import de.stefanmedack.ccctv.persistence.entities.LanguageList 5 | import de.stefanmedack.ccctv.util.EMPTY_STRING 6 | import info.metadude.kotlin.library.c3media.models.AspectRatio 7 | import info.metadude.kotlin.library.c3media.models.Conference 8 | import timber.log.Timber 9 | import de.stefanmedack.ccctv.persistence.entities.Conference as ConferenceEntity 10 | import de.stefanmedack.ccctv.persistence.entities.Event as EventEntity 11 | import info.metadude.kotlin.library.c3media.models.Conference as ConferenceRemote 12 | import info.metadude.kotlin.library.c3media.models.Event as EventRemote 13 | 14 | fun ConferenceRemote.toEntity() = try { 15 | ConferenceEntity( 16 | acronym = acronym, 17 | url = url ?: throw EntityMappingException("invalid conference url: $url"), 18 | group = extractConferenceGroup(), 19 | slug = slug, 20 | title = title ?: throw EntityMappingException("invalid conference title: $title"), 21 | aspectRatio = aspectRatio ?: AspectRatio.UNKNOWN, 22 | logoUrl = logoUrl, 23 | updatedAt = updatedAt, 24 | eventLastReleasedAt = eventLastReleasedAt 25 | ) 26 | } catch (e: EntityMappingException) { 27 | Timber.w(e) 28 | null 29 | } 30 | 31 | private fun Conference.extractConferenceGroup(): ConferenceGroup { 32 | var longestSlugPrefixGroup = ConferenceGroup.OTHER 33 | ConferenceGroup.values().forEach { currentGroup -> 34 | if (this.slug.startsWith(currentGroup.slugPrefix) && longestSlugPrefixGroup.slugPrefix.length < currentGroup.slugPrefix.length) { 35 | longestSlugPrefixGroup = currentGroup 36 | } 37 | } 38 | return longestSlugPrefixGroup 39 | } 40 | 41 | // TODO conferenceAcronym could be retrieved from conference_url, instead of passing it here 42 | fun EventRemote.toEntity(conferenceAcronym: String) = try { 43 | EventEntity( 44 | id = guid, 45 | conferenceAcronym = conferenceAcronym, 46 | url = url ?: throw EntityMappingException("invalid conference url: $url"), 47 | slug = slug, 48 | title = title, 49 | subtitle = subtitle ?: EMPTY_STRING, 50 | description = description ?: EMPTY_STRING, 51 | persons = persons?.filterNotNull() ?: listOf(), 52 | thumbUrl = thumbUrl, 53 | posterUrl = posterUrl, 54 | originalLanguage = LanguageList(originalLanguage), 55 | duration = duration, 56 | viewCount = viewCount ?: 0, 57 | promoted = promoted ?: false, 58 | tags = tags?.filterNotNull() ?: listOf(), 59 | related = related ?: listOf(), 60 | releaseDate = releaseDate, 61 | date = date, 62 | updatedAt = updatedAt 63 | ) 64 | } catch (e: EntityMappingException) { 65 | Timber.w(e) 66 | null 67 | } 68 | 69 | private class EntityMappingException(msg: String) : Exception(msg) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/daos/BookmarkDao.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence.daos 2 | 3 | import android.arch.persistence.room.Dao 4 | import android.arch.persistence.room.Delete 5 | import android.arch.persistence.room.Insert 6 | import android.arch.persistence.room.OnConflictStrategy 7 | import android.arch.persistence.room.Query 8 | import de.stefanmedack.ccctv.persistence.entities.Bookmark 9 | import de.stefanmedack.ccctv.persistence.entities.Event 10 | import io.reactivex.Flowable 11 | 12 | @Dao 13 | interface BookmarkDao { 14 | 15 | @Query("SELECT events.* FROM Events INNER JOIN Bookmarks WHERE events.id = bookmarks.event_id ORDER BY created_at DESC") 16 | fun getBookmarkedEvents(): Flowable> 17 | 18 | @Query("SELECT COUNT(*) FROM Bookmarks WHERE event_id = :eventId") 19 | fun isBookmarked(eventId: String) : Flowable 20 | 21 | @Insert(onConflict = OnConflictStrategy.REPLACE) 22 | fun insert(bookmark: Bookmark) 23 | 24 | @Delete 25 | fun delete(bookmark: Bookmark) 26 | 27 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/daos/ConferenceDao.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence.daos 2 | 3 | import android.arch.persistence.room.Dao 4 | import android.arch.persistence.room.Insert 5 | import android.arch.persistence.room.OnConflictStrategy 6 | import android.arch.persistence.room.Query 7 | import android.arch.persistence.room.Transaction 8 | import de.stefanmedack.ccctv.persistence.entities.Conference 9 | import de.stefanmedack.ccctv.persistence.entities.ConferenceWithEvents 10 | import de.stefanmedack.ccctv.persistence.entities.Event 11 | import io.reactivex.Flowable 12 | 13 | @Dao 14 | interface ConferenceDao { 15 | 16 | @Query("SELECT * FROM Conferences") 17 | fun getConferences(): Flowable> 18 | 19 | @Query("SELECT * FROM Conferences WHERE c_group LIKE :conferenceGroupName") 20 | fun getConferences(conferenceGroupName: String): Flowable> 21 | 22 | @Query("SELECT * FROM Conferences WHERE acronym = :acronym") 23 | fun getConferenceByAcronym(acronym: String): Flowable 24 | 25 | @Transaction 26 | @Query("SELECT * FROM Conferences") 27 | fun getConferencesWithEvents(): Flowable> 28 | 29 | @Transaction 30 | @Query("SELECT * FROM Conferences WHERE c_group LIKE :conferenceGroupName") 31 | fun getConferencesWithEvents(conferenceGroupName: String): Flowable> 32 | 33 | @Transaction 34 | @Query("SELECT * FROM Conferences WHERE acronym = :acronym") 35 | fun getConferenceWithEventsByAcronym(acronym: String): Flowable 36 | 37 | @Insert(onConflict = OnConflictStrategy.REPLACE) 38 | fun insert(conference: Conference) 39 | 40 | @Insert(onConflict = OnConflictStrategy.REPLACE) 41 | fun insertAll(conferences: List) 42 | 43 | @Insert(onConflict = OnConflictStrategy.REPLACE) 44 | fun insertConferencesWithEvents(conferences: List, events: List) 45 | 46 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/daos/EventDao.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence.daos 2 | 3 | import android.arch.persistence.room.Dao 4 | import android.arch.persistence.room.Insert 5 | import android.arch.persistence.room.OnConflictStrategy 6 | import android.arch.persistence.room.Query 7 | import de.stefanmedack.ccctv.persistence.entities.Event 8 | import io.reactivex.Flowable 9 | import io.reactivex.Single 10 | import org.threeten.bp.OffsetDateTime 11 | 12 | @Dao 13 | interface EventDao { 14 | 15 | @Query("SELECT * FROM Events") 16 | fun getEvents(): Flowable> 17 | 18 | @Query("SELECT * FROM Events WHERE id in (:ids)") 19 | fun getEvents(ids: List): Flowable> 20 | 21 | @Query("SELECT * FROM Events ORDER BY date(date) DESC Limit 25") 22 | fun getRecentEvents(): Flowable> 23 | 24 | @Query("SELECT * FROM Events WHERE promoted = 1 Limit 25") 25 | fun getPromotedEvents(): Flowable> 26 | 27 | @Query("SELECT * FROM Events ORDER BY view_count DESC Limit 25") 28 | fun getPopularEvents(): Flowable> 29 | 30 | @Query("SELECT * FROM Events WHERE date > :date ORDER BY view_count DESC Limit 25") 31 | fun getPopularEventsYoungerThan(date: OffsetDateTime): Flowable> 32 | 33 | @Query("SELECT * FROM Events WHERE id = :id") 34 | fun getEventById(id: String): Single 35 | 36 | @Insert(onConflict = OnConflictStrategy.REPLACE) 37 | fun insert(event: Event) 38 | 39 | @Insert(onConflict = OnConflictStrategy.REPLACE) 40 | fun insertAll(events: List) 41 | 42 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/daos/PlayPositionDao.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence.daos 2 | 3 | import android.arch.persistence.room.Dao 4 | import android.arch.persistence.room.Delete 5 | import android.arch.persistence.room.Insert 6 | import android.arch.persistence.room.OnConflictStrategy 7 | import android.arch.persistence.room.Query 8 | import de.stefanmedack.ccctv.persistence.entities.Event 9 | import de.stefanmedack.ccctv.persistence.entities.PlayPosition 10 | import io.reactivex.Flowable 11 | import io.reactivex.Single 12 | 13 | @Dao 14 | interface PlayPositionDao { 15 | 16 | @Query("SELECT events.* FROM Events INNER JOIN play_positions WHERE events.id = play_positions.event_id ORDER BY created_at DESC") 17 | fun getPlayedEvents(): Flowable> 18 | 19 | @Query("SELECT seconds FROM play_positions WHERE event_id = :eventId") 20 | fun getPlaybackSeconds(eventId: String) : Single 21 | 22 | @Insert(onConflict = OnConflictStrategy.REPLACE) 23 | fun insert(playPosition: PlayPosition) 24 | 25 | @Delete 26 | fun delete(playPosition: PlayPosition) 27 | 28 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/entities/Bookmark.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence.entities 2 | 3 | import android.arch.persistence.room.ColumnInfo 4 | import android.arch.persistence.room.Entity 5 | import android.arch.persistence.room.ForeignKey 6 | import android.arch.persistence.room.ForeignKey.NO_ACTION 7 | import android.arch.persistence.room.Index 8 | import org.threeten.bp.OffsetDateTime 9 | 10 | @Entity(tableName = "bookmarks", 11 | primaryKeys = ["event_id"], 12 | foreignKeys = [ 13 | ForeignKey( 14 | entity = Event::class, 15 | parentColumns = arrayOf("id"), 16 | childColumns = arrayOf("event_id"), 17 | onUpdate = NO_ACTION, 18 | onDelete = NO_ACTION 19 | ) 20 | ], 21 | indices = [ 22 | Index(name = "event_bookmark_idx", value = ["event_id"]) 23 | ] 24 | ) 25 | data class Bookmark( 26 | 27 | @ColumnInfo(name = "event_id") 28 | val eventId: String, 29 | 30 | @ColumnInfo(name = "created_at") 31 | val createdAt: OffsetDateTime = OffsetDateTime.now() 32 | 33 | ) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/entities/Conference.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence.entities 2 | 3 | import android.arch.persistence.room.ColumnInfo 4 | import android.arch.persistence.room.Entity 5 | import android.arch.persistence.room.PrimaryKey 6 | import de.stefanmedack.ccctv.model.ConferenceGroup 7 | import info.metadude.kotlin.library.c3media.models.AspectRatio 8 | import org.threeten.bp.LocalDate 9 | import org.threeten.bp.OffsetDateTime 10 | 11 | @Entity(tableName = "conferences") 12 | data class Conference( 13 | 14 | @PrimaryKey 15 | val acronym: String, 16 | 17 | @ColumnInfo(name = "url") 18 | val url: String, 19 | 20 | // Note: this field is not available through the media.ccc.de API, but extracted from the slug 21 | @ColumnInfo(name = "c_group") 22 | val group: ConferenceGroup, 23 | 24 | @ColumnInfo(name = "slug") 25 | val slug: String, 26 | 27 | @ColumnInfo(name = "title") 28 | val title: String, 29 | 30 | @ColumnInfo(name = "aspect_ratio") 31 | val aspectRatio: AspectRatio = AspectRatio.UNKNOWN, 32 | 33 | @ColumnInfo(name = "logo_url") 34 | val logoUrl: String? = null, 35 | 36 | @ColumnInfo(name = "updated_at") 37 | val updatedAt: OffsetDateTime? = null, 38 | 39 | @ColumnInfo(name = "event_last_released_at") 40 | val eventLastReleasedAt: LocalDate? = null 41 | 42 | ) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/entities/ConferenceWithEvents.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence.entities 2 | 3 | import android.arch.persistence.room.Embedded 4 | import android.arch.persistence.room.Relation 5 | 6 | data class ConferenceWithEvents @JvmOverloads constructor( 7 | @Embedded 8 | val conference: Conference, 9 | 10 | @Relation(parentColumn = "acronym", entityColumn = "conference_acronym", entity = Event::class) 11 | var events: List = listOf() 12 | // TODO events should be immutable, but currently can't be because of Rooms constructor handling 13 | // https://issuetracker.google.com/issues/67273372 14 | ) 15 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/entities/Event.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence.entities 2 | 3 | import android.arch.persistence.room.ColumnInfo 4 | import android.arch.persistence.room.Entity 5 | import android.arch.persistence.room.ForeignKey 6 | import android.arch.persistence.room.ForeignKey.NO_ACTION 7 | import android.arch.persistence.room.Index 8 | import android.arch.persistence.room.PrimaryKey 9 | import de.stefanmedack.ccctv.util.EMPTY_STRING 10 | import info.metadude.kotlin.library.c3media.models.RelatedEvent 11 | import org.threeten.bp.LocalDate 12 | import org.threeten.bp.OffsetDateTime 13 | 14 | @Entity(tableName = "events", 15 | foreignKeys = [ 16 | ForeignKey( 17 | entity = Conference::class, 18 | parentColumns = arrayOf("acronym"), 19 | childColumns = arrayOf("conference_acronym"), 20 | onUpdate = NO_ACTION, 21 | onDelete = NO_ACTION 22 | )], 23 | indices = [ 24 | Index(name = "conference_idx", value = ["conference_acronym"]) 25 | ] 26 | ) 27 | data class Event( 28 | 29 | @PrimaryKey 30 | val id: String, 31 | 32 | @ColumnInfo(name = "conference_acronym") 33 | val conferenceAcronym: String, 34 | 35 | @ColumnInfo(name = "url") 36 | val url: String, 37 | 38 | @ColumnInfo(name = "slug") 39 | val slug: String, 40 | 41 | @ColumnInfo(name = "title") 42 | val title: String, 43 | 44 | @ColumnInfo(name = "subtitle") 45 | val subtitle: String = EMPTY_STRING, 46 | 47 | @ColumnInfo(name = "description") 48 | val description: String = EMPTY_STRING, 49 | 50 | @ColumnInfo(name = "persons") 51 | val persons: List = listOf(), 52 | 53 | @ColumnInfo(name = "thumb_url") 54 | val thumbUrl: String? = null, 55 | 56 | @ColumnInfo(name = "poster_url") 57 | val posterUrl: String? = null, 58 | 59 | @ColumnInfo(name = "original_language") 60 | val originalLanguage: LanguageList = LanguageList(), 61 | 62 | @ColumnInfo(name = "duration") 63 | val duration: Int? = null, 64 | 65 | @ColumnInfo(name = "view_count") 66 | val viewCount: Int = 0, 67 | 68 | @ColumnInfo(name = "promoted") 69 | val promoted: Boolean = false, 70 | 71 | @ColumnInfo(name = "tags") 72 | val tags: List = listOf(), 73 | 74 | @ColumnInfo(name = "related") 75 | val related: List = listOf(), 76 | 77 | @ColumnInfo(name = "release_date") 78 | val releaseDate: LocalDate? = null, 79 | 80 | @ColumnInfo(name = "date") 81 | val date: OffsetDateTime? = null, 82 | 83 | @ColumnInfo(name = "updated_at") 84 | val updatedAt: OffsetDateTime? = null 85 | 86 | ) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/entities/LanguageList.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence.entities 2 | 3 | import info.metadude.kotlin.library.c3media.models.Language 4 | 5 | data class LanguageList(val languages: List = listOf()) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/persistence/entities/PlayPosition.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.persistence.entities 2 | 3 | import android.arch.persistence.room.ColumnInfo 4 | import android.arch.persistence.room.Entity 5 | import android.arch.persistence.room.ForeignKey 6 | import android.arch.persistence.room.ForeignKey.NO_ACTION 7 | import android.arch.persistence.room.Index 8 | import org.threeten.bp.OffsetDateTime 9 | 10 | @Entity(tableName = "play_positions", 11 | primaryKeys = ["event_id"], 12 | foreignKeys = [ 13 | ForeignKey( 14 | entity = Event::class, 15 | parentColumns = arrayOf("id"), 16 | childColumns = arrayOf("event_id"), 17 | onUpdate = NO_ACTION, 18 | onDelete = NO_ACTION 19 | ) 20 | ], 21 | indices = [ 22 | Index(name = "event_play_position_idx", value = ["event_id"]) 23 | ] 24 | ) 25 | data class PlayPosition( 26 | 27 | @ColumnInfo(name = "event_id") 28 | val eventId: String, 29 | 30 | @ColumnInfo(name = "seconds") 31 | val seconds: Int = 0, 32 | 33 | @ColumnInfo(name = "created_at") 34 | val createdAt: OffsetDateTime = OffsetDateTime.now() 35 | 36 | ) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/repository/ConferenceRepository.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.repository 2 | 3 | import de.stefanmedack.ccctv.model.Resource 4 | import de.stefanmedack.ccctv.persistence.daos.ConferenceDao 5 | import de.stefanmedack.ccctv.persistence.daos.EventDao 6 | import de.stefanmedack.ccctv.persistence.entities.ConferenceWithEvents 7 | import de.stefanmedack.ccctv.persistence.toEntity 8 | import de.stefanmedack.ccctv.util.applySchedulers 9 | import info.metadude.kotlin.library.c3media.RxC3MediaService 10 | import io.reactivex.Flowable 11 | import io.reactivex.Single 12 | import javax.inject.Inject 13 | import javax.inject.Singleton 14 | import de.stefanmedack.ccctv.persistence.entities.Conference as ConferenceEntity 15 | import info.metadude.kotlin.library.c3media.models.Conference as ConferenceRemote 16 | 17 | @Singleton 18 | class ConferenceRepository @Inject constructor( 19 | private val mediaService: RxC3MediaService, 20 | private val conferenceDao: ConferenceDao, 21 | private val eventDao: EventDao 22 | ) { 23 | val conferences: Flowable>> 24 | get() = conferenceResource(forceUpdate = false) 25 | 26 | fun conferenceWithEvents(acronym: String): Flowable> = 27 | conferenceWithEventsResource(acronym, forceUpdate = false) 28 | 29 | fun loadedConferences(conferenceGroup: String): Flowable>> = conferenceDao 30 | .getConferences(conferenceGroup) 31 | .map>> { Resource.Success(it) } 32 | .applySchedulers() 33 | 34 | fun updateContent(): Single>> = conferenceResource(forceUpdate = true) 35 | .filter { it is Resource.Success } 36 | .firstOrError() 37 | .flattenAsFlowable { it.data } 38 | .flatMap { 39 | conferenceWithEventsResource(acronym = it.acronym, forceUpdate = true) 40 | .filter { it is Resource.Success } 41 | } 42 | .applySchedulers() 43 | .toList() 44 | 45 | private fun conferenceResource(forceUpdate: Boolean): Flowable>> = object 46 | : NetworkBoundResource, List>() { 47 | 48 | override fun fetchLocal(): Flowable> = conferenceDao.getConferences() 49 | 50 | override fun saveLocal(data: List) { 51 | conferenceDao.insertAll(data) 52 | } 53 | 54 | override fun isStale(localResource: Resource>) = when (localResource) { 55 | is Resource.Success -> localResource.data.isEmpty() || forceUpdate 56 | is Resource.Loading -> false 57 | is Resource.Error -> true 58 | } 59 | 60 | override fun fetchNetwork(): Single> = mediaService 61 | .getConferences() 62 | .map { it.conferences?.filterNotNull() } 63 | 64 | override fun mapNetworkToLocal(data: List) = data.mapNotNull { it.toEntity() } 65 | 66 | }.resource 67 | 68 | private fun conferenceWithEventsResource(acronym: String, forceUpdate: Boolean): Flowable> = object 69 | : NetworkBoundResource() { 70 | 71 | override fun fetchLocal(): Flowable = conferenceDao.getConferenceWithEventsByAcronym(acronym) 72 | 73 | override fun saveLocal(data: ConferenceWithEvents) = 74 | data.let { (_, events) -> 75 | eventDao.insertAll(events) 76 | } 77 | 78 | override fun isStale(localResource: Resource) = when (localResource) { 79 | is Resource.Success -> localResource.data.events.isEmpty() || forceUpdate 80 | is Resource.Loading -> false 81 | is Resource.Error -> true 82 | } 83 | 84 | override fun fetchNetwork(): Single = mediaService.getConference(acronym) 85 | .applySchedulers() 86 | 87 | override fun mapNetworkToLocal(data: ConferenceRemote): ConferenceWithEvents = 88 | data.toEntity()?.let { conference -> 89 | ConferenceWithEvents( 90 | conference = conference, 91 | events = data.events?.mapNotNull { it?.toEntity(acronym) } ?: listOf() 92 | ) 93 | } ?: throw IllegalArgumentException("Could not parse ConferenceRemote to ConferenceEntity") 94 | 95 | }.resource 96 | } 97 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/repository/EventRepository.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.repository 2 | 3 | import android.arch.persistence.room.EmptyResultSetException 4 | import de.stefanmedack.ccctv.persistence.daos.BookmarkDao 5 | import de.stefanmedack.ccctv.persistence.daos.EventDao 6 | import de.stefanmedack.ccctv.persistence.daos.PlayPositionDao 7 | import de.stefanmedack.ccctv.persistence.entities.Bookmark 8 | import de.stefanmedack.ccctv.persistence.entities.Event 9 | import de.stefanmedack.ccctv.persistence.entities.PlayPosition 10 | import de.stefanmedack.ccctv.persistence.toEntity 11 | import de.stefanmedack.ccctv.util.EMPTY_STRING 12 | import de.stefanmedack.ccctv.util.applySchedulers 13 | import info.metadude.kotlin.library.c3media.RxC3MediaService 14 | import io.reactivex.Completable 15 | import io.reactivex.Flowable 16 | import io.reactivex.Single 17 | import io.reactivex.rxkotlin.toFlowable 18 | import org.threeten.bp.OffsetDateTime 19 | import javax.inject.Inject 20 | import javax.inject.Singleton 21 | import info.metadude.kotlin.library.c3media.models.Event as EventRemote 22 | 23 | @Singleton 24 | class EventRepository @Inject constructor( 25 | private val mediaService: RxC3MediaService, 26 | private val eventDao: EventDao, 27 | private val bookmarkDao: BookmarkDao, 28 | private val playPositionDao: PlayPositionDao 29 | ) { 30 | fun getEvent(id: String): Flowable = eventDao.getEventById(id) 31 | .onErrorResumeNext( 32 | mediaService.getEvent(id) 33 | .applySchedulers() 34 | .map { it.toEntity(EMPTY_STRING)!! } 35 | ).toFlowable() 36 | 37 | fun getEvents(ids: List): Flowable> = ids.toFlowable() 38 | .flatMap { getEvent(it) } 39 | .toList() 40 | .toFlowable() 41 | 42 | fun getRecentEvents(): Flowable> = eventDao.getRecentEvents() 43 | 44 | fun getPopularEvents(): Flowable> = eventDao.getPopularEvents() 45 | 46 | fun getPromotedEvents(): Flowable> = eventDao.getPromotedEvents() 47 | 48 | fun getTrendingEvents(): Flowable> = eventDao.getPopularEventsYoungerThan(OffsetDateTime.now().minusDays(90)) 49 | 50 | // TODO change to Resource> 51 | fun getEventWithRecordings(id: String): Single = mediaService.getEvent(id) 52 | .applySchedulers() 53 | 54 | fun getBookmarkedEvents(): Flowable> = bookmarkDao.getBookmarkedEvents() 55 | 56 | fun isBookmarked(eventId: String): Flowable = bookmarkDao.isBookmarked(eventId) 57 | 58 | fun changeBookmarkState(eventId: String, shouldBeBookmarked: Boolean): Completable = 59 | Completable.fromAction { 60 | if (shouldBeBookmarked) { 61 | bookmarkDao.insert(Bookmark(eventId)) 62 | } else { 63 | bookmarkDao.delete(Bookmark(eventId)) 64 | } 65 | }.applySchedulers() 66 | 67 | fun getPlayedEvents(): Flowable> = playPositionDao.getPlayedEvents() 68 | 69 | fun getPlayedSeconds(eventId: String): Single = playPositionDao.getPlaybackSeconds(eventId) 70 | .applySchedulers() // TODO fix tests when applySchedulers is added 71 | .onErrorReturn { if (it is EmptyResultSetException) 0 else throw it } 72 | 73 | fun savePlayedSeconds(eventId: String, seconds: Int): Completable = 74 | Completable.fromAction { 75 | if (seconds > 0) { 76 | playPositionDao.insert(PlayPosition(eventId, seconds)) 77 | } else { 78 | playPositionDao.delete(PlayPosition(eventId)) 79 | } 80 | }.applySchedulers() 81 | 82 | fun deletePlayedSeconds(eventId: String): Completable = 83 | Completable.fromAction { 84 | playPositionDao.delete(PlayPosition(eventId)) 85 | }.applySchedulers() 86 | 87 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/repository/NetworkBoundResource.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.repository 2 | 3 | import de.stefanmedack.ccctv.model.Resource 4 | import de.stefanmedack.ccctv.util.applySchedulers 5 | import io.reactivex.Flowable 6 | import io.reactivex.Single 7 | 8 | abstract class NetworkBoundResource internal constructor() { 9 | 10 | val resource: Flowable> 11 | get() = Flowable.concat( 12 | fetchLocal() 13 | .take(1) 14 | .map> { Resource.Success(it) } 15 | .applySchedulers() 16 | .filter { !isStale(it) }, // TODO maybe return local even if local data is stale 17 | fetchNetwork() 18 | .toFlowable() 19 | .map { mapNetworkToLocal(it) } 20 | .flatMap { 21 | Flowable.fromCallable { 22 | saveLocal(it) 23 | it 24 | }.applySchedulers() 25 | } 26 | .map> { Resource.Success(it) } 27 | // .onErrorReturn { Resource.Error("Error") } // TODO better msg 28 | .applySchedulers() 29 | ).firstOrError() 30 | .toFlowable() 31 | .startWith(Resource.Loading()) 32 | 33 | protected abstract fun fetchLocal(): Flowable 34 | 35 | protected abstract fun saveLocal(data: LocalType) 36 | 37 | protected abstract fun isStale(localResource: Resource): Boolean 38 | 39 | protected abstract fun fetchNetwork(): Single 40 | 41 | protected abstract fun mapNetworkToLocal(data: NetworkType): LocalType 42 | 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/repository/StreamingRepository.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.repository 2 | 3 | import de.stefanmedack.ccctv.BuildConfig 4 | import de.stefanmedack.ccctv.model.Resource 5 | import de.stefanmedack.ccctv.model.Resource.Success 6 | import de.stefanmedack.ccctv.util.createFlowable 7 | import info.metadude.java.library.brockman.StreamsService 8 | import info.metadude.java.library.brockman.models.Offer 9 | import io.reactivex.BackpressureStrategy 10 | import io.reactivex.Flowable 11 | import retrofit2.Call 12 | import retrofit2.Callback 13 | import retrofit2.Response 14 | import javax.inject.Inject 15 | import javax.inject.Singleton 16 | 17 | @Singleton 18 | class StreamingRepository @Inject constructor( 19 | private val streamsService: StreamsService 20 | ) { 21 | var cachedStreams: List = listOf() 22 | 23 | val streams: Flowable>> 24 | get() = 25 | createFlowable(BackpressureStrategy.LATEST) { emitter -> 26 | emitter.onNext(Resource.Loading()) 27 | streamsService.getOffers(BuildConfig.STREAMING_API_OFFERS_PATH).enqueue(object : Callback> { 28 | override fun onResponse(call: Call>?, response: Response>?) { 29 | response?.body()?.let { 30 | cachedStreams = it 31 | emitter.onNext(Success(it)) 32 | } 33 | } 34 | 35 | override fun onFailure(call: Call>?, t: Throwable?) { 36 | emitter.onNext(Resource.Error("Could not load streams")) 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/service/C3BroadcastReceiver.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.service 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.Intent.ACTION_BOOT_COMPLETED 7 | import android.content.Intent.ACTION_MY_PACKAGE_REPLACED 8 | 9 | /** 10 | * Here we initialize the alarm manager to schedule content updates in the background automatically, which is important for having fresh 11 | * content even when the app is in the background. This will be used to display recommendations in the TVs main UI. 12 | * 13 | * This Receiver will be triggered by reboots of the TV or by updates of the app. 14 | */ 15 | class C3BroadcastReceiver : BroadcastReceiver() { 16 | 17 | override fun onReceive(context: Context, intent: Intent) { 18 | if (intent.action.endsWith(ACTION_BOOT_COMPLETED) || intent.action.endsWith(ACTION_MY_PACKAGE_REPLACED)) { 19 | ContentUpdateService.schedulePeriodicContentUpdates(context) 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/service/ContentUpdateService.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.service 2 | 3 | import android.app.AlarmManager 4 | import android.app.AlarmManager.ELAPSED_REALTIME_WAKEUP 5 | import android.app.AlarmManager.INTERVAL_HALF_HOUR 6 | import android.app.PendingIntent 7 | import android.content.Context 8 | import android.content.Intent 9 | import dagger.android.DaggerIntentService 10 | import de.stefanmedack.ccctv.repository.ConferenceRepository 11 | import timber.log.Timber 12 | import javax.inject.Inject 13 | 14 | class ContentUpdateService : DaggerIntentService("ContentUpdateService") { 15 | 16 | @Inject 17 | lateinit var conferenceRepository: ConferenceRepository 18 | 19 | override fun onHandleIntent(intent: Intent?) { 20 | updateContent() 21 | } 22 | 23 | private fun updateContent() { 24 | try { 25 | conferenceRepository.updateContent().blockingGet() 26 | } catch (e: Exception) { 27 | Timber.w(e, "Could not update content") 28 | } 29 | } 30 | 31 | companion object { 32 | fun schedulePeriodicContentUpdates(context: Context) { 33 | val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager 34 | val scheduleIntent = Intent(context, ContentUpdateService::class.java) 35 | val alarmIntent = PendingIntent.getService(context, 0, scheduleIntent, 0) 36 | 37 | // start intent initially 38 | context.startService(scheduleIntent) 39 | 40 | // schedule periodic intents 41 | alarmManager.cancel(alarmIntent) 42 | alarmManager.setInexactRepeating(ELAPSED_REALTIME_WAKEUP, INTERVAL_HALF_HOUR, INTERVAL_HALF_HOUR, alarmIntent) 43 | } 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/about/AboutDescriptionPresenter.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.about 2 | 3 | import android.support.v17.leanback.widget.AbstractDetailsDescriptionPresenter 4 | 5 | class AboutDescriptionPresenter : AbstractDetailsDescriptionPresenter() { 6 | 7 | override fun onBindDescription( 8 | viewHolder: ViewHolder, 9 | item: Any) { 10 | (item as AboutDescription).let { 11 | viewHolder.title.text = it.title 12 | viewHolder.subtitle.text = it.description 13 | } 14 | } 15 | 16 | data class AboutDescription( 17 | val title: String, 18 | val description: String 19 | ) 20 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/about/AboutFragment.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.about 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.graphics.BitmapFactory 6 | import android.os.Bundle 7 | import android.support.v17.leanback.app.BrowseSupportFragment 8 | import android.support.v17.leanback.app.DetailsSupportFragment 9 | import android.support.v17.leanback.app.DetailsSupportFragmentBackgroundController 10 | import android.support.v17.leanback.widget.* 11 | import android.support.v4.content.ContextCompat 12 | import android.view.View 13 | import android.widget.Toast 14 | import com.google.android.gms.oss.licenses.OssLicensesMenuActivity 15 | import de.stefanmedack.ccctv.R 16 | import de.stefanmedack.ccctv.ui.about.AboutDescriptionPresenter.AboutDescription 17 | import de.stefanmedack.ccctv.ui.cards.SpeakerCardPresenter 18 | import de.stefanmedack.ccctv.ui.detail.uiModels.SpeakerUiModel 19 | 20 | 21 | class AboutFragment : DetailsSupportFragment(), BrowseSupportFragment.MainFragmentAdapterProvider { 22 | 23 | var shouldKeyLeftEventTriggerBackAnimation = true 24 | var shouldKeyUpEventTriggerBackAnimation = false 25 | 26 | private val mainFragmentAdapter = BrowseSupportFragment.MainFragmentAdapter(this) 27 | 28 | override fun getMainFragmentAdapter(): BrowseSupportFragment.MainFragmentAdapter<*> { 29 | return mainFragmentAdapter 30 | } 31 | 32 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 33 | super.onViewCreated(view, savedInstanceState) 34 | 35 | setupUi(view.context) 36 | setupEventListeners() 37 | } 38 | 39 | private fun setupUi(context: Context) { 40 | DetailsSupportFragmentBackgroundController(this).apply { 41 | enableParallax() 42 | coverBitmap = BitmapFactory.decodeResource(resources, R.drawable.about_cover) 43 | } 44 | 45 | val detailOverviewRowPresenter = FullWidthDetailsOverviewRowPresenter(AboutDescriptionPresenter()) 46 | detailOverviewRowPresenter.actionsBackgroundColor = ContextCompat.getColor(context, R.color.teal_900) 47 | detailOverviewRowPresenter.backgroundColor = ContextCompat.getColor(context, R.color.teal_900) 48 | 49 | val detailsOverview = DetailsOverviewRow( 50 | AboutDescription( 51 | title = getString(R.string.app_name), 52 | description = getString(R.string.about_description) 53 | ) 54 | ) 55 | detailsOverview.imageDrawable = ContextCompat.getDrawable(context, R.drawable.qr_github) 56 | 57 | adapter = ArrayObjectAdapter( 58 | // Setup PresenterSelector to distinguish between the different rows. 59 | ClassPresenterSelector().apply { 60 | addClassPresenter(DetailsOverviewRow::class.java, detailOverviewRowPresenter) 61 | // Setup ListRow Presenter without shadows, to hide highlighting boxes 62 | addClassPresenter(ListRow::class.java, ListRowPresenter().apply { 63 | shadowEnabled = false 64 | }) 65 | } 66 | ).apply { 67 | add(detailsOverview) 68 | 69 | // add Licenses 70 | val listRowAdapter = ArrayObjectAdapter(SpeakerCardPresenter()) 71 | listRowAdapter.add(0, SpeakerUiModel(getString(R.string.libraries))) 72 | listRowAdapter.add(1, SpeakerUiModel(getString(R.string.voctocat))) 73 | add(ListRow(listRowAdapter)) 74 | } 75 | } 76 | 77 | private fun setupEventListeners() { 78 | onItemViewClickedListener = OnItemViewClickedListener { _, item, _, _ -> 79 | when ((item as SpeakerUiModel).name) { 80 | getString(R.string.libraries) -> showLicenses() 81 | getString(R.string.voctocat) -> Toast.makeText(activity, R.string.voctocat_toast, Toast.LENGTH_LONG).show() 82 | } 83 | } 84 | 85 | setOnItemViewSelectedListener { _, _, rowViewHolder, _ -> 86 | if (rowViewHolder is ListRowPresenter.ViewHolder) { 87 | shouldKeyLeftEventTriggerBackAnimation = rowViewHolder.selectedPosition == 0 88 | } 89 | } 90 | } 91 | 92 | private fun showLicenses() { 93 | val intent = Intent(activity, OssLicensesMenuActivity::class.java) 94 | intent.putExtra("title", getString(R.string.libraries)) 95 | startActivity(intent) 96 | } 97 | 98 | override fun onSetDetailsOverviewRowStatus(presenter: FullWidthDetailsOverviewRowPresenter?, viewHolder: FullWidthDetailsOverviewRowPresenter.ViewHolder?, adapterPosition: Int, selectedPosition: Int, selectedSubPosition: Int) { 99 | // NOTE: this complicated handling is needed to find out, which state (STATE_HALF/STATE_FULL) the DetailsSupportFragment is in 100 | // -> any better way of figuring out the current state is very much welcome 101 | shouldKeyUpEventTriggerBackAnimation = adapterPosition == 0 && selectedPosition == 0 && selectedSubPosition == 0 102 | super.onSetDetailsOverviewRowStatus(presenter, viewHolder, adapterPosition, selectedPosition, selectedSubPosition) 103 | } 104 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/base/BaseDisposableViewModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.base 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import io.reactivex.disposables.CompositeDisposable 5 | 6 | open class BaseDisposableViewModel : ViewModel() { 7 | 8 | val disposables = CompositeDisposable() 9 | 10 | public override fun onCleared() { 11 | disposables.clear() 12 | super.onCleared() 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/base/BaseInjectableActivity.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.base 2 | 3 | import android.os.Bundle 4 | import android.support.v4.app.Fragment 5 | import android.support.v4.app.FragmentActivity 6 | import dagger.android.AndroidInjection 7 | import dagger.android.AndroidInjector 8 | import dagger.android.DispatchingAndroidInjector 9 | import dagger.android.support.HasSupportFragmentInjector 10 | import javax.inject.Inject 11 | 12 | abstract class BaseInjectableActivity : FragmentActivity(), HasSupportFragmentInjector { 13 | 14 | @Inject 15 | lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector 16 | 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | AndroidInjection.inject(this) 19 | super.onCreate(savedInstanceState) 20 | } 21 | 22 | override fun supportFragmentInjector(): AndroidInjector = dispatchingAndroidInjector 23 | 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/cards/ConferenceCardPresenter.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.cards 2 | 3 | import android.support.v17.leanback.widget.ImageCardView 4 | import android.support.v17.leanback.widget.Presenter 5 | import android.support.v4.content.ContextCompat 6 | import android.support.v7.view.ContextThemeWrapper 7 | import android.view.ViewGroup 8 | import com.bumptech.glide.Glide 9 | import com.bumptech.glide.request.RequestOptions 10 | import de.stefanmedack.ccctv.R 11 | import de.stefanmedack.ccctv.persistence.entities.Conference 12 | import kotlin.properties.Delegates 13 | 14 | class ConferenceCardPresenter : Presenter() { 15 | 16 | private var selectedBackgroundColor: Int by Delegates.notNull() 17 | private var defaultBackgroundColor: Int by Delegates.notNull() 18 | 19 | override fun onCreateViewHolder(parent: ViewGroup): Presenter.ViewHolder { 20 | defaultBackgroundColor = ContextCompat.getColor(parent.context, R.color.teal_900) 21 | selectedBackgroundColor = ContextCompat.getColor(parent.context, R.color.amber_800) 22 | 23 | val cardView = object : ImageCardView(ContextThemeWrapper(parent.context, R.style.ConferenceCardStyle)) { 24 | override fun setSelected(selected: Boolean) { 25 | updateCardBackgroundColor(this, selected) 26 | super.setSelected(selected) 27 | } 28 | } 29 | 30 | cardView.isFocusable = true 31 | cardView.isFocusableInTouchMode = true 32 | updateCardBackgroundColor(cardView, false) 33 | return Presenter.ViewHolder(cardView) 34 | } 35 | 36 | override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) { 37 | if (item is Conference) { 38 | val width = viewHolder.view.resources.getDimensionPixelSize(R.dimen.grid_card_width) 39 | val height = viewHolder.view.resources.getDimensionPixelSize(R.dimen.grid_card_height) 40 | (viewHolder.view as ImageCardView).let { 41 | it.titleText = item.title 42 | it.contentText = item.acronym 43 | Glide.with(viewHolder.view) 44 | .load(item.logoUrl) 45 | .apply(RequestOptions() 46 | .error(R.drawable.voctocat) 47 | .override(width, height) 48 | .centerInside() 49 | ) 50 | .into(it.mainImageView) 51 | } 52 | } 53 | } 54 | 55 | override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) { 56 | (viewHolder.view as ImageCardView).let { 57 | it.badgeImage = null 58 | it.mainImage = null 59 | } 60 | } 61 | 62 | private fun updateCardBackgroundColor(view: ImageCardView, selected: Boolean) { 63 | val color = if (selected) selectedBackgroundColor else defaultBackgroundColor 64 | // both background colors should be set because the view's background is temporarily visible during animations. 65 | view.setBackgroundColor(color) 66 | view.setInfoAreaBackgroundColor(color) 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/cards/EventCardPresenter.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.cards 2 | 3 | import android.support.v17.leanback.widget.ImageCardView 4 | import android.support.v17.leanback.widget.Presenter 5 | import android.support.v4.content.ContextCompat 6 | import android.support.v7.view.ContextThemeWrapper 7 | import android.view.ViewGroup 8 | import com.bumptech.glide.Glide 9 | import com.bumptech.glide.request.RequestOptions 10 | import de.stefanmedack.ccctv.R 11 | import de.stefanmedack.ccctv.persistence.entities.Event 12 | import de.stefanmedack.ccctv.util.stripHtml 13 | import kotlin.properties.Delegates 14 | 15 | class EventCardPresenter : Presenter() { 16 | 17 | private var selectedBackgroundColor: Int by Delegates.notNull() 18 | private var defaultBackgroundColor: Int by Delegates.notNull() 19 | 20 | override fun onCreateViewHolder(parent: ViewGroup): Presenter.ViewHolder { 21 | defaultBackgroundColor = ContextCompat.getColor(parent.context, R.color.teal_900) 22 | selectedBackgroundColor = ContextCompat.getColor(parent.context, R.color.amber_800) 23 | 24 | val cardView = object : ImageCardView(ContextThemeWrapper(parent.context, R.style.EventCardStyle)) { 25 | override fun setSelected(selected: Boolean) { 26 | updateCardBackgroundColor(this, selected) 27 | super.setSelected(selected) 28 | } 29 | } 30 | 31 | cardView.isFocusable = true 32 | cardView.isFocusableInTouchMode = true 33 | updateCardBackgroundColor(cardView, false) 34 | return Presenter.ViewHolder(cardView) 35 | } 36 | 37 | override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) { 38 | if (item is Event) { 39 | (viewHolder.view as ImageCardView).let { 40 | it.titleText = item.title.stripHtml() 41 | it.contentText = item.description.stripHtml() 42 | Glide.with(viewHolder.view) 43 | .load(item.thumbUrl) 44 | .apply(RequestOptions() 45 | .error(R.drawable.voctocat) 46 | .centerCrop() 47 | ) 48 | .into(it.mainImageView) 49 | } 50 | } 51 | } 52 | 53 | override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) { 54 | (viewHolder.view as ImageCardView).let { 55 | it.badgeImage = null 56 | it.mainImage = null 57 | } 58 | } 59 | 60 | private fun updateCardBackgroundColor(view: ImageCardView, selected: Boolean) { 61 | val color = if (selected) selectedBackgroundColor else defaultBackgroundColor 62 | // both background colors should be set because the view's background is temporarily visible during animations. 63 | view.setBackgroundColor(color) 64 | view.setInfoAreaBackgroundColor(color) 65 | } 66 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/cards/SpeakerCardPresenter.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.cards 2 | 3 | import android.support.v17.leanback.widget.Presenter 4 | import android.view.ViewGroup 5 | import de.stefanmedack.ccctv.ui.detail.uiModels.SpeakerUiModel 6 | 7 | class SpeakerCardPresenter : Presenter() { 8 | 9 | override fun onCreateViewHolder(parent: ViewGroup): Presenter.ViewHolder 10 | = Presenter.ViewHolder(SpeakerCardView(parent.context)) 11 | 12 | override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) { 13 | if (item is SpeakerUiModel) { 14 | (viewHolder.view as SpeakerCardView).setSpeaker(item.name) 15 | } 16 | } 17 | 18 | override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) {} 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/cards/SpeakerCardView.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.cards 2 | 3 | import android.content.Context 4 | import android.graphics.PorterDuff.Mode.MULTIPLY 5 | import android.support.v17.leanback.widget.BaseCardView 6 | import android.support.v4.content.ContextCompat 7 | import android.view.LayoutInflater 8 | import android.view.View.OnFocusChangeListener 9 | import de.stefanmedack.ccctv.R 10 | import kotlinx.android.synthetic.main.speaker_card.view.* 11 | 12 | class SpeakerCardView(context: Context) : BaseCardView(context, null, R.style.SpeakerCardStyle) { 13 | 14 | init { 15 | LayoutInflater.from(getContext()).inflate(R.layout.speaker_card, this) 16 | onFocusChangeListener = OnFocusChangeListener { _, hasFocus -> 17 | val focusColorResId = if (hasFocus) R.color.focused_speaker else R.color.unfocused_speaker 18 | speaker_image.setColorFilter(ContextCompat.getColor(context, focusColorResId), MULTIPLY) 19 | speaker_name.setTextColor(ContextCompat.getColor(context, focusColorResId)) 20 | } 21 | isFocusable = true 22 | } 23 | 24 | fun setSpeaker(name: String) { 25 | speaker_name.text = name 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/cards/StreamCardPresenter.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.cards 2 | 3 | import android.support.v17.leanback.widget.ImageCardView 4 | import android.support.v17.leanback.widget.Presenter 5 | import android.support.v4.content.ContextCompat 6 | import android.support.v7.view.ContextThemeWrapper 7 | import android.view.ViewGroup 8 | import com.bumptech.glide.Glide 9 | import com.bumptech.glide.request.RequestOptions 10 | import de.stefanmedack.ccctv.R 11 | import info.metadude.java.library.brockman.models.Stream 12 | import kotlin.properties.Delegates 13 | 14 | class StreamCardPresenter(val thumbPictureUrl: String) : Presenter() { 15 | 16 | private var selectedBackgroundColor: Int by Delegates.notNull() 17 | private var defaultBackgroundColor: Int by Delegates.notNull() 18 | 19 | override fun onCreateViewHolder(parent: ViewGroup): Presenter.ViewHolder { 20 | defaultBackgroundColor = ContextCompat.getColor(parent.context, R.color.teal_900) 21 | selectedBackgroundColor = ContextCompat.getColor(parent.context, R.color.amber_800) 22 | 23 | val cardView = object : ImageCardView(ContextThemeWrapper(parent.context, R.style.EventCardStyle)) { 24 | override fun setSelected(selected: Boolean) { 25 | updateCardBackgroundColor(this, selected) 26 | super.setSelected(selected) 27 | } 28 | } 29 | 30 | cardView.isFocusable = true 31 | cardView.isFocusableInTouchMode = true 32 | updateCardBackgroundColor(cardView, false) 33 | return Presenter.ViewHolder(cardView) 34 | } 35 | 36 | override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) { 37 | if (item is Stream) { 38 | (viewHolder.view as ImageCardView).let { 39 | it.titleText = item.display 40 | it.contentText = item.slug 41 | Glide.with(viewHolder.view) 42 | .load(thumbPictureUrl) 43 | .apply(RequestOptions() 44 | .error(R.drawable.voctocat) 45 | .centerCrop() 46 | ) 47 | .into(it.mainImageView) 48 | } 49 | } 50 | } 51 | 52 | override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) { 53 | (viewHolder.view as ImageCardView).let { 54 | it.badgeImage = null 55 | it.mainImage = null 56 | } 57 | } 58 | 59 | private fun updateCardBackgroundColor(view: ImageCardView, selected: Boolean) { 60 | val color = if (selected) selectedBackgroundColor else defaultBackgroundColor 61 | // both background colors should be set because the view's background is temporarily visible during animations. 62 | view.setBackgroundColor(color) 63 | view.setInfoAreaBackgroundColor(color) 64 | } 65 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/detail/DetailActivity.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.detail 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.support.v4.app.ActivityOptionsCompat 7 | import android.view.KeyEvent 8 | import android.view.WindowManager 9 | import android.widget.ImageView 10 | import de.stefanmedack.ccctv.R 11 | import de.stefanmedack.ccctv.persistence.entities.Event 12 | import de.stefanmedack.ccctv.ui.base.BaseInjectableActivity 13 | import de.stefanmedack.ccctv.util.FRAGMENT_ARGUMENTS 14 | import de.stefanmedack.ccctv.util.SHARED_DETAIL_TRANSITION 15 | import de.stefanmedack.ccctv.util.replaceFragmentInTransaction 16 | 17 | class DetailActivity : BaseInjectableActivity() { 18 | 19 | private val DETAIL_TAG = "DETAIL_TAG" 20 | 21 | var fragment: DetailFragment? = null 22 | 23 | public override fun onCreate(savedInstanceState: Bundle?) { 24 | super.onCreate(savedInstanceState) 25 | setContentView(R.layout.fragment_activity) 26 | 27 | // prevent stand-by while playing videos 28 | window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 29 | 30 | fragment = DetailFragment() 31 | fragment?.let { frag -> 32 | frag.arguments = intent.getBundleExtra(FRAGMENT_ARGUMENTS) 33 | replaceFragmentInTransaction(frag, R.id.fragment, DETAIL_TAG) 34 | } 35 | } 36 | 37 | override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { 38 | return (fragment?.onKeyDown(keyCode) == true) || super.onKeyDown(keyCode, event) 39 | } 40 | 41 | companion object { 42 | fun start(activity: Activity, event: Event, sharedImage: ImageView? = null) { 43 | val intent = Intent(activity, DetailActivity::class.java) 44 | intent.putExtra(FRAGMENT_ARGUMENTS, DetailFragment.getBundle(eventId = event.id, eventThumbUrl = event.thumbUrl)) 45 | 46 | if (sharedImage != null) { 47 | val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation( 48 | activity, 49 | sharedImage, 50 | SHARED_DETAIL_TRANSITION).toBundle() 51 | activity.startActivity(intent, bundle) 52 | } else { 53 | activity.startActivity(intent) 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/detail/DetailContract.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.detail 2 | 3 | import de.stefanmedack.ccctv.ui.detail.uiModels.DetailUiModel 4 | import de.stefanmedack.ccctv.ui.detail.uiModels.VideoPlaybackUiModel 5 | import io.reactivex.Flowable 6 | import io.reactivex.Single 7 | 8 | internal interface Inputs { 9 | fun toggleBookmark() 10 | fun savePlaybackPosition(playedSeconds: Int, totalDurationSeconds: Int) 11 | } 12 | 13 | internal interface Outputs { 14 | val detailData: Single 15 | val videoPlaybackData: Single 16 | val isBookmarked: Flowable 17 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/detail/DetailDescriptionPresenter.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.detail 2 | 3 | import android.annotation.SuppressLint 4 | import android.support.v17.leanback.widget.AbstractDetailsDescriptionPresenter 5 | import de.stefanmedack.ccctv.persistence.entities.Event 6 | import de.stefanmedack.ccctv.util.stripHtml 7 | 8 | class DetailDescriptionPresenter : AbstractDetailsDescriptionPresenter() { 9 | 10 | override fun onBindDescription(viewHolder: AbstractDetailsDescriptionPresenter.ViewHolder, item: Any) { 11 | if (item is Event) { 12 | viewHolder.title.text = item.title.stripHtml() 13 | 14 | val separator = if (item.subtitle.stripHtml().isNotEmpty()) "\n\n" else "" 15 | @SuppressLint("SetTextI18n") 16 | viewHolder.subtitle.text = "${item.subtitle.stripHtml()}$separator${item.description.stripHtml()}" 17 | 18 | // TODO for some reason, leanback body can not display all content -> find solution and use next 2 lines again 19 | // viewHolder.subtitle.text = item.subtitle.stripHtml() 20 | // viewHolder.body.text = item.description.stripHtml() 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/detail/DetailModule.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.detail 2 | 3 | import dagger.Module 4 | import dagger.android.ContributesAndroidInjector 5 | 6 | @Module 7 | abstract class DetailModule { 8 | 9 | @ContributesAndroidInjector 10 | abstract fun contributeDetailFragment(): DetailFragment 11 | 12 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/detail/DetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.detail 2 | 3 | import de.stefanmedack.ccctv.persistence.entities.Event 4 | import de.stefanmedack.ccctv.repository.EventRepository 5 | import de.stefanmedack.ccctv.ui.base.BaseDisposableViewModel 6 | import de.stefanmedack.ccctv.ui.detail.uiModels.DetailUiModel 7 | import de.stefanmedack.ccctv.ui.detail.uiModels.SpeakerUiModel 8 | import de.stefanmedack.ccctv.ui.detail.uiModels.VideoPlaybackUiModel 9 | import de.stefanmedack.ccctv.util.EMPTY_STRING 10 | import de.stefanmedack.ccctv.util.getRelatedEventGuidsWeighted 11 | import io.reactivex.Completable 12 | import io.reactivex.Flowable 13 | import io.reactivex.Single 14 | import io.reactivex.rxkotlin.Singles 15 | import io.reactivex.rxkotlin.subscribeBy 16 | import io.reactivex.rxkotlin.withLatestFrom 17 | import io.reactivex.subjects.PublishSubject 18 | import timber.log.Timber 19 | import javax.inject.Inject 20 | import info.metadude.kotlin.library.c3media.models.Event as EventRemote 21 | 22 | class DetailViewModel @Inject constructor( 23 | private val repository: EventRepository 24 | ) : BaseDisposableViewModel(), Inputs, Outputs { 25 | 26 | internal val inputs: Inputs = this 27 | internal val outputs: Outputs = this 28 | 29 | private var eventId: String = EMPTY_STRING 30 | 31 | fun init(eventId: String) { 32 | this.eventId = eventId 33 | 34 | disposables.addAll( 35 | doToggleBookmark.subscribeBy(onError = { Timber.w("DetailViewModel - doToggleBookmark - onError $it") }), 36 | doSavePlayedSeconds.subscribeBy(onError = { Timber.w("DetailViewModel - doSavePlayedSeconds - onError $it") }) 37 | ) 38 | } 39 | 40 | // 41 | 42 | override fun toggleBookmark() { 43 | bookmarkClickStream.onNext(0) 44 | } 45 | 46 | override fun savePlaybackPosition(playedSeconds: Int, totalDurationSeconds: Int) { 47 | savePlayPositionStream.onNext(PlaybackData(playedSeconds, totalDurationSeconds)) 48 | } 49 | 50 | // 51 | 52 | // 53 | 54 | override val detailData: Single 55 | get() = Singles.zip( 56 | eventWithRelated.firstOrError(), 57 | wasPlayed, 58 | { first, wasPlayed -> first.copy(wasPlayed = wasPlayed) }) 59 | 60 | override val videoPlaybackData: Single 61 | get() = Singles.zip( 62 | repository.getEventWithRecordings(eventId), 63 | repository.getPlayedSeconds(eventId), 64 | ::VideoPlaybackUiModel) 65 | 66 | override val isBookmarked: Flowable 67 | get() = repository.isBookmarked(eventId) 68 | 69 | // 70 | 71 | private val bookmarkClickStream = PublishSubject.create() 72 | private val savePlayPositionStream = PublishSubject.create() 73 | 74 | private val doToggleBookmark 75 | get() = bookmarkClickStream 76 | .withLatestFrom(isBookmarked.toObservable(), { _, t2 -> t2 }) 77 | .flatMapCompletable { updateBookmarkState(it) } 78 | 79 | private val doSavePlayedSeconds 80 | get() = savePlayPositionStream 81 | .flatMapCompletable { 82 | if (it.hasPlayedMinimumToSave() && !it.hasAlmostFinished()) { 83 | repository.savePlayedSeconds(eventId, it.playedSeconds) 84 | } else { 85 | repository.deletePlayedSeconds(eventId) 86 | } 87 | } 88 | 89 | private val eventWithRelated: Flowable 90 | get() = repository.getEvent(eventId) 91 | .flatMap { event -> 92 | getRelatedEvents(event).map { DetailUiModel(event = event, speaker = event.persons.map { SpeakerUiModel(it) }, related = it) } 93 | } 94 | 95 | private fun getRelatedEvents(event: Event): Flowable> = repository.getEvents(event.getRelatedEventGuidsWeighted()) 96 | 97 | private val wasPlayed: Single 98 | get() = repository.getPlayedSeconds(eventId).map { it > 0 } 99 | 100 | private fun updateBookmarkState(isBookmarked: Boolean): Completable = repository.changeBookmarkState(eventId, !isBookmarked) 101 | 102 | private data class PlaybackData( 103 | val playedSeconds: Int, 104 | val totalDurationSeconds: Int 105 | ) { 106 | 107 | private val MINIMUM_PLAYBACK_SECONDS_TO_SAVE = 60 108 | private val MAXIMUM_PLAYBACK_PERCENT_TO_SAVE = .9f 109 | 110 | fun hasPlayedMinimumToSave(): Boolean = this.playedSeconds > MINIMUM_PLAYBACK_SECONDS_TO_SAVE 111 | 112 | fun hasAlmostFinished(): Boolean = when { 113 | totalDurationSeconds > 0 -> (playedSeconds.toFloat() / totalDurationSeconds.toFloat()) > MAXIMUM_PLAYBACK_PERCENT_TO_SAVE 114 | else -> true 115 | } 116 | } 117 | 118 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/detail/playback/VideoMediaPlayerGlue.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.detail.playback 2 | 3 | import android.app.Activity 4 | import android.support.v17.leanback.media.PlaybackTransportControlGlue 5 | import android.support.v17.leanback.media.PlayerAdapter 6 | import android.support.v17.leanback.widget.Action 7 | import android.support.v17.leanback.widget.ArrayObjectAdapter 8 | import android.support.v17.leanback.widget.PlaybackControlsRow 9 | import android.support.v17.leanback.widget.PlaybackControlsRow.HighQualityAction.INDEX_OFF 10 | import android.support.v17.leanback.widget.PlaybackControlsRow.HighQualityAction.INDEX_ON 11 | import de.stefanmedack.ccctv.ui.detail.playback.actions.AspectRatioAction 12 | 13 | class VideoMediaPlayerGlue(activity: Activity, playerAdapter: T) 14 | : PlaybackTransportControlGlue(activity, playerAdapter) { 15 | 16 | private val pipAction = PlaybackControlsRow.PictureInPictureAction(activity) 17 | private val highQualityAction = PlaybackControlsRow.HighQualityAction(activity).apply { index = INDEX_ON } 18 | private val aspectRatioAction = AspectRatioAction(activity) 19 | 20 | override fun onCreatePrimaryActions(adapter: ArrayObjectAdapter?) { 21 | super.onCreatePrimaryActions(adapter) 22 | // TODO add back PIP 23 | // if (android.os.Build.VERSION.SDK_INT > 23) { 24 | // adapter?.add(pipAction) 25 | // } 26 | adapter?.add(highQualityAction) 27 | adapter?.add(aspectRatioAction) 28 | } 29 | 30 | override fun onActionClicked(action: Action) { 31 | if (shouldDispatchAction(action)) { 32 | dispatchAction(action) 33 | return 34 | } 35 | super.onActionClicked(action) 36 | } 37 | 38 | private fun shouldDispatchAction(action: Action): Boolean = 39 | action == pipAction 40 | || action == highQualityAction 41 | || action == aspectRatioAction 42 | 43 | private fun dispatchAction(action: Action) { 44 | when (action) { 45 | pipAction -> if (android.os.Build.VERSION.SDK_INT > 23) (context as Activity).enterPictureInPictureMode() 46 | highQualityAction -> { 47 | if (highQualityAction.index == INDEX_ON) { 48 | highQualityAction.index = INDEX_OFF 49 | (playerAdapter as ExoPlayerAdapter).changeQuality(false) 50 | } else { 51 | highQualityAction.index = INDEX_ON 52 | (playerAdapter as ExoPlayerAdapter).changeQuality(true) 53 | } 54 | notifyActionChanged(highQualityAction, controlsRow.primaryActionsAdapter as ArrayObjectAdapter) 55 | } 56 | aspectRatioAction -> (playerAdapter as ExoPlayerAdapter).toggleAspectRatio() 57 | } 58 | } 59 | 60 | private fun notifyActionChanged(action: PlaybackControlsRow.MultiAction, adapter: ArrayObjectAdapter?) { 61 | if (adapter != null) { 62 | val index = adapter.indexOf(action) 63 | if (index >= 0) { 64 | adapter.notifyArrayItemRangeChanged(index, 1) 65 | } 66 | } 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/detail/playback/actions/AspectRatioAction.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.detail.playback.actions 2 | 3 | import android.content.Context 4 | import android.support.v17.leanback.widget.PlaybackControlsRow 5 | import android.support.v4.content.ContextCompat 6 | import de.stefanmedack.ccctv.R 7 | 8 | const val ASPECT_RATIO_ID = 42 9 | 10 | class AspectRatioAction(context: Context) : PlaybackControlsRow.MultiAction(ASPECT_RATIO_ID) { 11 | init { 12 | setDrawables(arrayOf(ContextCompat.getDrawable(context, R.drawable.ic_aspect_ratio))) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/detail/uiModels/DetailUiModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.detail.uiModels 2 | 3 | import de.stefanmedack.ccctv.persistence.entities.Event 4 | 5 | data class DetailUiModel( 6 | val event: Event, 7 | val speaker: List = listOf(), 8 | val related: List = listOf(), 9 | val wasPlayed : Boolean = false 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/detail/uiModels/SpeakerUiModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.detail.uiModels 2 | 3 | data class SpeakerUiModel( 4 | val name: String 5 | ) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/detail/uiModels/VideoPlaybackUiModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.detail.uiModels 2 | 3 | import info.metadude.kotlin.library.c3media.models.Event 4 | 5 | data class VideoPlaybackUiModel(val event: Event, val retainedPlaybackSeconds: Int) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/events/EventsActivity.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.events 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.support.v4.app.ActivityOptionsCompat 7 | import de.stefanmedack.ccctv.R 8 | import de.stefanmedack.ccctv.persistence.entities.Conference 9 | import de.stefanmedack.ccctv.ui.base.BaseInjectableActivity 10 | import de.stefanmedack.ccctv.util.FRAGMENT_ARGUMENTS 11 | import de.stefanmedack.ccctv.util.addFragmentInTransaction 12 | 13 | class EventsActivity : BaseInjectableActivity() { 14 | 15 | private val EVENTS_TAG = "EVENTS_TAG" 16 | 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | setContentView(R.layout.fragment_activity) 20 | 21 | if (savedInstanceState == null) { 22 | addFragmentInTransaction( 23 | fragment = EventsFragment().apply { arguments = intent.getBundleExtra(FRAGMENT_ARGUMENTS) }, 24 | containerId = R.id.fragment, 25 | tag = EVENTS_TAG) 26 | } 27 | } 28 | 29 | companion object { 30 | fun startForConference(activity: Activity, conference: Conference) { 31 | val intent = Intent(activity.baseContext, EventsActivity::class.java) 32 | intent.putExtra(FRAGMENT_ARGUMENTS, EventsFragment.getBundleForConference( 33 | conferenceAcronym = conference.acronym, 34 | title = conference.title, 35 | conferenceLogoUrl = conference.logoUrl 36 | )) 37 | activity.startActivity(intent, ActivityOptionsCompat.makeSceneTransitionAnimation(activity).toBundle()) 38 | } 39 | 40 | fun startWithSearch(activity: Activity, searchQuery: String) { 41 | val intent = Intent(activity.baseContext, EventsActivity::class.java) 42 | intent.putExtra(FRAGMENT_ARGUMENTS, EventsFragment.getBundleForSearch( 43 | searchQuery = searchQuery, 44 | title = activity.getString(R.string.events_view_search_result_header, searchQuery) 45 | )) 46 | activity.startActivity(intent, ActivityOptionsCompat.makeSceneTransitionAnimation(activity).toBundle()) 47 | } 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/events/EventsModule.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.events 2 | 3 | import dagger.Module 4 | import dagger.android.ContributesAndroidInjector 5 | 6 | @Module 7 | abstract class EventsModule { 8 | 9 | @ContributesAndroidInjector 10 | abstract fun contributeEventsFragment(): EventsFragment 11 | 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/events/EventsViewModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.events 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import de.stefanmedack.ccctv.model.Resource 5 | import de.stefanmedack.ccctv.persistence.entities.Event 6 | import de.stefanmedack.ccctv.persistence.toEntity 7 | import de.stefanmedack.ccctv.repository.ConferenceRepository 8 | import de.stefanmedack.ccctv.util.EMPTY_STRING 9 | import de.stefanmedack.ccctv.util.applySchedulers 10 | import info.metadude.kotlin.library.c3media.RxC3MediaService 11 | import io.reactivex.Flowable 12 | import javax.inject.Inject 13 | 14 | class EventsViewModel @Inject constructor( 15 | private val c3MediaService: RxC3MediaService, 16 | private val repository: ConferenceRepository 17 | ) : ViewModel() { 18 | 19 | private var searchQuery: String? = null 20 | private var conferenceAcronym: String? = null 21 | 22 | lateinit var events: Flowable>> 23 | 24 | fun initWithSearch(searchQuery: String) { 25 | this.searchQuery = searchQuery 26 | this.events = c3MediaService.searchEvents(searchQuery) 27 | .applySchedulers() 28 | .map>> { Resource.Success(it.events.mapNotNull { it.toEntity(EMPTY_STRING) }) } 29 | .toFlowable() 30 | } 31 | 32 | fun initWithConference(conferenceAcronym: String) { 33 | this.conferenceAcronym = conferenceAcronym 34 | this.events = repository.conferenceWithEvents(conferenceAcronym) 35 | .map>> { 36 | when (it) { 37 | is Resource.Success -> Resource.Success(it.data.events.sortedByDescending { it.viewCount }) 38 | is Resource.Loading -> Resource.Loading() 39 | is Resource.Error -> Resource.Error(it.msg, it.data?.events) 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.main 2 | 3 | import android.os.Bundle 4 | import android.view.KeyEvent 5 | import de.stefanmedack.ccctv.R 6 | import de.stefanmedack.ccctv.ui.base.BaseInjectableActivity 7 | import de.stefanmedack.ccctv.util.replaceFragmentInTransaction 8 | 9 | class MainActivity : BaseInjectableActivity() { 10 | 11 | private val MAIN_TAG = "MAIN_TAG" 12 | 13 | var fragment: MainFragment? = null 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | setContentView(R.layout.fragment_activity) 18 | 19 | fragment = supportFragmentManager.findFragmentByTag(MAIN_TAG) as? MainFragment ?: MainFragment() 20 | fragment?.let { frag -> 21 | replaceFragmentInTransaction(frag, R.id.fragment, MAIN_TAG) 22 | } 23 | } 24 | 25 | override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { 26 | return (fragment?.onKeyDown(keyCode) == true) || super.onKeyDown(keyCode, event) 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/main/MainFragmentFactory.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.main 2 | 3 | import android.support.v17.leanback.app.BrowseSupportFragment 4 | import android.support.v17.leanback.widget.Row 5 | import android.support.v4.app.Fragment 6 | import de.stefanmedack.ccctv.model.ConferenceGroup 7 | import de.stefanmedack.ccctv.ui.about.AboutFragment 8 | import de.stefanmedack.ccctv.ui.main.conferences.ConferencesFragment 9 | import de.stefanmedack.ccctv.ui.main.home.HomeFragment 10 | import de.stefanmedack.ccctv.ui.main.streaming.LiveStreamingFragment 11 | import de.stefanmedack.ccctv.util.CONFERENCE_GROUP_TRANSLATIONS 12 | 13 | class MainFragmentFactory : BrowseSupportFragment.FragmentFactory() { 14 | override fun createFragment(rowObj: Any): Fragment { 15 | return when ((rowObj as Row).headerItem.id) { 16 | KEY_ABOUT_PAGE -> AboutFragment() 17 | KEY_STREAMING_PAGE -> LiveStreamingFragment.create(rowObj.headerItem.name) 18 | KEY_HOME_PAGE -> HomeFragment() 19 | else -> ConferencesFragment.create(CONFERENCE_GROUP_TRANSLATIONS // reverse lookup of ConferenceGroup by StringResourceId 20 | .filterValues { it == rowObj.id.toInt() } 21 | .keys 22 | .firstOrNull() ?: ConferenceGroup.OTHER) 23 | } 24 | } 25 | 26 | companion object { 27 | const val KEY_HOME_SECTION = 1L 28 | const val KEY_HOME_PAGE = 2L 29 | const val KEY_STREAMING_SECTION = 3L 30 | const val KEY_STREAMING_PAGE = 4L 31 | const val KEY_LIBRARY_SECTION = 5L 32 | const val KEY_LIBRARY_PAGE = 6L 33 | const val KEY_ABOUT_SECTION = 7L 34 | const val KEY_ABOUT_PAGE = 8L 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/main/MainModule.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.main 2 | 3 | import dagger.Module 4 | import dagger.android.ContributesAndroidInjector 5 | import de.stefanmedack.ccctv.ui.main.conferences.ConferencesFragment 6 | import de.stefanmedack.ccctv.ui.main.home.HomeFragment 7 | import de.stefanmedack.ccctv.ui.main.streaming.LiveStreamingFragment 8 | 9 | @Module 10 | abstract class MainModule { 11 | 12 | @ContributesAndroidInjector 13 | abstract fun contributeMainFragment(): MainFragment 14 | 15 | @ContributesAndroidInjector 16 | abstract fun contributeHomeFragment(): HomeFragment 17 | 18 | @ContributesAndroidInjector 19 | abstract fun contributeConferencesFragment(): ConferencesFragment 20 | 21 | @ContributesAndroidInjector 22 | abstract fun contributeLiveStreamingFragment(): LiveStreamingFragment 23 | 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/main/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.main 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import de.stefanmedack.ccctv.model.ConferenceGroup 5 | import de.stefanmedack.ccctv.model.Resource 6 | import de.stefanmedack.ccctv.repository.ConferenceRepository 7 | import de.stefanmedack.ccctv.repository.StreamingRepository 8 | import de.stefanmedack.ccctv.util.groupConferences 9 | import info.metadude.java.library.brockman.models.Offer 10 | import io.reactivex.Flowable 11 | import io.reactivex.rxkotlin.Flowables 12 | import javax.inject.Inject 13 | 14 | class MainViewModel @Inject constructor( 15 | private val conferenceRepository: ConferenceRepository, 16 | private val streamingRepository: StreamingRepository 17 | ) : ViewModel() { 18 | 19 | data class MainUiModel( 20 | val conferenceGroupResource: Resource>, 21 | val offersResource: Resource> 22 | ) 23 | 24 | val data: Flowable 25 | get() = Flowables.combineLatest(conferences, streams, ::MainUiModel) 26 | 27 | private val conferences: Flowable>> 28 | get() = conferenceRepository.conferences 29 | .map>> { 30 | when (it) { 31 | // TODO create helper method for Success-Mapping 32 | is Resource.Success -> Resource.Success(it.data 33 | .groupConferences() 34 | .keys 35 | .toList() 36 | ) 37 | is Resource.Loading -> Resource.Loading() 38 | is Resource.Error -> Resource.Error(it.msg) 39 | } 40 | } 41 | 42 | private val streams 43 | get() = streamingRepository.streams 44 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/main/conferences/ConferencesFragment.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.main.conferences 2 | 3 | import android.arch.lifecycle.ViewModelProvider 4 | import android.arch.lifecycle.ViewModelProviders 5 | import android.os.Bundle 6 | import android.support.v17.leanback.app.BrowseSupportFragment 7 | import android.support.v17.leanback.app.BrowseSupportFragment.MainFragmentAdapterProvider 8 | import android.support.v17.leanback.app.VerticalGridSupportFragment 9 | import android.support.v17.leanback.widget.ArrayObjectAdapter 10 | import android.support.v17.leanback.widget.FocusHighlight 11 | import android.support.v17.leanback.widget.OnItemViewClickedListener 12 | import android.support.v17.leanback.widget.VerticalGridPresenter 13 | import android.view.View 14 | import android.widget.Toast 15 | import androidx.core.os.bundleOf 16 | import dagger.android.support.AndroidSupportInjection 17 | import de.stefanmedack.ccctv.model.ConferenceGroup 18 | import de.stefanmedack.ccctv.model.Resource 19 | import de.stefanmedack.ccctv.persistence.entities.Conference 20 | import de.stefanmedack.ccctv.ui.cards.ConferenceCardPresenter 21 | import de.stefanmedack.ccctv.ui.events.EventsActivity 22 | import de.stefanmedack.ccctv.util.CONFERENCE_GROUP 23 | import de.stefanmedack.ccctv.util.plusAssign 24 | import io.reactivex.disposables.CompositeDisposable 25 | import io.reactivex.rxkotlin.subscribeBy 26 | import javax.inject.Inject 27 | 28 | class ConferencesFragment : VerticalGridSupportFragment(), MainFragmentAdapterProvider { 29 | 30 | private val COLUMNS = 4 31 | private val ZOOM_FACTOR = FocusHighlight.ZOOM_FACTOR_SMALL 32 | 33 | @Inject 34 | lateinit var viewModelFactory: ViewModelProvider.Factory 35 | 36 | private val viewModel: ConferencesViewModel by lazy { 37 | ViewModelProviders.of(this, viewModelFactory).get(ConferencesViewModel::class.java).apply { 38 | init(arguments?.getSerializable(CONFERENCE_GROUP) as? ConferenceGroup ?: ConferenceGroup.OTHER) 39 | } 40 | } 41 | 42 | private val disposables = CompositeDisposable() 43 | 44 | private val mainFragmentAdapter = BrowseSupportFragment.MainFragmentAdapter(this) 45 | 46 | override fun getMainFragmentAdapter(): BrowseSupportFragment.MainFragmentAdapter<*> { 47 | return mainFragmentAdapter 48 | } 49 | 50 | override fun onCreate(savedInstanceState: Bundle?) { 51 | super.onCreate(savedInstanceState) 52 | 53 | setupUi() 54 | } 55 | 56 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 57 | AndroidSupportInjection.inject(this) 58 | super.onViewCreated(view, savedInstanceState) 59 | 60 | bindViewModel() 61 | } 62 | 63 | override fun onDestroy() { 64 | disposables.clear() 65 | super.onDestroy() 66 | } 67 | 68 | private fun setupUi() { 69 | gridPresenter = VerticalGridPresenter(ZOOM_FACTOR).apply { 70 | numberOfColumns = COLUMNS 71 | } 72 | 73 | adapter = ArrayObjectAdapter(ConferenceCardPresenter()) 74 | 75 | onItemViewClickedListener = OnItemViewClickedListener { _, item, _, _ -> 76 | if (item is Conference) { 77 | activity?.let { 78 | EventsActivity.startForConference(it, item) 79 | } 80 | } 81 | } 82 | } 83 | 84 | private fun bindViewModel() { 85 | disposables.add(viewModel.conferences 86 | .subscribeBy( 87 | onNext = { render(it) }, 88 | onError = { it.printStackTrace() } 89 | ) 90 | ) 91 | } 92 | 93 | private fun render(resource: Resource>) { 94 | mainFragmentAdapter.fragmentHost.notifyDataReady(mainFragmentAdapter) 95 | 96 | when (resource) { 97 | is Resource.Success -> (adapter as ArrayObjectAdapter) += resource.data 98 | is Resource.Error -> Toast.makeText(activity, resource.msg, Toast.LENGTH_LONG).show() 99 | } 100 | } 101 | 102 | companion object { 103 | 104 | fun create(conferenceGroup: ConferenceGroup): ConferencesFragment = ConferencesFragment().also { fragment -> 105 | fragment.arguments = bundleOf(CONFERENCE_GROUP to conferenceGroup) 106 | } 107 | 108 | } 109 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/main/conferences/ConferencesViewModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.main.conferences 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import de.stefanmedack.ccctv.model.ConferenceGroup 5 | import de.stefanmedack.ccctv.model.Resource 6 | import de.stefanmedack.ccctv.persistence.entities.Conference 7 | import de.stefanmedack.ccctv.repository.ConferenceRepository 8 | import io.reactivex.Flowable 9 | import javax.inject.Inject 10 | 11 | class ConferencesViewModel @Inject constructor( 12 | private val repository: ConferenceRepository 13 | ) : ViewModel() { 14 | 15 | private lateinit var conferenceGroup: ConferenceGroup 16 | 17 | fun init(conferenceGroup: ConferenceGroup) { 18 | this.conferenceGroup = conferenceGroup 19 | } 20 | 21 | val conferences: Flowable>> 22 | get() = repository.loadedConferences(conferenceGroup.name) 23 | .map { 24 | if (it is Resource.Success) { 25 | Resource.Success( 26 | when (conferenceGroup) { 27 | ConferenceGroup.MRMCD, ConferenceGroup.OTHER_CONFERENCES, ConferenceGroup.OTHER -> it.data 28 | .sortedByDescending { it.eventLastReleasedAt } 29 | else -> it.data 30 | .sortedByDescending { it.title } 31 | } 32 | ) 33 | } else { 34 | it 35 | } 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/main/home/HomeContract.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.main.home 2 | 3 | import de.stefanmedack.ccctv.ui.main.home.uiModel.HomeUiModel 4 | import io.reactivex.Flowable 5 | 6 | internal interface Inputs 7 | 8 | internal interface Outputs { 9 | val data: Flowable 10 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/main/home/HomeFragment.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.main.home 2 | 3 | import android.arch.lifecycle.ViewModelProvider 4 | import android.arch.lifecycle.ViewModelProviders 5 | import android.os.Bundle 6 | import android.support.v17.leanback.app.RowsSupportFragment 7 | import android.support.v17.leanback.widget.* 8 | import android.view.View 9 | import dagger.android.support.AndroidSupportInjection 10 | import de.stefanmedack.ccctv.R 11 | import de.stefanmedack.ccctv.persistence.entities.Event 12 | import de.stefanmedack.ccctv.ui.cards.EventCardPresenter 13 | import de.stefanmedack.ccctv.ui.detail.DetailActivity 14 | import de.stefanmedack.ccctv.ui.main.home.uiModel.HomeUiModel 15 | import io.reactivex.disposables.CompositeDisposable 16 | import io.reactivex.rxkotlin.subscribeBy 17 | import javax.inject.Inject 18 | 19 | class HomeFragment : RowsSupportFragment() { 20 | 21 | @Inject 22 | lateinit var viewModelFactory: ViewModelProvider.Factory 23 | 24 | private val viewModel: HomeViewModel by lazy { 25 | ViewModelProviders.of(this, viewModelFactory).get(HomeViewModel::class.java) 26 | } 27 | 28 | private val disposables = CompositeDisposable() 29 | 30 | private val bookmarkHeaderString by lazy { getString(R.string.home_header_bookmarked) } 31 | private val playedHeaderString by lazy { getString(R.string.home_header_played) } 32 | private val promotedHeaderString by lazy { getString(R.string.home_header_promoted) } 33 | private val trendingHeaderString by lazy { getString(R.string.home_header_trending) } 34 | private val popularHeaderString by lazy { getString(R.string.home_header_popular) } 35 | private val recentHeaderString by lazy { getString(R.string.home_header_recent) } 36 | 37 | // remember all EventListAdapter created by their title, so updating their content is easier 38 | private val eventAdapterMap = mutableMapOf() 39 | 40 | private val eventDiffCallback: DiffCallback = object : DiffCallback() { 41 | override fun areItemsTheSame(oldItem: Event, newItem: Event) = oldItem.id == newItem.id 42 | override fun areContentsTheSame(oldItem: Event, newItem: Event) = oldItem == newItem 43 | } 44 | 45 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 46 | AndroidSupportInjection.inject(this) 47 | super.onViewCreated(view, savedInstanceState) 48 | 49 | setupUi() 50 | bindViewModel() 51 | } 52 | 53 | override fun onDestroy() { 54 | disposables.clear() 55 | super.onDestroy() 56 | } 57 | 58 | private fun setupUi() { 59 | adapter = ArrayObjectAdapter(ListRowPresenter()) 60 | onItemViewClickedListener = OnItemViewClickedListener { itemViewHolder, item, _, _ -> 61 | if (item is Event) { 62 | activity?.let { 63 | DetailActivity.start(it, item, (itemViewHolder.view as ImageCardView).mainImageView) 64 | } 65 | } 66 | } 67 | } 68 | 69 | private fun bindViewModel() { 70 | disposables.add(viewModel.outputs.data 71 | .subscribeBy( 72 | onNext = { render(it) }, 73 | onError = { it.printStackTrace() } 74 | ) 75 | ) 76 | } 77 | 78 | private fun render(data: HomeUiModel) { 79 | mainFragmentAdapter.fragmentHost.notifyDataReady(mainFragmentAdapter) 80 | (adapter as ArrayObjectAdapter).let { adapter -> 81 | adapter.updateEventsRow(bookmarkHeaderString, data.bookmarkedEvents) 82 | adapter.updateEventsRow(playedHeaderString, data.playedEvents) 83 | // TODO promoted and trending are pretty similar - decide on one 84 | // adapter.updateEventsRow(promotedHeaderString, data.promoted) 85 | adapter.updateEventsRow(trendingHeaderString, data.trendingEvents) 86 | adapter.updateEventsRow(popularHeaderString, data.popularEvents) 87 | adapter.updateEventsRow(recentHeaderString, data.recentEvents) 88 | } 89 | } 90 | 91 | private fun ArrayObjectAdapter.updateEventsRow(headerItemTitle: String, events: List) { 92 | if (events.isNotEmpty()) { 93 | // get or create (and remember) EventListAdapter for this headerItemTitle and update its events 94 | eventAdapterMap.getOrPut( 95 | key = headerItemTitle, 96 | defaultValue = { createAndAddHorizontalEventListAdapter(headerItemTitle) } 97 | ).setItems(events, eventDiffCallback) 98 | } else { 99 | // remove entire listRow and do not remember its EventListAdapter for this headerItemTitle 100 | getListRow(headerItemTitle)?.let { listRow -> remove(listRow) } 101 | eventAdapterMap.remove(headerItemTitle) 102 | } 103 | } 104 | 105 | private fun ArrayObjectAdapter.createAndAddHorizontalEventListAdapter(headerItemTitle: String): ArrayObjectAdapter { 106 | val eventListAdapter = ArrayObjectAdapter(EventCardPresenter()) 107 | add(ListRow(headerItemTitle.hashCode().toLong(), HeaderItem(headerItemTitle), eventListAdapter)) 108 | return eventListAdapter 109 | } 110 | 111 | private fun ArrayObjectAdapter.getListRow(title: String): ListRow? { 112 | for (index in 0 until size()) { 113 | val listRow = get(index) as? ListRow 114 | if (listRow?.id == title.hashCode().toLong()) { 115 | return listRow 116 | } 117 | } 118 | return null 119 | } 120 | 121 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/main/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.main.home 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import de.stefanmedack.ccctv.persistence.entities.Event 5 | import de.stefanmedack.ccctv.repository.EventRepository 6 | import de.stefanmedack.ccctv.ui.main.home.uiModel.HomeUiModel 7 | import de.stefanmedack.ccctv.util.applySchedulers 8 | import io.reactivex.Flowable 9 | import io.reactivex.rxkotlin.Flowables 10 | import javax.inject.Inject 11 | 12 | class HomeViewModel @Inject constructor( 13 | private val eventRepository: EventRepository 14 | ) : ViewModel(), Inputs, Outputs { 15 | 16 | internal val inputs: Inputs = this 17 | internal val outputs: Outputs = this 18 | 19 | override val data: Flowable 20 | get() = Flowables.combineLatest( 21 | bookmarks, 22 | playedEvents, 23 | promotedEvents, 24 | trendingEvents, 25 | popularEvents, 26 | recentEvents, 27 | ::HomeUiModel) 28 | .applySchedulers() 29 | 30 | private val bookmarks: Flowable> 31 | get() = eventRepository.getBookmarkedEvents() 32 | 33 | private val playedEvents: Flowable> 34 | get() = eventRepository.getPlayedEvents() 35 | 36 | private val promotedEvents: Flowable> 37 | get() = eventRepository.getPromotedEvents() 38 | 39 | private val trendingEvents: Flowable> 40 | get() = eventRepository.getTrendingEvents() 41 | 42 | private val recentEvents: Flowable> 43 | get() = eventRepository.getRecentEvents() 44 | 45 | private val popularEvents: Flowable> 46 | get() = eventRepository.getPopularEvents() 47 | 48 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/main/home/uiModel/HomeUiModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.main.home.uiModel 2 | 3 | import de.stefanmedack.ccctv.persistence.entities.Event 4 | 5 | data class HomeUiModel( 6 | val bookmarkedEvents: List, 7 | val playedEvents: List, 8 | val promotedEvents: List, 9 | val trendingEvents: List, 10 | val popularEvents: List, 11 | val recentEvents: List 12 | ) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/main/streaming/LiveStreamingFragment.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.main.streaming 2 | 3 | import android.arch.lifecycle.ViewModelProvider 4 | import android.arch.lifecycle.ViewModelProviders 5 | import android.os.Bundle 6 | import android.support.v17.leanback.app.RowsSupportFragment 7 | import android.support.v17.leanback.widget.* 8 | import android.view.View 9 | import androidx.core.os.bundleOf 10 | import dagger.android.support.AndroidSupportInjection 11 | import de.stefanmedack.ccctv.ui.cards.StreamCardPresenter 12 | import de.stefanmedack.ccctv.ui.streaming.StreamingPlayerActivity 13 | import de.stefanmedack.ccctv.util.STREAM_ID 14 | import de.stefanmedack.ccctv.util.plusAssign 15 | import info.metadude.java.library.brockman.models.Room 16 | import info.metadude.java.library.brockman.models.Stream 17 | import io.reactivex.disposables.CompositeDisposable 18 | import io.reactivex.rxkotlin.subscribeBy 19 | import javax.inject.Inject 20 | 21 | class LiveStreamingFragment : RowsSupportFragment() { 22 | 23 | @Inject 24 | lateinit var viewModelFactory: ViewModelProvider.Factory 25 | 26 | private val viewModel: LiveStreamingViewModel by lazy { 27 | ViewModelProviders.of(this, viewModelFactory).get(LiveStreamingViewModel::class.java).apply { 28 | init(arguments?.getString(STREAM_ID, "") ?: "") 29 | } 30 | } 31 | 32 | private val disposables = CompositeDisposable() 33 | 34 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 35 | AndroidSupportInjection.inject(this) 36 | super.onViewCreated(view, savedInstanceState) 37 | 38 | setupUi() 39 | bindViewModel() 40 | } 41 | 42 | override fun onDestroy() { 43 | disposables.clear() 44 | super.onDestroy() 45 | } 46 | 47 | private fun setupUi() { 48 | adapter = ArrayObjectAdapter(ListRowPresenter()) 49 | onItemViewClickedListener = OnItemViewClickedListener { _, item, _, _ -> 50 | if (item is Stream) { 51 | activity?.let { 52 | StreamingPlayerActivity.start(it, item) 53 | } 54 | } 55 | } 56 | } 57 | 58 | private fun bindViewModel() { 59 | disposables.add(viewModel.roomsForConference 60 | .subscribeBy( 61 | onNext = { render(it) }, 62 | onError = { it.printStackTrace() } 63 | ) 64 | ) 65 | } 66 | 67 | private fun render(rooms: List) { 68 | mainFragmentAdapter.fragmentHost.notifyDataReady(mainFragmentAdapter) 69 | (adapter as ArrayObjectAdapter) += rooms.map { createEventRow(it) } 70 | } 71 | 72 | private fun createEventRow(room: Room): Row { 73 | val adapter = ArrayObjectAdapter(StreamCardPresenter(room.thumb)) 74 | adapter += room.streams 75 | 76 | val headerItem = HeaderItem(room.display) 77 | return ListRow(headerItem, adapter) 78 | } 79 | 80 | companion object { 81 | fun create(streamId: String): LiveStreamingFragment = LiveStreamingFragment().also { fragment -> 82 | fragment.arguments = bundleOf(STREAM_ID to streamId) 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/main/streaming/LiveStreamingViewModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.main.streaming 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import de.stefanmedack.ccctv.repository.StreamingRepository 5 | import info.metadude.java.library.brockman.models.Room 6 | import io.reactivex.Flowable 7 | import javax.inject.Inject 8 | 9 | class LiveStreamingViewModel @Inject constructor( 10 | private val streamingRepository: StreamingRepository 11 | ) : ViewModel() { 12 | 13 | private lateinit var conferenceName: String 14 | 15 | fun init(streamName: String) { 16 | this.conferenceName = streamName 17 | } 18 | 19 | val roomsForConference: Flowable> 20 | get() = Flowable.just(extractRooms()) 21 | 22 | private fun extractRooms(): List = streamingRepository.cachedStreams 23 | .find { it.conference == conferenceName } 24 | ?.groups 25 | ?.flatMap { group -> 26 | group.rooms.map { room -> 27 | if (group.group.isNotEmpty()) { 28 | Room( 29 | room.display + " [${group.group}]", 30 | room.link, 31 | room.scheduleName, 32 | room.slug, 33 | room.streams, 34 | room.thumb) 35 | } else { 36 | room 37 | } 38 | } 39 | } ?: listOf() 40 | 41 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/search/SearchActivity.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.search 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import de.stefanmedack.ccctv.R 6 | import de.stefanmedack.ccctv.ui.base.BaseInjectableActivity 7 | import de.stefanmedack.ccctv.util.replaceFragmentInTransaction 8 | 9 | class SearchActivity : BaseInjectableActivity() { 10 | 11 | private val SEARCH_TAG = "SEARCH_TAG" 12 | 13 | var fragment: SearchFragment? = null 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | setContentView(R.layout.fragment_activity) 18 | 19 | fragment = supportFragmentManager.findFragmentByTag(SEARCH_TAG) as? SearchFragment ?: SearchFragment() 20 | fragment?.let { frag -> 21 | replaceFragmentInTransaction(frag, R.id.fragment, SEARCH_TAG) 22 | } 23 | } 24 | 25 | override fun onSearchRequested(): Boolean { 26 | if (fragment?.hasResults() == true) { 27 | startActivity(Intent(this, SearchActivity::class.java)) 28 | } else { 29 | fragment?.startRecognition() 30 | } 31 | return true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/search/SearchFragment.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.search 2 | 3 | import android.Manifest 4 | import android.app.Activity.RESULT_OK 5 | import android.arch.lifecycle.ViewModelProvider 6 | import android.arch.lifecycle.ViewModelProviders 7 | import android.content.ActivityNotFoundException 8 | import android.content.Intent 9 | import android.os.Bundle 10 | import android.support.v17.leanback.R.id.lb_search_bar 11 | import android.support.v17.leanback.app.SearchSupportFragment 12 | import android.support.v17.leanback.widget.* 13 | import android.view.View 14 | import com.jakewharton.rxbinding2.support.v17.leanback.widget.searchQueryChanges 15 | import dagger.android.support.AndroidSupportInjection 16 | import de.stefanmedack.ccctv.R 17 | import de.stefanmedack.ccctv.persistence.entities.Event 18 | import de.stefanmedack.ccctv.ui.cards.EventCardPresenter 19 | import de.stefanmedack.ccctv.ui.detail.DetailActivity 20 | import de.stefanmedack.ccctv.ui.search.uiModels.SearchResultUiModel 21 | import de.stefanmedack.ccctv.util.hasPermission 22 | import de.stefanmedack.ccctv.util.plusAssign 23 | import io.reactivex.disposables.CompositeDisposable 24 | import io.reactivex.rxkotlin.subscribeBy 25 | import timber.log.Timber 26 | import javax.inject.Inject 27 | 28 | class SearchFragment : SearchSupportFragment() { 29 | 30 | private val REQUEST_SPEECH = 0x00000010 31 | 32 | @Inject 33 | lateinit var viewModelFactory: ViewModelProvider.Factory 34 | 35 | private val viewModel: SearchViewModel by lazy { 36 | ViewModelProviders.of(this, viewModelFactory).get(SearchViewModel::class.java) 37 | } 38 | 39 | private val disposables = CompositeDisposable() 40 | 41 | private val rowsAdapter: ArrayObjectAdapter = ArrayObjectAdapter(ListRowPresenter()) 42 | 43 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 44 | AndroidSupportInjection.inject(this) 45 | super.onViewCreated(view, savedInstanceState) 46 | 47 | setupUi() 48 | bindViewModel(view) 49 | } 50 | 51 | override fun onDestroy() { 52 | disposables.clear() 53 | super.onDestroy() 54 | } 55 | 56 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 57 | if (requestCode == REQUEST_SPEECH && resultCode == RESULT_OK && data != null) { 58 | setSearchQuery(data, true) 59 | } 60 | } 61 | 62 | 63 | private fun setupUi() { 64 | setSearchResultProvider(object : SearchResultProvider { 65 | override fun getResultsAdapter(): ObjectAdapter? = rowsAdapter 66 | override fun onQueryTextChange(newQuery: String): Boolean = true 67 | override fun onQueryTextSubmit(query: String): Boolean = true 68 | }) 69 | 70 | setOnItemViewClickedListener { _, item, _, _ -> if (item is Event && activity != null) DetailActivity.start(activity!!, item) } 71 | 72 | // TODO solve deprecation 73 | if (activity?.hasPermission(Manifest.permission.RECORD_AUDIO) == false) { 74 | // SpeechRecognitionCallback is not required and if not provided recognition will be handled using internal speech recognizer, 75 | // in which case you must have RECORD_AUDIO permission 76 | setSpeechRecognitionCallback { 77 | try { 78 | if (activity != null) { 79 | startActivityForResult(recognizerIntent, REQUEST_SPEECH) 80 | } 81 | } catch (e: ActivityNotFoundException) { 82 | Timber.e(e, "Cannot find activity for speech recognizer") 83 | } 84 | 85 | } 86 | } 87 | } 88 | 89 | private fun bindViewModel(view: View?) { 90 | view?.findViewById(lb_search_bar)?.let { searchBar -> 91 | disposables.add(viewModel.bindSearch(searchBar.searchQueryChanges()) 92 | .subscribeBy( 93 | onNext = { render(it) }, 94 | // TODO proper error handling 95 | onError = { it.printStackTrace() } 96 | ) 97 | ) 98 | } 99 | } 100 | 101 | private fun render(result: SearchResultUiModel) { 102 | rowsAdapter.clear() 103 | if (result.showResults) 104 | rowsAdapter.add(ListRow( 105 | HeaderItem(0, getString(R.string.search_result_header, result.events.size.toString(), result.searchTerm)), 106 | ArrayObjectAdapter(EventCardPresenter()).also { it += result.events } 107 | )) 108 | } 109 | 110 | fun hasResults(): Boolean = rowsAdapter.size() > 0 111 | } 112 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/search/SearchModule.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.search 2 | 3 | import dagger.Module 4 | import dagger.android.ContributesAndroidInjector 5 | 6 | @Module 7 | abstract class SearchModule { 8 | 9 | @ContributesAndroidInjector 10 | abstract fun contributeSearchFragment(): SearchFragment 11 | 12 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/search/SearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.search 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import de.stefanmedack.ccctv.persistence.toEntity 5 | import de.stefanmedack.ccctv.ui.search.uiModels.SearchResultUiModel 6 | import de.stefanmedack.ccctv.util.EMPTY_STRING 7 | import de.stefanmedack.ccctv.util.applySchedulers 8 | import info.metadude.kotlin.library.c3media.RxC3MediaService 9 | import io.reactivex.Observable 10 | import java.util.concurrent.TimeUnit 11 | import javax.inject.Inject 12 | 13 | class SearchViewModel @Inject constructor( 14 | private val c3MediaService: RxC3MediaService 15 | ) : ViewModel() { 16 | 17 | fun bindSearch(searchQueryChanges: Observable): Observable = searchQueryChanges 18 | .debounce(333, TimeUnit.MILLISECONDS) 19 | .flatMap { 20 | if (it.length > 1) 21 | this.loadSearchResult(it) 22 | else 23 | Observable.just(SearchResultUiModel(showResults = false)) 24 | } 25 | 26 | private fun loadSearchResult(searchTerm: String): Observable? = c3MediaService 27 | .searchEvents(searchTerm) 28 | .applySchedulers() 29 | .map { SearchResultUiModel(searchTerm, it.events.mapNotNull { it.toEntity(EMPTY_STRING) }) } 30 | .toObservable() 31 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/search/uiModels/SearchResultUiModel.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.search.uiModels 2 | 3 | import de.stefanmedack.ccctv.persistence.entities.Event 4 | 5 | data class SearchResultUiModel( 6 | val searchTerm: String = "", 7 | val events: List = listOf(), 8 | val showResults: Boolean = true 9 | ) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/streaming/StreamingMediaPlayerGlue.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.streaming 2 | 3 | import android.app.Activity 4 | import android.support.v17.leanback.media.PlaybackTransportControlGlue 5 | import android.support.v17.leanback.media.PlayerAdapter 6 | 7 | 8 | // TODO this is a second VideoMediaPlayerGlue implementation, which is obsolete in case it gets merged with the original one 9 | /** 10 | * PlayerGlue for video playback 11 | * @param 12 | */ 13 | class StreamingMediaPlayerGlue(context: Activity, impl: T) : PlaybackTransportControlGlue(context, impl) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/streaming/StreamingPlayerActivity.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.streaming 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.support.v4.app.FragmentActivity 6 | import android.view.WindowManager 7 | import androidx.core.os.bundleOf 8 | import de.stefanmedack.ccctv.R 9 | import de.stefanmedack.ccctv.util.STREAM_URL 10 | import de.stefanmedack.ccctv.util.addFragmentInTransaction 11 | import info.metadude.java.library.brockman.models.Stream 12 | import info.metadude.java.library.brockman.models.Url.TYPE 13 | 14 | class StreamingPlayerActivity : FragmentActivity() { 15 | 16 | public override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | setContentView(R.layout.activity_video_example) 19 | 20 | // prevent stand-by while playing videos 21 | window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 22 | 23 | val fragment = StreamingPlayerFragment().apply { 24 | arguments = bundleOf(STREAM_URL to intent.getStringExtra(STREAM_URL)) 25 | } 26 | addFragmentInTransaction(fragment, R.id.videoFragment, StreamingPlayerFragment.TAG) 27 | } 28 | 29 | override fun onNewIntent(intent: Intent) { 30 | super.onNewIntent(intent) 31 | // This part is necessary to ensure that getIntent returns the latest intent when 32 | // VideoExampleActivity is started. By default, getIntent() returns the initial intent 33 | // that was set from another activity that started VideoExampleActivity. However, we need 34 | // to update this intent when for example, user clicks on another video when the currently 35 | // playing video is in PIP mode, and a new video needs to be started. 36 | setIntent(intent) 37 | } 38 | 39 | companion object { 40 | fun start(activity: FragmentActivity, item: Stream) { 41 | val intent = Intent(activity, StreamingPlayerActivity::class.java) 42 | intent.putExtra(STREAM_URL, item.urls.find { it.type == TYPE.WEBM }?.url ?: item.urls[0].url) 43 | activity.startActivity(intent) 44 | } 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/ui/streaming/StreamingPlayerFragment.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.streaming 2 | 3 | import android.content.Context 4 | import android.media.AudioManager 5 | import android.net.Uri 6 | import android.os.Build 7 | import android.os.Bundle 8 | import android.support.v17.leanback.app.PlaybackFragment 9 | import android.support.v17.leanback.app.VideoSupportFragment 10 | import android.support.v17.leanback.app.VideoSupportFragmentGlueHost 11 | import android.support.v17.leanback.media.PlaybackGlue 12 | import android.util.Log 13 | import android.view.View 14 | import de.stefanmedack.ccctv.util.STREAM_URL 15 | 16 | class StreamingPlayerFragment : VideoSupportFragment() { 17 | 18 | private lateinit var mediaPlayerGlue: StreamingMediaPlayerGlue 19 | 20 | private val glueHost = VideoSupportFragmentGlueHost(this) 21 | private val onAudioFocusChangeListener: AudioManager.OnAudioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { } 22 | 23 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 24 | super.onViewCreated(view, savedInstanceState) 25 | 26 | val playerAdapter = StreamingPlayerAdapter(view.context) 27 | playerAdapter.audioStreamType = AudioManager.USE_DEFAULT_STREAM_TYPE 28 | activity?.let { activityContext -> 29 | mediaPlayerGlue = StreamingMediaPlayerGlue(activityContext, playerAdapter) 30 | mediaPlayerGlue.host = glueHost 31 | val audioManager = activityContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager 32 | if (audioManager.requestAudioFocus(onAudioFocusChangeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) 33 | != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { 34 | Log.w(TAG, "video player cannot obtain audio focus!") 35 | } 36 | } 37 | 38 | val streamUrl = arguments?.getString(STREAM_URL) 39 | if (streamUrl != null) { 40 | playVideo(streamUrl) 41 | } else { 42 | activity?.finish() 43 | } 44 | } 45 | 46 | override fun onPause() { 47 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || activity?.isInPictureInPictureMode == false) { 48 | mediaPlayerGlue.pause() 49 | } 50 | super.onPause() 51 | } 52 | 53 | internal fun playWhenReady(glue: PlaybackGlue) { 54 | if (glue.isPrepared) { 55 | glue.play() 56 | } else { 57 | glue.addPlayerCallback(object : PlaybackGlue.PlayerCallback() { 58 | override fun onPreparedStateChanged(glue2: PlaybackGlue) { 59 | if (glue2.isPrepared) { 60 | glue2.removePlayerCallback(this) 61 | glue2.play() 62 | } 63 | } 64 | }) 65 | } 66 | } 67 | 68 | private fun playVideo(videoUrl: String) { 69 | // mediaPlayerGlue.title = event.title 70 | // mediaPlayerGlue.subtitle = event.subtitle 71 | 72 | mediaPlayerGlue.playerAdapter.setDataSource(Uri.parse(videoUrl)) 73 | mediaPlayerGlue.isSeekEnabled = false 74 | playWhenReady(mediaPlayerGlue) 75 | backgroundType = PlaybackFragment.BG_LIGHT 76 | } 77 | 78 | companion object { 79 | val TAG = "VideoConsumptionFrag" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/util/AndroidExtensions.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.util 2 | 3 | import android.app.Activity 4 | import android.content.pm.PackageManager.PERMISSION_GRANTED 5 | import android.support.v17.leanback.widget.ArrayObjectAdapter 6 | import android.support.v17.leanback.widget.Row 7 | import android.support.v4.app.Fragment 8 | import android.support.v4.app.FragmentActivity 9 | import android.support.v4.app.FragmentManager 10 | import android.support.v4.app.FragmentTransaction 11 | 12 | fun Activity.hasPermission(permission: String) : Boolean = 13 | PERMISSION_GRANTED == this.packageManager.checkPermission(permission, this.packageName) 14 | 15 | operator fun ArrayObjectAdapter.plusAssign(items: List<*>?) { 16 | items?.forEach { this.add(it) } 17 | } 18 | 19 | operator fun ArrayObjectAdapter.plusAssign(row: Row) = this.add(row) 20 | 21 | inline fun FragmentManager.inTransaction(func: FragmentTransaction.() -> FragmentTransaction) { 22 | beginTransaction().func().commit() 23 | } 24 | 25 | fun FragmentActivity.addFragmentInTransaction( 26 | fragment: Fragment, 27 | containerId: Int, 28 | tag: String? = null, 29 | addToBackStack: Boolean = false 30 | ) { 31 | supportFragmentManager.inTransaction { 32 | add(containerId, fragment, tag).also { 33 | if (addToBackStack) addToBackStack(tag) 34 | } 35 | } 36 | } 37 | 38 | fun FragmentActivity.replaceFragmentInTransaction( 39 | fragment: Fragment, 40 | containerId: Int, 41 | tag: String? = null, 42 | addToBackStack: Boolean = false 43 | ) { 44 | supportFragmentManager.inTransaction { 45 | replace(containerId, fragment, tag).also { 46 | if (addToBackStack) addToBackStack(tag) 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/util/Constants.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.util 2 | 3 | import de.stefanmedack.ccctv.R 4 | import de.stefanmedack.ccctv.model.ConferenceGroup 5 | import info.metadude.kotlin.library.c3media.models.MimeType 6 | 7 | const val CACHE_MAX_SIZE_HTTP = (20 * 1024 * 1024).toLong() 8 | const val EMPTY_STRING = "" 9 | 10 | val SUPPORTED_VIDEO_MIME_TYPE_SORTING = listOf( 11 | MimeType.MP4, 12 | MimeType.WEBM) 13 | 14 | const val EVENT_ID = "EventId" 15 | const val EVENT_PICTURE = "EventPicture" 16 | const val CONFERENCE_GROUP = "CGroup" 17 | const val CONFERENCE_ACRONYM = "ConferenceAcronym" 18 | const val EVENTS_VIEW_TITLE = "EventsViewTitle" 19 | const val CONFERENCE_LOGO_URL = "ConferenceLogo" 20 | const val STREAM_ID = "StreamId" 21 | const val STREAM_URL = "StreamUrl" 22 | const val SEARCH_QUERY = "SearchQuery" 23 | const val FRAGMENT_ARGUMENTS = "FragmentArguments" 24 | 25 | const val DETAIL_ACTION_PLAY: Long = 1 26 | const val DETAIL_ACTION_RESTART: Long = 2 27 | const val DETAIL_ACTION_BOOKMARK: Long = 3 28 | const val DETAIL_ACTION_SPEAKER: Long = 4 29 | const val DETAIL_ACTION_RELATED: Long = 5 30 | 31 | const val SHARED_DETAIL_TRANSITION = "SHARED_DETAIL_TRANSITION" 32 | 33 | val CONFERENCE_GROUP_TRANSLATIONS: Map = mapOf( 34 | ConferenceGroup.CONGRESS to R.string.cg_congress, 35 | ConferenceGroup.CAMP to R.string.cg_camp, 36 | ConferenceGroup.CRYPTOCON to R.string.cg_cryptocon, 37 | ConferenceGroup.DATENSPUREN to R.string.cg_datenspuren, 38 | ConferenceGroup.DENOG to R.string.cg_denog, 39 | ConferenceGroup.EH to R.string.cg_eh, 40 | ConferenceGroup.FIFFKON to R.string.cg_fiffkon, 41 | ConferenceGroup.FROSCON to R.string.cg_froscon, 42 | ConferenceGroup.GPN to R.string.cg_gpn, 43 | ConferenceGroup.HACKOVER to R.string.cg_hackover, 44 | ConferenceGroup.JUGENDHACKT to R.string.cg_jugendhackt, 45 | ConferenceGroup.MRMCD to R.string.cg_mrmcd, 46 | ConferenceGroup.NETZPOLITIK to R.string.cg_netzpolitik, 47 | ConferenceGroup.OSC to R.string.cg_osc, 48 | ConferenceGroup.SIGINT to R.string.cg_sigint, 49 | ConferenceGroup.VCFB to R.string.cg_vcfb, 50 | ConferenceGroup.OTHER_CONFERENCES to R.string.cg_other_conferences, 51 | ConferenceGroup.OTHER to R.string.cg_other 52 | ) 53 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/util/LeanbackExtensions.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.util 2 | 3 | import android.support.v17.leanback.widget.ObjectAdapter 4 | 5 | fun ObjectAdapter.indexOf(item: Any): Int? { 6 | for (index in 0 until size()) { 7 | if (get(index) == item) { 8 | return index 9 | } 10 | } 11 | return null 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/util/ModelExtensions.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.util 2 | 3 | import android.os.Build 4 | import android.text.Html 5 | import de.stefanmedack.ccctv.BuildConfig.DEBUG 6 | import de.stefanmedack.ccctv.model.ConferenceGroup 7 | import de.stefanmedack.ccctv.persistence.entities.Conference 8 | import de.stefanmedack.ccctv.persistence.entities.Event 9 | import info.metadude.kotlin.library.c3media.models.Language 10 | import info.metadude.kotlin.library.c3media.models.Recording 11 | import timber.log.Timber 12 | import java.util.* 13 | import de.stefanmedack.ccctv.persistence.entities.Conference as ConferenceEntity 14 | import info.metadude.kotlin.library.c3media.models.Conference as ConferenceRemote 15 | import info.metadude.kotlin.library.c3media.models.Event as EventRemote 16 | 17 | fun List.groupConferences(): Map> = groupBy { it.group } 18 | .toSortedMap() 19 | 20 | fun EventRemote.bestRecording(favoriteLanguage: Language, isFavoriteQualityHigh: Boolean = true): Recording? { 21 | val sortedRecordings = this.recordings 22 | ?.filter { it.mimeType in SUPPORTED_VIDEO_MIME_TYPE_SORTING } 23 | ?.sortedWith(Comparator { lhs, rhs -> 24 | when { 25 | lhs.highQuality != rhs.highQuality -> when (isFavoriteQualityHigh) { 26 | true -> if (lhs.highQuality) -1 else 1 27 | false -> if (lhs.highQuality) 1 else -1 28 | } 29 | lhs.language != rhs.language -> lhs.languageSortingIndex(favoriteLanguage) - rhs.languageSortingIndex(favoriteLanguage) 30 | lhs.videoSortingIndex() != rhs.videoSortingIndex() -> lhs.videoSortingIndex() - rhs.videoSortingIndex() 31 | else -> 0 32 | } 33 | }) 34 | if (DEBUG) { 35 | sortedRecordings?.forEach { 36 | Timber.d("EventId:${this.guid}:mime=${it.mimeType}; hq=${it.highQuality}; res=${it.height}/${it.width};lang=${it.language}") 37 | } 38 | } 39 | 40 | return sortedRecordings?.firstOrNull() 41 | } 42 | 43 | fun Event.getRelatedEventGuidsWeighted(): List = related 44 | .asSequence() 45 | .sortedByDescending { it.weight } 46 | .mapNotNull { it.eventGuid } 47 | .toList() 48 | 49 | fun Recording.videoSortingIndex(): Int = 50 | if (SUPPORTED_VIDEO_MIME_TYPE_SORTING.contains(this.mimeType)) 51 | SUPPORTED_VIDEO_MIME_TYPE_SORTING.indexOf(this.mimeType) 52 | else 53 | SUPPORTED_VIDEO_MIME_TYPE_SORTING.size 54 | 55 | fun Recording.languageSortingIndex(favoriteLanguage: Language): Int { 56 | if (this.language?.contains(favoriteLanguage) == true) return this.language?.size ?: 1 57 | return 42 58 | } 59 | 60 | fun String.stripHtml(): String = 61 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 62 | Html.fromHtml(this, Html.FROM_HTML_MODE_COMPACT).toString() 63 | } else { 64 | @Suppress("DEPRECATION") 65 | Html.fromHtml(this).toString() 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/util/RxExtensions.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.util 2 | 3 | import io.reactivex.* 4 | import io.reactivex.android.schedulers.AndroidSchedulers 5 | import io.reactivex.schedulers.Schedulers 6 | 7 | fun Single.applySchedulers(): Single = subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) 8 | 9 | fun Flowable.applySchedulers(): Flowable = subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) 10 | 11 | fun Observable.applySchedulers(): Observable = subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) 12 | 13 | fun Completable.applySchedulers(): Completable = subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) 14 | 15 | fun createFlowable(strategy: BackpressureStrategy, onSubscribe: (Emitter) -> Unit): Flowable 16 | = Flowable.create(onSubscribe, strategy) -------------------------------------------------------------------------------- /app/src/main/java/de/stefanmedack/ccctv/util/VideoHelper.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.util 2 | 3 | val SIXTEEN_BY_NINE_RATIO = 16 / 9F 4 | val FOUR_BY_THREE_RATIO = 4 / 3F 5 | 6 | fun switchAspectRatio(width: Int, height: Int) = (height * 7 | if (Math.abs(width / height.toFloat() - SIXTEEN_BY_NINE_RATIO) > 0.01) 8 | SIXTEEN_BY_NINE_RATIO 9 | else 10 | FOUR_BY_THREE_RATIO 11 | ).toInt() -------------------------------------------------------------------------------- /app/src/main/res/drawable/about_cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/drawable/about_cover.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_aspect_ratio.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_bookmark_check.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_bookmark_plus.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_related.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_restart.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_speaker.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_watch.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/voctocat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/drawable/voctocat.png -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_video_example.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_activity.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/layout/grid_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 14 | 15 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/layout/speaker_card.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 20 | 21 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/app_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-hdpi/app_banner.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/app_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-mdpi/app_banner.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/app_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-xhdpi/app_banner.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/app_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-xxhdpi/app_banner.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/app_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-xxxhdpi/app_banner.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/values-de/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | cccTV 4 | 5 | Home 6 | Gerade Live 7 | Mediathek 8 | Weiteres 9 | Über cccTV 10 | 11 | Auf Deiner Watchlist 12 | Weiter schauen 13 | Ausgewählte Videos 14 | Trending Videos 15 | Neueste Videos 16 | Populärste Videos 17 | 18 | Ansehen 19 | Von Anfang abspielen 20 | Hinzufügen zur 21 | Entfernen von der 22 | Watchlist 23 | Vortragende 24 | Ähnliches 25 | Vortragende 26 | Ähnliche Videos 27 | Video wird vom letzten Mal fortgesetzt 28 | 29 | \ncccTV ist ein Open Source Projekt entwickelt von Stefan Medack. 30 | Sämtlicher Source Code ist unter der Apache License 2.0 auf github.com/stefanmedack/cccTV zu finden. 31 | \n\nDas Video Material wird freundlicherweise von media.ccc.de bereitgestellt. 32 | \n\nEin Großes Dankeschön an den Chaos Computer Club e.V. (CCC) für das Hosten dieser Videos, 33 | an alle Personen die an der Produktion beteiligt waren, 34 | so wie an alle Personen in den Aufzeichnungen, die ihr Wissen mit uns teilen. 35 | \n\nSollte Dir diese App gefallen, dann bewerte sie bitte. 36 | Solltest Du noch gute Ideen für Features haben oder Bugs finden, lass es mich wissen! 37 | Für Fragen oder Verbesserungsvorschläge an den Videos selbst, kontaktiere den Chaos Computer Club e.V. 38 | \n\nDie Open Source Bibliotheken und Lizenzen für diese App findest Du weiter unten. 39 | 40 | Bibliotheken 41 | Voctocat 42 | Dieses kleine Kerlchen ist von Sebastian Morr entworfen worden 43 | und verfügbar unter der CC BY-NC-SA 4.0 Lizenz (https://morr.cc/voctocat/) 44 | 45 | %1$s Videos gefunden für \"%2$s\" 46 | Suchergebnisse für \"%1$s\" 47 | 48 | 49 | Congress 50 | Camp 51 | CryptoCon 52 | Datenspuren 53 | DENOG 54 | Easterhegg 55 | FIfFKon 56 | FrOSCon 57 | GPN 58 | Hackover 59 | Jugend Hackt 60 | MRMCD 61 | Netzpolitik 62 | openSUSE 63 | sigint 64 | Vintage Computing Festival Berlin 65 | Weitere Konferenzen 66 | Noch mehr… 67 | 68 | Uups, under construction. Beim nächsten Mal ist hier aber bestimmt etwas. 69 | 70 | 71 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #00897B 5 | #004D40 6 | #1DE9B6 7 | 8 | 9 | #FF8F00 10 | 11 | 12 | #CCCCCC 13 | #FFFFFF 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 200dp 5 | 150dp 6 | 7 | 200dp 8 | 112dp 9 | 10 | 120dp 11 | 120dp 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #004D40 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | cccTV 4 | 5 | Home 6 | Live Stream 7 | Library 8 | More 9 | About cccTV 10 | 11 | On Your Watchlist 12 | Continue Watching 13 | Promoted Videos 14 | Trending Videos 15 | Most Recent Videos 16 | Most Popular Videos 17 | 18 | Watch 19 | Play from beginning 20 | Add to 21 | Remove from 22 | Watchlist 23 | Speaker 24 | Related 25 | Speaker 26 | Related Videos 27 | Picked up playback from last time 28 | 29 | \ncccTV is an Open Source project developed by Stefan Medack. 30 | All sources are available under the Apache License 2.0 on github.com/stefanmedack/cccTV. 31 | \n\nThe video material is kindly provided by media.ccc.de 32 | \n\nSpecial thanks to Chaos Computer Club e.V. (CCC) for hosting the content and 33 | everybody involved in its production, 34 | as well as to everybody participating in the videos and sharing their knowledge. 35 | \n\nIf you like this App, please give it a rating. 36 | In case you are missing some features or found a Bug, let me know. 37 | To improve the content of the Videos, contact or join the Chaos Computer Club e.V. 38 | \n\nFor the list of Open Source Libraries and Licenses, please have a look below. 39 | 40 | Libraries 41 | Voctocat 42 | This little fella is designed by Sebastian Morr and distributed under CC BY-NC-SA 4.0 License 43 | (https://morr.cc/voctocat/) 44 | 45 | %1$s videos found for \"%2$s\" 46 | Search results for \"%1$s\" 47 | 48 | 49 | Congress 50 | Camp 51 | CryptoCon 52 | Datenspuren 53 | DENOG 54 | Easterhegg 55 | FIfFKon 56 | FrOSCon 57 | GPN 58 | Hackover 59 | Jugend Hackt 60 | MRMCD 61 | Netzpolitik 62 | openSUSE 63 | sigint 64 | Vintage Computing Festival Berlin 65 | More Conferences 66 | Even More… 67 | 68 | Uups, this feature is still missing - check back later 69 | 70 | 71 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 14 | 15 | 18 | 19 | 22 | 23 | 27 | 28 | 32 | 33 | 37 | 38 | 41 | 42 | 47 | 48 | 53 | 54 | 60 | 61 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /app/src/test-common/java/de/stefanmedack/ccctv/ModelTestUtil.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv 2 | 3 | import de.stefanmedack.ccctv.model.ConferenceGroup 4 | import de.stefanmedack.ccctv.persistence.entities.Conference 5 | import de.stefanmedack.ccctv.persistence.entities.Event 6 | import de.stefanmedack.ccctv.persistence.entities.LanguageList 7 | import info.metadude.kotlin.library.c3media.models.AspectRatio 8 | import info.metadude.kotlin.library.c3media.models.Language 9 | import info.metadude.kotlin.library.c3media.models.MimeType 10 | import info.metadude.kotlin.library.c3media.models.Recording 11 | import info.metadude.kotlin.library.c3media.models.RelatedEvent 12 | import org.threeten.bp.LocalDate 13 | import org.threeten.bp.OffsetDateTime 14 | import info.metadude.kotlin.library.c3media.models.Conference as ConferenceRemote 15 | import info.metadude.kotlin.library.c3media.models.Event as EventRemote 16 | 17 | val minimalConferenceEntity = Conference( 18 | acronym = "34c3", 19 | url = "url", 20 | slug = "slug", 21 | group = ConferenceGroup.OTHER, 22 | title = "title" 23 | ) 24 | 25 | val fullConferenceEntity = Conference( 26 | acronym = "34c3", 27 | url = "url", 28 | slug = "slug", 29 | group = ConferenceGroup.OTHER, 30 | title = "title", 31 | aspectRatio = AspectRatio._16_X_9, 32 | logoUrl = "logoUrl", 33 | updatedAt = OffsetDateTime.now() 34 | ) 35 | 36 | val minimalEventEntity = Event( 37 | id = "43", 38 | conferenceAcronym = "34c3", 39 | url = "url", 40 | slug = "slug", 41 | title = "title" 42 | ) 43 | 44 | val fullEventEntity = Event( 45 | id = "43", 46 | conferenceAcronym = "34c3", 47 | url = "url", 48 | slug = "slug", 49 | title = "title", 50 | subtitle = "subtitle", 51 | description = "description", 52 | persons = listOf("Frank", "Fefe"), 53 | thumbUrl = "thumbUrl", 54 | posterUrl = "posterUrl", 55 | originalLanguage = LanguageList(listOf(Language.EN, Language.DE)), 56 | duration = 3, 57 | viewCount = 8, 58 | promoted = true, 59 | tags = listOf("33c3", "fnord"), 60 | related = listOf( 61 | RelatedEvent(0, "8", 15), 62 | RelatedEvent(23, "42", 3) 63 | ), 64 | releaseDate = LocalDate.now(), 65 | date = OffsetDateTime.now(), 66 | updatedAt = OffsetDateTime.now() 67 | ) 68 | 69 | val minimalConference = ConferenceRemote( 70 | url = "url/42", 71 | slug = "slug", 72 | title = "title", 73 | acronym = "acronym" 74 | ) 75 | 76 | val minimalEvent = EventRemote( 77 | conferenceId = 42, 78 | url = "url/43", 79 | slug = "slug", 80 | guid = "guid", 81 | title = "title", 82 | subtitle = "subtitle", 83 | description = "desc", 84 | thumbUrl = "thumbUrl", 85 | posterUrl = "poserUrl", 86 | releaseDate = LocalDate.now(), 87 | originalLanguage = listOf() 88 | ) 89 | 90 | val minimalRecording = Recording( 91 | id = 44, 92 | url = "url", 93 | conferenceUrl = "conferenceUrl", 94 | eventId = 43, 95 | eventUrl = "eventUrl", 96 | filename = "filename", 97 | folder = "folder", 98 | createdAt = "createdAt", 99 | highQuality = true, 100 | html5 = true, 101 | mimeType = MimeType.MP4 102 | ) 103 | -------------------------------------------------------------------------------- /app/src/test-common/java/de/stefanmedack/ccctv/RxTestUtils.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv 2 | 3 | import io.reactivex.Flowable 4 | import io.reactivex.Observable 5 | import io.reactivex.Single 6 | 7 | fun Observable.getSingleTestResult(waitUntilCompletion: Boolean = false): T = 8 | this.test().apply { if (waitUntilCompletion) await() }.values()[0] 9 | 10 | fun Flowable.getSingleTestResult(waitUntilCompletion: Boolean = false): T = 11 | this.test().apply { if (waitUntilCompletion) await() }.values()[0] 12 | 13 | fun Single.getSingleTestResult(): T = 14 | this.test().values()[0] 15 | 16 | -------------------------------------------------------------------------------- /app/src/test/java/de/stefanmedack/ccctv/repository/ConferenceRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.repository 2 | 3 | import com.nhaarman.mockito_kotlin.verify 4 | import de.stefanmedack.ccctv.minimalConference 5 | import de.stefanmedack.ccctv.minimalConferenceEntity 6 | import de.stefanmedack.ccctv.minimalEvent 7 | import de.stefanmedack.ccctv.model.Resource 8 | import de.stefanmedack.ccctv.persistence.daos.ConferenceDao 9 | import de.stefanmedack.ccctv.persistence.daos.EventDao 10 | import de.stefanmedack.ccctv.persistence.toEntity 11 | import info.metadude.kotlin.library.c3media.RxC3MediaService 12 | import info.metadude.kotlin.library.c3media.models.ConferencesResponse 13 | import io.reactivex.Flowable 14 | import io.reactivex.Single 15 | import io.reactivex.android.plugins.RxAndroidPlugins 16 | import io.reactivex.schedulers.Schedulers 17 | import org.amshove.kluent.When 18 | import org.amshove.kluent.any 19 | import org.amshove.kluent.calling 20 | import org.amshove.kluent.itReturns 21 | import org.amshove.kluent.shouldEqual 22 | import org.junit.Before 23 | import org.junit.Test 24 | import org.mockito.InjectMocks 25 | import org.mockito.Mock 26 | import org.mockito.MockitoAnnotations 27 | 28 | @Suppress("IllegalIdentifier") 29 | class ConferenceRepositoryTest { 30 | 31 | @Mock 32 | private lateinit var mediaService: RxC3MediaService 33 | 34 | @Mock 35 | private lateinit var conferenceDao: ConferenceDao 36 | 37 | @Mock 38 | private lateinit var eventDao: EventDao 39 | 40 | @InjectMocks 41 | private lateinit var repositoy: ConferenceRepository 42 | 43 | @Before 44 | fun setup() { 45 | MockitoAnnotations.initMocks(this) 46 | RxAndroidPlugins.setInitMainThreadSchedulerHandler { _ -> Schedulers.trampoline() } 47 | 48 | When calling conferenceDao.getConferences() itReturns Flowable.empty() 49 | When calling conferenceDao.getConferenceWithEventsByAcronym(any()) itReturns Flowable.empty() 50 | } 51 | 52 | @Test 53 | fun `fetch conferences from local`() { 54 | val confList = listOf(minimalConferenceEntity) 55 | When calling conferenceDao.getConferences() itReturns Flowable.just(confList) 56 | When calling mediaService.getConferences() itReturns Single.never() 57 | 58 | val result = repositoy.conferences.test().await().values() 59 | 60 | result[0] shouldEqual Resource.Loading() 61 | result[1] shouldEqual Resource.Success(confList) 62 | verify(conferenceDao).getConferences() 63 | } 64 | 65 | @Test 66 | fun `fetch conferences from network`() { 67 | val conferenceRemoteList = listOf(minimalConference) 68 | val conferenceEntityList = conferenceRemoteList.map { it.toEntity()!! } 69 | When calling mediaService.getConferences() itReturns Single.just(ConferencesResponse(conferenceRemoteList)) 70 | 71 | val result = repositoy.conferences.test().await().values() 72 | 73 | result[0] shouldEqual Resource.Loading() 74 | result[1] shouldEqual Resource.Success(conferenceEntityList) 75 | verify(mediaService).getConferences() 76 | verify(conferenceDao).insertAll(conferenceEntityList) 77 | } 78 | 79 | // TODO implement conference with events tests 80 | 81 | @Test 82 | fun `update content should fetch remote data and save it locally`() { 83 | val conferenceRemote = minimalConference.copy(events = listOf(minimalEvent)) 84 | val conferenceEntityList = listOf(conferenceRemote.toEntity()!!) 85 | val conferenceId = conferenceEntityList.first().acronym 86 | val eventEntityList = listOf(minimalEvent.toEntity(conferenceId)!!) 87 | When calling mediaService.getConferences() itReturns Single.just(ConferencesResponse(listOf(conferenceRemote))) 88 | When calling mediaService.getConference(conferenceId) itReturns Single.just(conferenceRemote) 89 | 90 | repositoy.updateContent().test().await() 91 | 92 | verify(mediaService).getConferences() 93 | verify(conferenceDao).insertAll(conferenceEntityList) 94 | verify(mediaService).getConference(conferenceId) 95 | verify(eventDao).insertAll(eventEntityList) 96 | } 97 | 98 | @Test 99 | fun `update content should iterate multiple conferences and their events`() { 100 | val remoteEvent1 = minimalEvent.copy(conferenceUrl = "url/33c3") 101 | val remoteEvent2 = minimalEvent.copy(conferenceUrl = "url/34c3") 102 | val remoteConf1 = minimalConference.copy(acronym = "33c3", events = listOf(remoteEvent1)) 103 | val remoteConf2 = minimalConference.copy(acronym = "34c3", events = listOf(remoteEvent2)) 104 | val eventEntityList1 = listOf(remoteEvent1.toEntity("33c3")!!) 105 | val eventEntityList2 = listOf(remoteEvent2.toEntity("34c3")!!) 106 | When calling mediaService.getConferences() itReturns Single.just(ConferencesResponse(listOf(remoteConf1, remoteConf2))) 107 | When calling mediaService.getConference("33c3") itReturns Single.just(remoteConf1) 108 | When calling mediaService.getConference("34c3") itReturns Single.just(remoteConf2) 109 | 110 | repositoy.updateContent().test().await() 111 | 112 | verify(mediaService).getConferences() 113 | verify(conferenceDao).insertAll(listOf(remoteConf1.toEntity()!!, remoteConf2.toEntity()!!)) 114 | verify(mediaService).getConference("33c3") 115 | verify(mediaService).getConference("34c3") 116 | 117 | verify(eventDao).insertAll(eventEntityList1) 118 | verify(eventDao).insertAll(eventEntityList2) 119 | } 120 | 121 | } -------------------------------------------------------------------------------- /app/src/test/java/de/stefanmedack/ccctv/repository/NetworkBoundResourceTest.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.repository 2 | 3 | import de.stefanmedack.ccctv.model.Resource 4 | import io.reactivex.Flowable 5 | import io.reactivex.Single 6 | import io.reactivex.android.plugins.RxAndroidPlugins 7 | import io.reactivex.schedulers.Schedulers 8 | import org.amshove.kluent.shouldBeNull 9 | import org.amshove.kluent.shouldEqual 10 | import org.junit.Before 11 | import org.junit.Test 12 | import java.util.concurrent.TimeUnit 13 | 14 | 15 | @Suppress("IllegalIdentifier") 16 | class NetworkBoundResourceTest { 17 | 18 | @Before 19 | fun setup() { 20 | RxAndroidPlugins.setInitMainThreadSchedulerHandler { _ -> Schedulers.trampoline() } 21 | } 22 | 23 | @Test 24 | fun `fetch from local should not fetch from network if data is fresh`() { 25 | val exampleTestData = TestData("local") 26 | val unchanged = TestData("should be unchanged") 27 | val toTest = TestableNetworkBoundResource( 28 | localFetcher = Flowable.just(exampleTestData), 29 | remoteFetcher = Single.just(TestData("network")).slowDown(), 30 | localData = unchanged 31 | ) 32 | 33 | val result = toTest.resource.test().await() 34 | 35 | result.assertValueAt(0, Resource.Loading()) 36 | result.assertValueAt(1, Resource.Success(exampleTestData)) 37 | toTest.localData shouldEqual unchanged 38 | } 39 | 40 | @Test 41 | fun `fetch from local should not fetch from network when data is fresh and network would have been faster`() { 42 | val exampleTestData = TestData("local") 43 | val unchanged = TestData("should be unchanged") 44 | val toTest = TestableNetworkBoundResource( 45 | localFetcher = Flowable.just(exampleTestData).slowDown(), 46 | remoteFetcher = Single.just(TestData("network")), 47 | localData = unchanged 48 | ) 49 | 50 | val result = toTest.resource.test().await() 51 | 52 | result.assertValueAt(0, Resource.Loading()) 53 | result.assertValueAt(1, Resource.Success(exampleTestData)) 54 | toTest.localData shouldEqual unchanged 55 | } 56 | 57 | @Test 58 | fun `fetch from network when local is stale overwrites local`() { 59 | val exampleTestData = TestData("network") 60 | val toTest = TestableNetworkBoundResource( 61 | localFetcher = Flowable.empty(), 62 | remoteFetcher = Single.just(exampleTestData).slowDown(), 63 | localData = null 64 | ) 65 | 66 | val result = toTest.resource.test().await() 67 | 68 | result.assertValueAt(0, Resource.Loading()) 69 | result.assertValueAt(1, Resource.Success(exampleTestData)) 70 | toTest.localData shouldEqual exampleTestData 71 | } 72 | 73 | @Test 74 | fun `network fetch failure`() { 75 | val toTest = TestableNetworkBoundResource( 76 | localFetcher = Flowable.empty(), 77 | remoteFetcher = Single.error(Exception()), 78 | localData = null 79 | ) 80 | 81 | val result = toTest.resource.test().await() 82 | 83 | result.assertValueAt(0, Resource.Loading()) 84 | // result.assertValueAt(1, Resource.Error("Error")) // TODO 85 | toTest.localData.shouldBeNull() 86 | } 87 | 88 | private fun Flowable.slowDown() = 89 | Flowable.timer(1, TimeUnit.SECONDS).flatMap { this } 90 | 91 | private fun Single.slowDown() = 92 | Single.timer(1, TimeUnit.SECONDS).flatMap { this } 93 | 94 | data class TestData(val name: String?) 95 | 96 | class TestableNetworkBoundResource( 97 | val localFetcher: Flowable = Flowable.empty(), 98 | val remoteFetcher: Single = Single.just(TestData(null)), 99 | var localData: TestData? = null 100 | ) : NetworkBoundResource() { 101 | 102 | override fun fetchLocal(): Flowable = localFetcher 103 | 104 | override fun saveLocal(data: TestData) { 105 | localData = data 106 | } 107 | 108 | override fun isStale(localResource: Resource): Boolean = when (localResource) { 109 | is Resource.Success -> localResource.data.name == null 110 | is Resource.Loading -> false 111 | is Resource.Error -> true 112 | } 113 | 114 | override fun fetchNetwork(): Single = remoteFetcher 115 | 116 | override fun mapNetworkToLocal(data: TestData): TestData = data 117 | } 118 | } -------------------------------------------------------------------------------- /app/src/test/java/de/stefanmedack/ccctv/ui/detail/DetailViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.ui.detail 2 | 3 | import com.nhaarman.mockito_kotlin.reset 4 | import com.nhaarman.mockito_kotlin.verify 5 | import com.nhaarman.mockito_kotlin.verifyNoMoreInteractions 6 | import de.stefanmedack.ccctv.repository.EventRepository 7 | import io.reactivex.Completable 8 | import io.reactivex.Flowable 9 | import io.reactivex.android.plugins.RxAndroidPlugins 10 | import io.reactivex.schedulers.Schedulers 11 | import org.amshove.kluent.When 12 | import org.amshove.kluent.any 13 | import org.amshove.kluent.calling 14 | import org.amshove.kluent.itReturns 15 | import org.junit.Before 16 | import org.junit.Test 17 | import org.mockito.InjectMocks 18 | import org.mockito.Mock 19 | import org.mockito.MockitoAnnotations 20 | 21 | class DetailViewModelTest { 22 | 23 | @Mock 24 | private lateinit var repository: EventRepository 25 | 26 | @InjectMocks 27 | private lateinit var detailViewModel: DetailViewModel 28 | 29 | @Before 30 | fun setUp() { 31 | MockitoAnnotations.initMocks(this) 32 | RxAndroidPlugins.setInitMainThreadSchedulerHandler { _ -> Schedulers.trampoline() } 33 | 34 | When calling repository.isBookmarked(any()) itReturns Flowable.just(true) 35 | } 36 | 37 | @Test 38 | fun `bookmarking a not-bookmarked event should change bookmark state to true`() { 39 | val testEventId = "3" 40 | val isBookmarked = false 41 | When calling repository.isBookmarked(testEventId) itReturns Flowable.just(isBookmarked) 42 | When calling repository.changeBookmarkState(testEventId, isBookmarked) itReturns Completable.complete() 43 | detailViewModel.init(testEventId) 44 | 45 | detailViewModel.toggleBookmark() 46 | 47 | verify(repository).changeBookmarkState(testEventId, true) 48 | } 49 | 50 | @Test 51 | fun `un-bookmarking an event should change bookmark state to false`() { 52 | val testEventId = "3" 53 | val isBookmarked = true 54 | When calling repository.isBookmarked(testEventId) itReturns Flowable.just(isBookmarked) 55 | When calling repository.changeBookmarkState(testEventId, isBookmarked) itReturns Completable.complete() 56 | detailViewModel.init(testEventId) 57 | 58 | detailViewModel.toggleBookmark() 59 | 60 | verify(repository).changeBookmarkState(testEventId, false) 61 | } 62 | 63 | @Test 64 | fun `bookmarking an event after disposing the view model is ignored`() { 65 | val testEventId = "3" 66 | val isBookmarked = true 67 | When calling repository.isBookmarked(testEventId) itReturns Flowable.just(isBookmarked) 68 | When calling repository.changeBookmarkState(testEventId, isBookmarked) itReturns Completable.complete() 69 | detailViewModel.init(testEventId) 70 | reset(repository) 71 | 72 | detailViewModel.onCleared() 73 | detailViewModel.toggleBookmark() 74 | 75 | verifyNoMoreInteractions(repository) 76 | } 77 | 78 | @Test 79 | fun `saving playback position after a minimum playback time should be saved in repository`() { 80 | val testEventId = "3" 81 | detailViewModel.init(testEventId) 82 | 83 | detailViewModel.inputs.savePlaybackPosition(playedSeconds = 180, totalDurationSeconds = 2300) 84 | 85 | verify(repository).savePlayedSeconds(eventId = testEventId, seconds = 180) 86 | } 87 | 88 | @Test 89 | fun `saving playback position before the minimum playback time should delete saved playback position`() { 90 | val testEventId = "3" 91 | detailViewModel.init(testEventId) 92 | 93 | detailViewModel.inputs.savePlaybackPosition(playedSeconds = 30, totalDurationSeconds = 2300) 94 | 95 | verify(repository).deletePlayedSeconds(testEventId) 96 | } 97 | 98 | @Test 99 | fun `saving playback position when video is almost finished should delete saved playback position`() { 100 | val testEventId = "3" 101 | detailViewModel.init(testEventId) 102 | 103 | detailViewModel.inputs.savePlaybackPosition(playedSeconds = 2250, totalDurationSeconds = 2300) 104 | 105 | verify(repository).deletePlayedSeconds(testEventId) 106 | } 107 | 108 | } -------------------------------------------------------------------------------- /app/src/test/java/de/stefanmedack/ccctv/util/EventExtensionsTest.kt: -------------------------------------------------------------------------------- 1 | package de.stefanmedack.ccctv.util 2 | 3 | import de.stefanmedack.ccctv.minimalEvent 4 | import de.stefanmedack.ccctv.minimalEventEntity 5 | import de.stefanmedack.ccctv.minimalRecording 6 | import info.metadude.kotlin.library.c3media.models.Language 7 | import info.metadude.kotlin.library.c3media.models.MimeType 8 | import info.metadude.kotlin.library.c3media.models.RelatedEvent 9 | import org.amshove.kluent.shouldBe 10 | import org.amshove.kluent.shouldBeNull 11 | import org.amshove.kluent.shouldEqual 12 | import org.junit.Test 13 | 14 | @Suppress("IllegalIdentifier") 15 | class EventExtensionsTest { 16 | 17 | @Test 18 | fun `Event with null list of recordings should not crash when retrieving best video recording`() { 19 | minimalEvent 20 | .copy(recordings = null) 21 | .bestRecording(Language.EN, true) 22 | .shouldBeNull() 23 | } 24 | 25 | @Test 26 | fun `Event with empty list of recordings should not crash when retrieving best video recording`() { 27 | minimalEvent 28 | .copy(recordings = listOf()) 29 | .bestRecording(Language.EN, true) 30 | .shouldBeNull() 31 | } 32 | 33 | @Test 34 | fun `Event with single recording should not crash when retrieving best video recording`() { 35 | val bestRecording = minimalEvent 36 | .copy(recordings = listOf(minimalRecording)) 37 | .bestRecording(Language.EN, true) 38 | 39 | bestRecording shouldEqual minimalRecording 40 | } 41 | 42 | @Test 43 | fun `Filter all recordings which are not a video mime type`() { 44 | val bestRecording = minimalEvent 45 | .copy(recordings = listOf( 46 | minimalRecording.copy(mimeType = MimeType.MP3), 47 | minimalRecording.copy(mimeType = MimeType.OPUS), 48 | minimalRecording.copy(mimeType = MimeType.UNKNOWN) 49 | )) 50 | .bestRecording(Language.EN, true) 51 | 52 | bestRecording.shouldBeNull() 53 | } 54 | 55 | @Test 56 | fun `Favor MP4 over WEBM when trying to find the best video recording`() { 57 | val bestRecording = minimalEvent 58 | .copy(recordings = listOf( 59 | minimalRecording.copy(mimeType = MimeType.WEBM), 60 | minimalRecording.copy(mimeType = MimeType.MP4) 61 | )) 62 | .bestRecording(Language.EN, true) 63 | 64 | bestRecording!!.mimeType shouldBe MimeType.MP4 65 | } 66 | 67 | @Test 68 | fun `Favor high quality over low quality when trying to find the best video recording`() { 69 | val bestRecording = minimalEvent 70 | .copy(recordings = listOf( 71 | minimalRecording.copy(highQuality = false), 72 | minimalRecording.copy(highQuality = true) 73 | )) 74 | .bestRecording(Language.EN, true) 75 | 76 | bestRecording!!.highQuality shouldBe true 77 | } 78 | 79 | @Test 80 | fun `Favor single language recording over other languages when trying to find the best video recording`() { 81 | val bestRecording = minimalEvent 82 | .copy(recordings = listOf( 83 | minimalRecording.copy(language = listOf(Language.DE)), 84 | minimalRecording.copy(language = listOf(Language.DE, Language.EN)), 85 | minimalRecording.copy(language = listOf(Language.EN)) 86 | )) 87 | .bestRecording(Language.EN, true) 88 | 89 | bestRecording!!.language!!.size shouldBe 1 90 | bestRecording.language!!.first() shouldBe Language.EN 91 | } 92 | 93 | @Test 94 | fun `Related event-ids should be extracted from metadata and be sorted by weight`() { 95 | val testEvent = minimalEventEntity.copy(related = listOf( 96 | RelatedEvent(1, "1", 23), 97 | RelatedEvent(2, "2", 43), 98 | RelatedEvent(3, "3", 13), 99 | RelatedEvent(4, "4", 1), 100 | RelatedEvent(5, "5", 42) 101 | )) 102 | 103 | val related = testEvent.getRelatedEventGuidsWeighted() 104 | 105 | related[0] shouldBe "2" 106 | related[1] shouldBe "5" 107 | related[2] shouldBe "1" 108 | related[3] shouldBe "3" 109 | related[4] shouldBe "4" 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | ext.kotlin_version = '1.2.71' 5 | repositories { 6 | google() 7 | jcenter() 8 | maven { url 'https://maven.fabric.io/public' } 9 | } 10 | dependencies { 11 | classpath 'com.android.tools.build:gradle:3.2.0' 12 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 13 | classpath "com.github.ben-manes:gradle-versions-plugin:0.20.0" 14 | classpath 'com.google.gms:oss-licenses:0.9.1' 15 | classpath 'io.fabric.tools:gradle:1.25.4' 16 | } 17 | } 18 | 19 | allprojects { 20 | repositories { 21 | google() 22 | jcenter() 23 | mavenCentral() 24 | mavenLocal() 25 | maven { url 'https://jitpack.io' } 26 | maven { url 'https://maven.fabric.io/public' } 27 | } 28 | } 29 | 30 | task clean(type: Delete) { 31 | delete rootProject.buildDir 32 | } 33 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # ccc release key settings - need to be replaced by actual values 2 | cccTvKeyStore=/Path/to/actual/keystore.jks 3 | cccTvStorePassword=REPLACE_ME_BY_REAL_VALUES 4 | cccTvKeyAlias=REPLACE_ME_BY_REAL_VALUES 5 | cccTvKeyPassword=REPLACE_ME_BY_REAL_VALUES 6 | 7 | org.gradle.jvmargs=-Xmx1536m 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanmedack/cccTV/b96d15c64b66af5c9f4ca08a2df961b47f1df70d/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Sep 25 22:34:57 CEST 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip 7 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | --------------------------------------------------------------------------------