├── .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 | [](http://choosealicense.com/licenses/apache-2.0/)
2 |
3 | [
][play] **OR** [
][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 |
21 |
22 |
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 |
--------------------------------------------------------------------------------