├── settings.gradle ├── app ├── .gitignore ├── src │ ├── test │ │ ├── resources │ │ │ └── mockito-extensions │ │ │ │ └── org.mockito.plugins.MockMaker │ │ └── java │ │ │ └── com │ │ │ └── youtubedl │ │ │ ├── ui │ │ │ └── main │ │ │ │ ├── home │ │ │ │ └── MainViewModelTest.kt │ │ │ │ ├── player │ │ │ │ └── VideoPlayerViewModelTest.kt │ │ │ │ ├── settings │ │ │ │ └── SettingsViewModelTest.kt │ │ │ │ └── video │ │ │ │ └── VideoViewModelTest.kt │ │ │ ├── util │ │ │ ├── TimeUtilTest.kt │ │ │ └── SingleLiveEventTest.kt │ │ │ ├── TestUtil.kt │ │ │ └── data │ │ │ ├── remote │ │ │ └── VideoRemoteDataSourceTest.kt │ │ │ ├── local │ │ │ ├── VideoLocalDataSourceTest.kt │ │ │ ├── ConfigLocalDataSourceTest.kt │ │ │ └── ProgressLocalDataSourceTest.kt │ │ │ └── repository │ │ │ ├── ProgressRepositoryImplTest.kt │ │ │ ├── ConfigRepositoryImplTest.kt │ │ │ └── VideoRepositoryImplTest.kt │ ├── main │ │ ├── res │ │ │ ├── drawable │ │ │ │ ├── ic_next.png │ │ │ │ ├── ic_pause.png │ │ │ │ ├── ic_play.png │ │ │ │ ├── ic_prev.png │ │ │ │ ├── ic_folder.png │ │ │ │ ├── item_bottom_bar_background.xml │ │ │ │ ├── ic_download_gray_24dp.xml │ │ │ │ ├── ic_download_white_24dp.xml │ │ │ │ ├── ic_arrow_back_white_24dp.xml │ │ │ │ ├── ic_cancel_gray_24dp.xml │ │ │ │ ├── ic_more_gray_24dp.xml │ │ │ │ ├── ic_star_border_gray_24dp.xml │ │ │ │ ├── ic_refresh_gray_24dp.xml │ │ │ │ ├── progress_load_data.xml │ │ │ │ └── ic_video_gray_24dp.xml │ │ │ ├── drawable-xxxhdpi │ │ │ │ ├── ic_empty.png │ │ │ │ ├── ic_video.png │ │ │ │ ├── ic_browser.png │ │ │ │ ├── ic_progress.png │ │ │ │ └── ic_settings.png │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── xml │ │ │ │ └── provider_paths.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── layout │ │ │ │ ├── activity_player.xml │ │ │ │ ├── item_suggestion.xml │ │ │ │ ├── activity_main.xml │ │ │ │ ├── activity_splash.xml │ │ │ │ ├── item_top_page.xml │ │ │ │ ├── fragment_video.xml │ │ │ │ ├── fragment_progress.xml │ │ │ │ ├── dialog_download_video.xml │ │ │ │ ├── item_progress.xml │ │ │ │ └── item_video.xml │ │ │ ├── values │ │ │ │ ├── styles.xml │ │ │ │ ├── colors.xml │ │ │ │ ├── dimens.xml │ │ │ │ └── strings.xml │ │ │ ├── menu │ │ │ │ ├── menu_video.xml │ │ │ │ └── menu_bottom_bar.xml │ │ │ └── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── youtubedl │ │ │ │ ├── ui │ │ │ │ ├── component │ │ │ │ │ ├── adapter │ │ │ │ │ │ ├── SettingsAdapter.kt │ │ │ │ │ │ ├── MainAdapter.kt │ │ │ │ │ │ ├── ProgressAdapter.kt │ │ │ │ │ │ ├── VideoAdapter.kt │ │ │ │ │ │ ├── SuggestionAdapter.kt │ │ │ │ │ │ └── TopPageAdapter.kt │ │ │ │ │ ├── binding │ │ │ │ │ │ ├── AppBarBinding.kt │ │ │ │ │ │ ├── ViewPagerBinding.kt │ │ │ │ │ │ ├── AutoCompleteTextViewBinding.kt │ │ │ │ │ │ ├── ImageBinding.kt │ │ │ │ │ │ ├── BottomNavigationViewBinding.kt │ │ │ │ │ │ ├── VideoViewBinding.kt │ │ │ │ │ │ ├── WebViewBinding.kt │ │ │ │ │ │ └── RecyclerViewBinding.kt │ │ │ │ │ └── dialog │ │ │ │ │ │ ├── DownloadVideoDialog.kt │ │ │ │ │ │ └── RenameVideoDialog.kt │ │ │ │ └── main │ │ │ │ │ ├── base │ │ │ │ │ ├── BaseFragment.kt │ │ │ │ │ ├── BaseActivity.kt │ │ │ │ │ └── BaseViewModel.kt │ │ │ │ │ ├── splash │ │ │ │ │ ├── SplashViewModel.kt │ │ │ │ │ └── SplashActivity.kt │ │ │ │ │ ├── settings │ │ │ │ │ ├── SettingsViewModel.kt │ │ │ │ │ └── SettingsFragment.kt │ │ │ │ │ ├── home │ │ │ │ │ ├── MainViewModel.kt │ │ │ │ │ └── MainActivity.kt │ │ │ │ │ ├── player │ │ │ │ │ ├── VideoPlayerActivity.kt │ │ │ │ │ ├── VideoPlayerViewModel.kt │ │ │ │ │ └── VideoPlayerFragment.kt │ │ │ │ │ ├── video │ │ │ │ │ └── VideoViewModel.kt │ │ │ │ │ └── progress │ │ │ │ │ └── ProgressFragment.kt │ │ │ │ ├── data │ │ │ │ ├── remote │ │ │ │ │ ├── service │ │ │ │ │ │ ├── SearchService.kt │ │ │ │ │ │ ├── VideoService.kt │ │ │ │ │ │ └── ConfigService.kt │ │ │ │ │ ├── TopPagesRemoteDataSource.kt │ │ │ │ │ ├── ConfigRemoteDataSource.kt │ │ │ │ │ └── VideoRemoteDataSource.kt │ │ │ │ ├── local │ │ │ │ │ ├── model │ │ │ │ │ │ ├── Suggestion.kt │ │ │ │ │ │ ├── VideoInfoWrapper.kt │ │ │ │ │ │ └── LocalVideo.kt │ │ │ │ │ ├── TopPagesLocalDataSource.kt │ │ │ │ │ ├── room │ │ │ │ │ │ ├── dao │ │ │ │ │ │ │ ├── ProgressDao.kt │ │ │ │ │ │ │ ├── VideoDao.kt │ │ │ │ │ │ │ └── ConfigDao.kt │ │ │ │ │ │ ├── entity │ │ │ │ │ │ │ ├── SupportedPage.kt │ │ │ │ │ │ │ ├── PageInfo.kt │ │ │ │ │ │ │ ├── ProgressInfo.kt │ │ │ │ │ │ │ └── VideoInfo.kt │ │ │ │ │ │ └── AppDatabase.kt │ │ │ │ │ ├── VideoLocalDataSource.kt │ │ │ │ │ ├── ConfigLocalDataSource.kt │ │ │ │ │ └── ProgressLocalDataSource.kt │ │ │ │ └── repository │ │ │ │ │ ├── TopPagesRepository.kt │ │ │ │ │ ├── ProgressRepository.kt │ │ │ │ │ ├── VideoRepository.kt │ │ │ │ │ └── ConfigRepository.kt │ │ │ │ ├── di │ │ │ │ ├── ActivityScoped.kt │ │ │ │ ├── FragmentScoped.kt │ │ │ │ ├── qualifier │ │ │ │ │ ├── LocalData.kt │ │ │ │ │ ├── RemoteData.kt │ │ │ │ │ ├── ActivityContext.kt │ │ │ │ │ └── ApplicationContext.kt │ │ │ │ ├── ViewModelKey.kt │ │ │ │ ├── module │ │ │ │ │ ├── activity │ │ │ │ │ │ ├── VideoPlayerModule.kt │ │ │ │ │ │ └── MainModule.kt │ │ │ │ │ ├── AppModule.kt │ │ │ │ │ ├── UtilModule.kt │ │ │ │ │ ├── ActivityBindingModule.kt │ │ │ │ │ ├── DatabaseModule.kt │ │ │ │ │ ├── ViewModelModule.kt │ │ │ │ │ ├── NetworkModule.kt │ │ │ │ │ └── RepositoryModule.kt │ │ │ │ └── component │ │ │ │ │ └── AppComponent.kt │ │ │ │ ├── util │ │ │ │ ├── fragment │ │ │ │ │ ├── StubbedFragmentFactory.kt │ │ │ │ │ └── FragmentFactory.kt │ │ │ │ ├── RoomConverter.kt │ │ │ │ ├── scheduler │ │ │ │ │ ├── StubbedSchedulers.kt │ │ │ │ │ └── BaseSchedulers.kt │ │ │ │ ├── Memory.kt │ │ │ │ ├── TimeUtil.kt │ │ │ │ ├── AppUtil.kt │ │ │ │ ├── ViewModelFactory.kt │ │ │ │ ├── ext │ │ │ │ │ └── ActivityExt.kt │ │ │ │ ├── SystemUtil.kt │ │ │ │ ├── SingleLiveEvent.kt │ │ │ │ ├── IntentUtil.kt │ │ │ │ ├── FileUtil.kt │ │ │ │ └── ScriptUtil.kt │ │ │ │ └── DLApplication.kt │ │ └── AndroidManifest.xml │ ├── release │ │ └── java │ │ │ └── com │ │ │ └── youtubedl │ │ │ └── OpenForTesting.kt │ ├── debug │ │ └── java │ │ │ └── com │ │ │ └── youtubedl │ │ │ └── OpenForTesting.kt │ └── androidTest │ │ └── java │ │ ├── util │ │ ├── TestRunner.kt │ │ ├── ViewModelUtil.kt │ │ ├── TestUtil.kt │ │ ├── RecyclerViewUtil.kt │ │ ├── rule │ │ │ ├── InjectedActivityTestRule.kt │ │ │ └── InjectedFragmentTestRule.kt │ │ ├── TestApplication.kt │ │ ├── TestHelperActivity.kt │ │ └── RecyclerViewMatcher.kt │ │ └── com │ │ └── youtubedl │ │ └── ui │ │ └── main │ │ ├── settings │ │ └── SettingsFragmentTest.kt │ │ └── player │ │ └── VideoPlayerFragmentTest.kt └── proguard-rules.pro ├── screenshots ├── screenshot_1.png ├── screenshot_2.png ├── screenshot_3.png ├── screenshot_4.png └── screenshot_5.png ├── .gitignore ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── LICENSE ├── README.md ├── gradlew.bat └── versions.gradle /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | google-services.json -------------------------------------------------------------------------------- /app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /screenshots/screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/screenshots/screenshot_1.png -------------------------------------------------------------------------------- /screenshots/screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/screenshots/screenshot_2.png -------------------------------------------------------------------------------- /screenshots/screenshot_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/screenshots/screenshot_3.png -------------------------------------------------------------------------------- /screenshots/screenshot_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/screenshots/screenshot_4.png -------------------------------------------------------------------------------- /screenshots/screenshot_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/screenshots/screenshot_5.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .gradle 3 | .idea 4 | *.iml 5 | local.properties 6 | build/ 7 | captures/* 8 | .externalNativeBuild 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/drawable/ic_next.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/drawable/ic_pause.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/drawable/ic_play.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_prev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/drawable/ic_prev.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/drawable/ic_folder.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/drawable-xxxhdpi/ic_empty.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/drawable-xxxhdpi/ic_video.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_browser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/drawable-xxxhdpi/ic_browser.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/xml/provider_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_progress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/drawable-xxxhdpi/ic_progress.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/drawable-xxxhdpi/ic_settings.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubs/youtube-dl-android/master/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/adapter/SettingsAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.adapter 2 | 3 | /** 4 | * Created by cuongpm on 12/23/18. 5 | */ -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/remote/service/SearchService.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.remote.service 2 | 3 | /** 4 | * Created by cuongpm on 12/8/18. 5 | */ 6 | 7 | interface SearchService -------------------------------------------------------------------------------- /app/src/release/java/com/youtubedl/OpenForTesting.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl 2 | 3 | /** 4 | * Created by cuongpm on 1/31/19. 5 | */ 6 | 7 | @Target(AnnotationTarget.CLASS) 8 | annotation class OpenForTesting -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/model/Suggestion.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local.model 2 | 3 | /** 4 | * Created by cuongpm on 12/23/18. 5 | */ 6 | 7 | data class Suggestion constructor( 8 | var content: String = "", 9 | 10 | var icon: Int = 0 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/base/BaseFragment.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.base 2 | 3 | import dagger.android.support.DaggerFragment 4 | 5 | /** 6 | * Created by cuongpm on 12/6/18. 7 | */ 8 | 9 | abstract class BaseFragment : DaggerFragment() { 10 | 11 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun May 12 15:08:17 ICT 2019 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-5.1.1-all.zip 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/ActivityScoped.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di 2 | 3 | import javax.inject.Scope 4 | 5 | /** 6 | * Created by cuongpm on 12/6/18. 7 | */ 8 | 9 | @Scope 10 | @MustBeDocumented 11 | @Retention(AnnotationRetention.RUNTIME) 12 | annotation class ActivityScoped -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/FragmentScoped.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di 2 | 3 | import javax.inject.Scope 4 | 5 | /** 6 | * Created by cuongpm on 12/6/18. 7 | */ 8 | 9 | @Scope 10 | @MustBeDocumented 11 | @Retention(AnnotationRetention.RUNTIME) 12 | annotation class FragmentScoped -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/base/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.base 2 | 3 | import dagger.android.support.DaggerAppCompatActivity 4 | 5 | /** 6 | * Created by cuongpm on 12/6/18. 7 | */ 8 | 9 | abstract class BaseActivity : DaggerAppCompatActivity() { 10 | 11 | } -------------------------------------------------------------------------------- /app/src/debug/java/com/youtubedl/OpenForTesting.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl 2 | 3 | /** 4 | * Created by cuongpm on 1/31/19. 5 | */ 6 | 7 | @Target(AnnotationTarget.ANNOTATION_CLASS) 8 | annotation class OpenClass 9 | 10 | @OpenClass 11 | @Target(AnnotationTarget.CLASS) 12 | annotation class OpenForTesting -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/qualifier/LocalData.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.qualifier 2 | 3 | import javax.inject.Qualifier 4 | 5 | /** 6 | * Created by cuongpm on 12/8/18. 7 | */ 8 | 9 | @Qualifier 10 | @MustBeDocumented 11 | @Retention(AnnotationRetention.RUNTIME) 12 | annotation class LocalData -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/qualifier/RemoteData.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.qualifier 2 | 3 | import javax.inject.Qualifier 4 | 5 | /** 6 | * Created by cuongpm on 12/8/18. 7 | */ 8 | 9 | @Qualifier 10 | @MustBeDocumented 11 | @Retention(AnnotationRetention.RUNTIME) 12 | annotation class RemoteData -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/qualifier/ActivityContext.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.qualifier 2 | 3 | import javax.inject.Qualifier 4 | 5 | /** 6 | * Created by cuongpm on 12/6/18. 7 | */ 8 | 9 | @Qualifier 10 | @MustBeDocumented 11 | @Retention(AnnotationRetention.RUNTIME) 12 | annotation class ActivityContext -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/qualifier/ApplicationContext.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.qualifier 2 | 3 | import javax.inject.Qualifier 4 | 5 | /** 6 | * Created by cuongpm on 12/6/18. 7 | */ 8 | 9 | @Qualifier 10 | @MustBeDocumented 11 | @Retention(AnnotationRetention.RUNTIME) 12 | annotation class ApplicationContext -------------------------------------------------------------------------------- /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/java/com/youtubedl/ui/main/base/BaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.base 2 | 3 | import android.arch.lifecycle.ViewModel 4 | 5 | /** 6 | * Created by cuongpm on 12/15/18. 7 | */ 8 | 9 | abstract class BaseViewModel : ViewModel() { 10 | 11 | abstract fun start() 12 | 13 | abstract fun stop() 14 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/item_bottom_bar_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_download_gray_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_download_white_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/ViewModelKey.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import dagger.MapKey 5 | import kotlin.reflect.KClass 6 | 7 | /** 8 | * Created by cuongpm on 12/6/18. 9 | */ 10 | 11 | @Target(allowedTargets = [AnnotationTarget.FUNCTION]) 12 | @Retention(value = AnnotationRetention.RUNTIME) 13 | @MapKey 14 | annotation class ViewModelKey(val value: KClass) -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_arrow_back_white_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/model/VideoInfoWrapper.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local.model 2 | 3 | import com.google.gson.annotations.Expose 4 | import com.google.gson.annotations.SerializedName 5 | import com.youtubedl.data.local.room.entity.VideoInfo 6 | 7 | /** 8 | * Created by cuongpm on 1/6/19. 9 | */ 10 | 11 | data class VideoInfoWrapper constructor( 12 | @SerializedName("info") 13 | @Expose 14 | var videoInfo: VideoInfo? 15 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/remote/service/VideoService.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.remote.service 2 | 3 | import com.youtubedl.data.local.model.VideoInfoWrapper 4 | import io.reactivex.Flowable 5 | import retrofit2.http.GET 6 | import retrofit2.http.Query 7 | 8 | /** 9 | * Created by cuongpm on 1/6/19. 10 | */ 11 | 12 | interface VideoService { 13 | 14 | @GET("info") 15 | fun getVideoInfo(@Query("url") url: String): Flowable 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/splash/SplashViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.splash 2 | 3 | import com.youtubedl.OpenForTesting 4 | import com.youtubedl.ui.main.base.BaseViewModel 5 | import javax.inject.Inject 6 | 7 | /** 8 | * Created by cuongpm on 12/6/18. 9 | */ 10 | 11 | @OpenForTesting 12 | class SplashViewModel @Inject constructor() : BaseViewModel() { 13 | 14 | override fun start() { 15 | } 16 | 17 | override fun stop() { 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/binding/AppBarBinding.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.binding 2 | 3 | import android.databinding.BindingAdapter 4 | import android.support.design.widget.AppBarLayout 5 | 6 | /** 7 | * Created by cuongpm on 12/23/18. 8 | */ 9 | 10 | object AppBarBinding { 11 | 12 | @BindingAdapter("app:smoothExpanded") 13 | @JvmStatic 14 | fun AppBarLayout.setExpanded(isExpanded: Boolean) { 15 | setExpanded(isExpanded, true) 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/util/TestRunner.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.support.test.runner.AndroidJUnitRunner 6 | 7 | /** 8 | * Created by cuongpm on 2/10/19. 9 | */ 10 | 11 | class TestRunner : AndroidJUnitRunner() { 12 | 13 | override fun newApplication(cl: ClassLoader, className: String, context: Context): Application = 14 | super.newApplication(cl, TestApplication::class.java.canonicalName, context) 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/fragment/StubbedFragmentFactory.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util.fragment 2 | 3 | import android.support.v4.app.Fragment 4 | 5 | /** 6 | * Created by cuongpm on 2/13/19. 7 | */ 8 | 9 | class StubbedFragmentFactory : FragmentFactory { 10 | override fun createBrowserFragment() = Fragment() 11 | 12 | override fun createProgressFragment() = Fragment() 13 | 14 | override fun createVideoFragment() = Fragment() 15 | 16 | override fun createSettingsFragment() = Fragment() 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/module/activity/VideoPlayerModule.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.module.activity 2 | 3 | import com.youtubedl.di.FragmentScoped 4 | import com.youtubedl.ui.main.player.VideoPlayerFragment 5 | import dagger.Module 6 | import dagger.android.ContributesAndroidInjector 7 | 8 | /** 9 | * Created by cuongpm on 1/6/19. 10 | */ 11 | 12 | @Module 13 | abstract class VideoPlayerModule { 14 | 15 | @FragmentScoped 16 | @ContributesAndroidInjector 17 | abstract fun bindVideoPlayerFragment(): VideoPlayerFragment 18 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_cancel_gray_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_more_gray_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/ui/main/home/MainViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.home 2 | 3 | import org.junit.Before 4 | import org.junit.Test 5 | 6 | /** 7 | * Created by cuongpm on 1/14/19. 8 | */ 9 | 10 | class MainViewModelTest { 11 | 12 | private lateinit var mainViewModel: MainViewModel 13 | 14 | @Before 15 | fun setup() { 16 | mainViewModel = MainViewModel() 17 | } 18 | 19 | @Test 20 | fun `test MainViewModel here`() { 21 | mainViewModel.start() 22 | mainViewModel.stop() 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_player.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_star_border_gray_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_refresh_gray_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/remote/service/ConfigService.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.remote.service 2 | 3 | import com.youtubedl.data.local.room.entity.PageInfo 4 | import com.youtubedl.data.local.room.entity.SupportedPage 5 | import io.reactivex.Flowable 6 | import retrofit2.http.GET 7 | 8 | /** 9 | * Created by cuongpm on 12/8/18. 10 | */ 11 | 12 | interface ConfigService { 13 | 14 | @GET("supported_pages.json") 15 | fun getSupportedPages(): Flowable> 16 | 17 | @GET("top_pages.json") 18 | fun getTopPages(): Flowable> 19 | 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/TopPagesLocalDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local 2 | 3 | import com.youtubedl.data.local.room.entity.PageInfo 4 | import com.youtubedl.data.repository.TopPagesRepository 5 | import io.reactivex.Flowable 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | /** 10 | * Created by cuongpm on 12/18/18. 11 | */ 12 | 13 | @Singleton 14 | class TopPagesLocalDataSource @Inject constructor( 15 | 16 | ) : TopPagesRepository { 17 | 18 | override fun getTopPages(): Flowable> { 19 | return Flowable.empty() 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/RoomConverter.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util 2 | 3 | import android.arch.persistence.room.TypeConverter 4 | import com.google.gson.Gson 5 | import com.youtubedl.data.local.room.entity.VideoInfo 6 | 7 | /** 8 | * Created by cuongpm on 1/15/19. 9 | */ 10 | 11 | class RoomConverter { 12 | 13 | @TypeConverter 14 | fun convertJsonToVideo(json: String): VideoInfo { 15 | return Gson().fromJson(json, VideoInfo::class.java) 16 | } 17 | 18 | @TypeConverter 19 | fun convertListVideosToJson(video: VideoInfo): String { 20 | return Gson().toJson(video) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/scheduler/StubbedSchedulers.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util.scheduler 2 | 3 | import io.reactivex.Scheduler 4 | import io.reactivex.schedulers.Schedulers 5 | 6 | 7 | /** 8 | * Created by cuongpm on 1/16/19. 9 | */ 10 | 11 | class StubbedSchedulers( 12 | override val computation: Scheduler = Schedulers.trampoline(), 13 | override val io: Scheduler = Schedulers.trampoline(), 14 | override val newThread: Scheduler = Schedulers.trampoline(), 15 | override val single: Scheduler = Schedulers.trampoline(), 16 | override val mainThread: Scheduler = Schedulers.trampoline() 17 | ) : BaseSchedulers -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/room/dao/ProgressDao.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local.room.dao 2 | 3 | import android.arch.persistence.room.* 4 | import com.youtubedl.data.local.room.entity.ProgressInfo 5 | import io.reactivex.Flowable 6 | 7 | /** 8 | * Created by cuongpm on 1/15/19. 9 | */ 10 | 11 | @Dao 12 | interface ProgressDao { 13 | 14 | @Query("SELECT * FROM ProgressInfo") 15 | fun getProgressInfos(): Flowable> 16 | 17 | @Insert(onConflict = OnConflictStrategy.REPLACE) 18 | fun insertProgressInfo(progressInfo: ProgressInfo) 19 | 20 | @Delete 21 | fun deleteProgressInfo(progressInfo: ProgressInfo) 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/binding/ViewPagerBinding.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.binding 2 | 3 | import android.databinding.BindingAdapter 4 | import android.support.v4.view.ViewPager 5 | 6 | /** 7 | * Created by cuongpm on 12/9/18. 8 | */ 9 | 10 | object ViewPagerBinding { 11 | 12 | @BindingAdapter("app:currentItem") 13 | @JvmStatic 14 | fun ViewPager.setCurrentItem(currentItem: Int) { 15 | setCurrentItem(currentItem, true) 16 | } 17 | 18 | @BindingAdapter("app:offScreenPageLimit") 19 | @JvmStatic 20 | fun ViewPager.setOffScreenPageLimit(pageLimit: Int) { 21 | offscreenPageLimit = pageLimit 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/model/LocalVideo.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local.model 2 | 3 | import android.graphics.Bitmap 4 | import android.media.ThumbnailUtils 5 | import android.provider.MediaStore.Video.Thumbnails.MINI_KIND 6 | import com.youtubedl.util.FileUtil.Companion.getFileSize 7 | import java.io.File 8 | 9 | /** 10 | * Created by cuongpm on 1/12/19. 11 | */ 12 | 13 | data class LocalVideo constructor( 14 | var file: File 15 | ) { 16 | 17 | var size: String = "" 18 | get() = getFileSize(file.length().toDouble()) 19 | 20 | val thumbnail: Bitmap? 21 | get() = ThumbnailUtils.createVideoThumbnail(file.path, MINI_KIND) 22 | 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/binding/AutoCompleteTextViewBinding.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.binding 2 | 3 | import android.databinding.BindingAdapter 4 | import android.widget.AutoCompleteTextView 5 | import com.youtubedl.data.local.model.Suggestion 6 | import com.youtubedl.ui.component.adapter.SuggestionAdapter 7 | 8 | /** 9 | * Created by cuongpm on 12/23/18. 10 | */ 11 | 12 | object AutoCompleteTextViewBinding { 13 | 14 | @BindingAdapter("app:items") 15 | @JvmStatic 16 | fun AutoCompleteTextView.setSuggestions(items: List) { 17 | with(adapter as SuggestionAdapter?) { 18 | this?.setData(items) 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/util/TimeUtilTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util 2 | 3 | import com.youtubedl.util.TimeUtil.convertMilliSecondsToTimer 4 | import org.junit.Assert.assertEquals 5 | import org.junit.Test 6 | 7 | /** 8 | * Created by cuongpm on 1/29/19. 9 | */ 10 | class TimeUtilTest { 11 | 12 | @Test 13 | fun `test convert milliseconds to timer`() { 14 | val timer1 = 111000L 15 | val timer2 = 929000L 16 | val timer3 = 8725000L 17 | 18 | assertEquals("01:51", convertMilliSecondsToTimer(timer1)) 19 | assertEquals("15:29", convertMilliSecondsToTimer(timer2)) 20 | assertEquals("2:25:25", convertMilliSecondsToTimer(timer3)) 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/binding/ImageBinding.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.binding 2 | 3 | import android.databinding.BindingAdapter 4 | import android.graphics.Bitmap 5 | import android.widget.ImageView 6 | import com.bumptech.glide.Glide 7 | 8 | /** 9 | * Created by cuongpm on 12/18/18. 10 | */ 11 | 12 | object ImageBinding { 13 | 14 | @BindingAdapter("app:imageUrl") 15 | @JvmStatic 16 | fun ImageView.loadImage(url: String) { 17 | Glide.with(context).load(url).into(this) 18 | } 19 | 20 | @BindingAdapter("app:bitmap") 21 | @JvmStatic 22 | fun ImageView.setImageBitmap(bitmap: Bitmap?) { 23 | bitmap?.let { setImageBitmap(it) } 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/remote/TopPagesRemoteDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.remote 2 | 3 | import com.youtubedl.data.local.room.entity.PageInfo 4 | import com.youtubedl.data.remote.service.ConfigService 5 | import com.youtubedl.data.repository.TopPagesRepository 6 | import io.reactivex.Flowable 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | /** 11 | * Created by cuongpm on 12/18/18. 12 | */ 13 | 14 | @Singleton 15 | class TopPagesRemoteDataSource @Inject constructor( 16 | private val configService: ConfigService 17 | ) : TopPagesRepository { 18 | 19 | override fun getTopPages(): Flowable> { 20 | return configService.getTopPages() 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3f51b5 4 | #3044AF 5 | #D81B60 6 | 7 | #7a7a7a 8 | #f2f2f2 9 | #7a7a7a 10 | 11 | #26000000 12 | #8C000000 13 | #D9000000 14 | 15 | #26ffffff 16 | #8Cffffff 17 | #D9ffffff 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/room/dao/VideoDao.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local.room.dao 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 com.youtubedl.data.local.room.entity.VideoInfo 8 | import io.reactivex.Maybe 9 | 10 | /** 11 | * Created by cuongpm on 1/6/19. 12 | */ 13 | 14 | @Dao 15 | interface VideoDao { 16 | 17 | @Query("SELECT * FROM VideoInfo WHERE originalUrl = :url") 18 | fun getVideoById(url: String): Maybe 19 | 20 | @Insert(onConflict = OnConflictStrategy.REPLACE) 21 | fun insertVideo(videoInfo: VideoInfo) 22 | } -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_video.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 8 | 9 | 13 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/binding/BottomNavigationViewBinding.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.binding 2 | 3 | import android.databinding.BindingAdapter 4 | import android.support.design.widget.BottomNavigationView 5 | import com.youtubedl.R 6 | 7 | /** 8 | * Created by cuongpm on 12/15/18. 9 | */ 10 | 11 | object BottomNavigationViewBinding { 12 | 13 | @BindingAdapter("app:selectedItemId") 14 | @JvmStatic 15 | fun BottomNavigationView.setSelectedItemId(position: Int) { 16 | selectedItemId = when (position) { 17 | 0 -> R.id.tab_browser 18 | 1 -> R.id.tab_progress 19 | 2 -> R.id.tab_video 20 | else -> R.id.tab_settings 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/util/ViewModelUtil.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import android.arch.lifecycle.ViewModelProvider 5 | 6 | /** 7 | * Created by cuongpm on 1/31/19. 8 | */ 9 | 10 | object ViewModelUtil { 11 | fun createFor(model: T): ViewModelProvider.Factory { 12 | return object : ViewModelProvider.Factory { 13 | override fun create(modelClass: Class): T { 14 | if (modelClass.isAssignableFrom(model.javaClass)) { 15 | @Suppress("UNCHECKED_CAST") 16 | return model as T 17 | } 18 | throw IllegalArgumentException("unexpected model class $modelClass") 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/Memory.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util 2 | 3 | import android.app.ActivityManager 4 | import android.content.Context 5 | import android.content.pm.ApplicationInfo 6 | import android.support.annotation.FloatRange 7 | 8 | /** 9 | * Created by cuongpm on 12/6/18. 10 | */ 11 | 12 | object Memory { 13 | 14 | fun calcCacheSize(context: Context, @FloatRange(from = 0.01, to = 1.0) size: Float): Long { 15 | val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager 16 | val largeHeap = context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP != 0 17 | val memoryClass = if (largeHeap) am.largeMemoryClass else am.memoryClass 18 | return (memoryClass * 1024L * 1024L * size).toLong() 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/util/TestUtil.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import java.util.concurrent.Callable 4 | 5 | /** 6 | * Created by cuongpm on 1/17/19. 7 | */ 8 | 9 | fun waitUntil(commandName: String, check: Callable, timeout: Long) { 10 | val startTime = System.currentTimeMillis() 11 | var lastError = RuntimeException(commandName) 12 | do { 13 | try { 14 | if (check.call()) break 15 | } catch (t: Throwable) { 16 | lastError = RuntimeException(commandName, t) 17 | } 18 | 19 | if (System.currentTimeMillis() - startTime > timeout) { 20 | throw lastError 21 | } 22 | try { 23 | Thread.sleep(10) 24 | } catch (ignored: InterruptedException) { 25 | } 26 | } while (true) 27 | } -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/TestUtil.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl 2 | 3 | import java.util.concurrent.Callable 4 | 5 | /** 6 | * Created by cuongpm on 1/17/19. 7 | */ 8 | 9 | fun waitUntil(commandName: String, check: Callable, waitTime: Long) { 10 | val startTime = System.currentTimeMillis() 11 | var lastError = RuntimeException(commandName) 12 | do { 13 | try { 14 | if (check.call()) break 15 | } catch (t: Throwable) { 16 | lastError = RuntimeException(commandName, t) 17 | } 18 | 19 | if (System.currentTimeMillis() - startTime > waitTime) { 20 | throw lastError 21 | } 22 | try { 23 | Thread.sleep(10) 24 | } catch (ignored: InterruptedException) { 25 | } 26 | } while (true) 27 | } -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/ui/main/player/VideoPlayerViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.player 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Before 5 | import org.junit.Test 6 | 7 | /** 8 | * Created by cuongpm on 1/29/19. 9 | */ 10 | class VideoPlayerViewModelTest { 11 | 12 | private lateinit var videoPlayerViewModel: VideoPlayerViewModel 13 | 14 | @Before 15 | fun setup() { 16 | videoPlayerViewModel = VideoPlayerViewModel() 17 | } 18 | 19 | @Test 20 | fun `test video volume on`() { 21 | assertEquals(1.0f, videoPlayerViewModel.getVolume()) 22 | } 23 | 24 | @Test 25 | fun `test video volume off`() { 26 | videoPlayerViewModel.isVolumeOn = false 27 | assertEquals(0.0f, videoPlayerViewModel.getVolume()) 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/settings/SettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.settings 2 | 3 | import com.youtubedl.OpenForTesting 4 | import com.youtubedl.ui.main.base.BaseViewModel 5 | import com.youtubedl.util.SingleLiveEvent 6 | import javax.inject.Inject 7 | 8 | /** 9 | * Created by cuongpm on 12/7/18. 10 | */ 11 | 12 | @OpenForTesting 13 | class SettingsViewModel @Inject constructor() : BaseViewModel() { 14 | 15 | val clearCookiesEvent = SingleLiveEvent() 16 | val openVideoFolderEvent = SingleLiveEvent() 17 | 18 | override fun start() { 19 | } 20 | 21 | override fun stop() { 22 | } 23 | 24 | fun clearCookies() { 25 | clearCookiesEvent.call() 26 | } 27 | 28 | fun openVideoFolder() { 29 | openVideoFolderEvent.call() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/remote/ConfigRemoteDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.remote 2 | 3 | import com.youtubedl.data.local.room.entity.SupportedPage 4 | import com.youtubedl.data.remote.service.ConfigService 5 | import com.youtubedl.data.repository.ConfigRepository 6 | import io.reactivex.Flowable 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | /** 11 | * Created by cuongpm on 12/8/18. 12 | */ 13 | 14 | @Singleton 15 | class ConfigRemoteDataSource @Inject constructor( 16 | private val configService: ConfigService 17 | ) : ConfigRepository { 18 | 19 | override fun getSupportedPages(): Flowable> { 20 | return configService.getSupportedPages() 21 | } 22 | 23 | override fun saveSupportedPages(supportedPages: List) { 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/VideoLocalDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local 2 | 3 | import com.youtubedl.data.local.room.dao.VideoDao 4 | import com.youtubedl.data.local.room.entity.VideoInfo 5 | import com.youtubedl.data.repository.VideoRepository 6 | import io.reactivex.Flowable 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | /** 11 | * Created by cuongpm on 1/6/19. 12 | */ 13 | 14 | @Singleton 15 | class VideoLocalDataSource @Inject constructor( 16 | private val videoDao: VideoDao 17 | ) : VideoRepository { 18 | 19 | override fun getVideoInfo(url: String): Flowable { 20 | return videoDao.getVideoById(url).toFlowable() 21 | } 22 | 23 | override fun saveVideoInfo(videoInfo: VideoInfo) { 24 | videoDao.insertVideo(videoInfo) 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/binding/VideoViewBinding.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.binding 2 | 3 | import android.databinding.BindingAdapter 4 | import android.net.Uri 5 | import android.support.v4.content.FileProvider 6 | import android.widget.VideoView 7 | import java.io.File 8 | 9 | /** 10 | * Created by cuongpm on 12/9/18. 11 | */ 12 | 13 | object VideoViewBinding { 14 | 15 | @BindingAdapter("app:videoURI") 16 | @JvmStatic 17 | fun VideoView.setVideoURI(videoPath: String?) { 18 | videoPath?.let { path -> 19 | val uri = if (path.startsWith("http")) { 20 | Uri.parse(path) 21 | } else { 22 | FileProvider.getUriForFile(context, context.packageName + ".provider", File(path)) 23 | } 24 | setVideoURI(uri) 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/home/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.home 2 | 3 | import android.databinding.ObservableField 4 | import com.youtubedl.OpenForTesting 5 | import com.youtubedl.data.local.room.entity.VideoInfo 6 | import com.youtubedl.ui.main.base.BaseViewModel 7 | import com.youtubedl.util.SingleLiveEvent 8 | import javax.inject.Inject 9 | 10 | /** 11 | * Created by cuongpm on 12/9/18. 12 | */ 13 | 14 | @OpenForTesting 15 | class MainViewModel @Inject constructor() : BaseViewModel() { 16 | 17 | val currentItem = ObservableField() 18 | 19 | val offScreenPageLimit = ObservableField(4) 20 | 21 | val pressBackBtnEvent = SingleLiveEvent() 22 | 23 | val downloadVideoEvent = SingleLiveEvent() 24 | 25 | override fun start() { 26 | } 27 | 28 | override fun stop() { 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/TimeUtil.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util 2 | 3 | /** 4 | * Created by cuongpm on 1/29/19. 5 | */ 6 | 7 | object TimeUtil { 8 | 9 | fun convertMilliSecondsToTimer(milliSeconds: Long): String { 10 | val hourString: String 11 | val secondString: String 12 | val minuteString: String 13 | 14 | val hours = (milliSeconds / (1000 * 60 * 60)).toInt() 15 | val minutes = (milliSeconds % (1000 * 60 * 60)).toInt() / (1000 * 60) 16 | val seconds = (milliSeconds % (1000 * 60 * 60) % (1000 * 60) / 1000).toInt() 17 | 18 | hourString = if (hours > 0) "$hours:" else "" 19 | minuteString = if (minutes < 10) "0$minutes" else "" + minutes 20 | secondString = if (seconds < 10) "0$seconds" else "" + seconds 21 | 22 | return "$hourString$minuteString:$secondString" 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/util/RecyclerViewUtil.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import android.support.test.espresso.matcher.BoundedMatcher 4 | import android.support.v7.widget.RecyclerView 5 | import android.view.View 6 | import org.hamcrest.Description 7 | 8 | /** 9 | * Created by cuongpm on 2/4/19. 10 | */ 11 | 12 | object RecyclerViewUtil { 13 | 14 | fun recyclerViewSizeIs(size: Int) = object : BoundedMatcher(RecyclerView::class.java) { 15 | var realSize: Int = -1 16 | 17 | override fun describeTo(description: Description?) { 18 | description?.appendText("RecyclerView size should be $size but it's $realSize") 19 | } 20 | 21 | override fun matchesSafely(item: RecyclerView?): Boolean { 22 | realSize = item?.adapter?.itemCount ?: -2 23 | return realSize == size 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/remote/VideoRemoteDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.remote 2 | 3 | import com.youtubedl.data.local.room.entity.VideoInfo 4 | import com.youtubedl.data.remote.service.VideoService 5 | import com.youtubedl.data.repository.VideoRepository 6 | import io.reactivex.Flowable 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | /** 11 | * Created by cuongpm on 1/6/19. 12 | */ 13 | 14 | @Singleton 15 | class VideoRemoteDataSource @Inject constructor( 16 | private val videoService: VideoService 17 | ) : VideoRepository { 18 | 19 | override fun getVideoInfo(url: String): Flowable { 20 | return videoService.getVideoInfo(url) 21 | .flatMap { videoInfoWrapper -> Flowable.just(videoInfoWrapper.videoInfo) } 22 | } 23 | 24 | override fun saveVideoInfo(videoInfo: VideoInfo) { 25 | } 26 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # Kotlin code style for this project: "official" or "obsolete": 15 | kotlin.code.style=official 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/player/VideoPlayerActivity.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.player 2 | 3 | import android.os.Bundle 4 | import android.view.Window 5 | import android.view.WindowManager 6 | import com.youtubedl.R 7 | import com.youtubedl.ui.main.base.BaseActivity 8 | import com.youtubedl.util.ext.addFragment 9 | 10 | /** 11 | * Created by cuongpm on 1/6/19. 12 | */ 13 | 14 | class VideoPlayerActivity : BaseActivity() { 15 | 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | requestWindowFeature(Window.FEATURE_NO_TITLE) 19 | window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) 20 | setContentView(R.layout.activity_player) 21 | 22 | addFragment(R.id.content_frame, intent.extras, ::VideoPlayerFragment) 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/ConfigLocalDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local 2 | 3 | import com.youtubedl.data.local.room.dao.ConfigDao 4 | import com.youtubedl.data.local.room.entity.SupportedPage 5 | import com.youtubedl.data.repository.ConfigRepository 6 | import io.reactivex.Flowable 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | /** 11 | * Created by cuongpm on 12/8/18. 12 | */ 13 | 14 | @Singleton 15 | class ConfigLocalDataSource @Inject constructor( 16 | private val configDao: ConfigDao 17 | ) : ConfigRepository { 18 | 19 | override fun getSupportedPages(): Flowable> { 20 | return configDao.getSupportedPages().toFlowable() 21 | } 22 | 23 | override fun saveSupportedPages(supportedPages: List) { 24 | supportedPages.map { configDao.insertSupportedPage(it) } 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/repository/TopPagesRepository.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.repository 2 | 3 | import com.youtubedl.data.local.room.entity.PageInfo 4 | import com.youtubedl.di.qualifier.LocalData 5 | import com.youtubedl.di.qualifier.RemoteData 6 | import io.reactivex.Flowable 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | /** 11 | * Created by cuongpm on 12/18/18. 12 | */ 13 | 14 | interface TopPagesRepository { 15 | fun getTopPages(): Flowable> 16 | } 17 | 18 | @Singleton 19 | class TopPagesRepositoryImpl @Inject constructor( 20 | @LocalData private val localDataSource: TopPagesRepository, 21 | @RemoteData private val remoteDataSource: TopPagesRepository 22 | ) : TopPagesRepository { 23 | 24 | override fun getTopPages(): Flowable> { 25 | return remoteDataSource.getTopPages() 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/room/entity/SupportedPage.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local.room.entity 2 | 3 | import android.arch.persistence.room.ColumnInfo 4 | import android.arch.persistence.room.Entity 5 | import android.arch.persistence.room.PrimaryKey 6 | import com.google.gson.annotations.Expose 7 | import com.google.gson.annotations.SerializedName 8 | import java.util.* 9 | 10 | /** 11 | * Created by cuongpm on 12/8/18. 12 | */ 13 | 14 | @Entity(tableName = "SupportedPage") 15 | data class SupportedPage constructor( 16 | @PrimaryKey 17 | @ColumnInfo(name = "id") 18 | var id: String = UUID.randomUUID().toString(), 19 | 20 | @ColumnInfo(name = "name") 21 | @SerializedName("name") 22 | @Expose 23 | var name: String = "", 24 | 25 | @ColumnInfo(name = "pattern") 26 | @SerializedName("pattern") 27 | @Expose 28 | var pattern: String = "" 29 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/module/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.module 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import com.youtubedl.DLApplication 6 | import com.youtubedl.di.qualifier.ApplicationContext 7 | import com.youtubedl.util.scheduler.BaseSchedulers 8 | import com.youtubedl.util.scheduler.BaseSchedulersImpl 9 | import dagger.Binds 10 | import dagger.Module 11 | import javax.inject.Singleton 12 | 13 | /** 14 | * Created by cuongpm on 12/6/18. 15 | */ 16 | 17 | @Module 18 | abstract class AppModule { 19 | 20 | @Binds 21 | @ApplicationContext 22 | abstract fun bindApplicationContext(application: DLApplication): Context 23 | 24 | @Binds 25 | abstract fun bindApplication(application: DLApplication): Application 26 | 27 | @Singleton 28 | @Binds 29 | abstract fun bindBaseSchedulers(baseSchedulers: BaseSchedulersImpl): BaseSchedulers 30 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/progress_load_data.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/scheduler/BaseSchedulers.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util.scheduler 2 | 3 | import io.reactivex.Scheduler 4 | import io.reactivex.android.schedulers.AndroidSchedulers 5 | import io.reactivex.schedulers.Schedulers 6 | import javax.inject.Inject 7 | 8 | /** 9 | * Created by cuongpm on 1/16/19. 10 | */ 11 | 12 | interface BaseSchedulers { 13 | val computation: Scheduler 14 | val io: Scheduler 15 | val newThread: Scheduler 16 | val single: Scheduler 17 | val mainThread: Scheduler 18 | } 19 | 20 | class BaseSchedulersImpl @Inject constructor() : BaseSchedulers { 21 | override val computation: Scheduler = Schedulers.computation() 22 | override val io: Scheduler = Schedulers.io() 23 | override val newThread: Scheduler = Schedulers.newThread() 24 | override val single: Scheduler = Schedulers.single() 25 | override val mainThread: Scheduler = AndroidSchedulers.mainThread() 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/AppUtil.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util 2 | 3 | import android.content.Context 4 | import android.view.View 5 | import android.view.inputmethod.InputMethodManager 6 | import com.youtubedl.OpenForTesting 7 | import javax.inject.Inject 8 | 9 | /** 10 | * Created by cuongpm on 12/22/18. 11 | */ 12 | 13 | @OpenForTesting 14 | class AppUtil @Inject constructor() { 15 | 16 | fun showSoftKeyboard(view: View) { 17 | if (view.requestFocus()) { 18 | val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 19 | imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) 20 | } 21 | } 22 | 23 | fun hideSoftKeyboard(view: View) { 24 | val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 25 | imm.hideSoftInputFromWindow(view.windowToken, InputMethodManager.HIDE_IMPLICIT_ONLY) 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_video_gray_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/component/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.component 2 | 3 | import com.youtubedl.DLApplication 4 | import com.youtubedl.di.module.* 5 | import dagger.BindsInstance 6 | import dagger.Component 7 | import dagger.android.AndroidInjector 8 | import dagger.android.support.AndroidSupportInjectionModule 9 | import javax.inject.Singleton 10 | 11 | /** 12 | * Created by cuongpm on 12/6/18. 13 | */ 14 | 15 | @Singleton 16 | @Component( 17 | modules = [AndroidSupportInjectionModule::class, AppModule::class, ActivityBindingModule::class, UtilModule::class, 18 | DatabaseModule::class, NetworkModule::class, RepositoryModule::class, ViewModelModule::class] 19 | ) 20 | interface AppComponent : AndroidInjector { 21 | 22 | @Component.Builder 23 | interface Builder { 24 | @BindsInstance 25 | fun application(application: DLApplication): Builder 26 | 27 | fun build(): AppComponent 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/adapter/MainAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.adapter 2 | 3 | import android.support.v4.app.Fragment 4 | import android.support.v4.app.FragmentManager 5 | import android.support.v4.app.FragmentPagerAdapter 6 | import com.youtubedl.util.fragment.FragmentFactory 7 | 8 | /** 9 | * Created by cuongpm on 12/9/18. 10 | */ 11 | 12 | class MainAdapter constructor( 13 | fm: FragmentManager, 14 | private val fragmentFactory: FragmentFactory 15 | ) : FragmentPagerAdapter(fm) { 16 | 17 | override fun getItem(position: Int): Fragment { 18 | return when (position) { 19 | 0 -> fragmentFactory.createBrowserFragment() 20 | 1 -> fragmentFactory.createProgressFragment() 21 | 2 -> fragmentFactory.createVideoFragment() 22 | else -> fragmentFactory.createSettingsFragment() 23 | } 24 | } 25 | 26 | override fun getCount(): Int { 27 | return 4 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/ViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import android.arch.lifecycle.ViewModelProvider 5 | import javax.inject.Inject 6 | import javax.inject.Provider 7 | import javax.inject.Singleton 8 | 9 | /** 10 | * Created by cuongpm on 12/6/18. 11 | */ 12 | 13 | @Singleton 14 | class ViewModelFactory @Inject constructor( 15 | private val creators: Map, 16 | @JvmSuppressWildcards Provider> 17 | ) : ViewModelProvider.Factory { 18 | 19 | @Suppress("unchecked_cast") 20 | override fun create(modelClass: Class): T { 21 | val creator = creators[modelClass as Class] 22 | ?: creators.entries.firstOrNull { (c, _) -> modelClass.isAssignableFrom(c) }?.value 23 | ?: throw IllegalArgumentException("Unknown model class $modelClass") 24 | 25 | return creator.get() as T 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/ui/main/settings/SettingsViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.settings 2 | 3 | import android.arch.core.executor.testing.InstantTaskExecutorRule 4 | import org.junit.Assert.assertNull 5 | import org.junit.Before 6 | import org.junit.Rule 7 | import org.junit.Test 8 | 9 | /** 10 | * Created by cuongpm on 1/14/19. 11 | */ 12 | 13 | class SettingsViewModelTest { 14 | 15 | @get:Rule 16 | var instantExecutorRule = InstantTaskExecutorRule() 17 | 18 | private lateinit var viewModel: SettingsViewModel 19 | 20 | @Before 21 | fun setup() { 22 | viewModel = SettingsViewModel() 23 | } 24 | 25 | @Test 26 | fun `test clear cookies`() { 27 | viewModel.clearCookies() 28 | assertNull(viewModel.clearCookiesEvent.value) 29 | } 30 | 31 | @Test 32 | fun `test open video folder`() { 33 | viewModel.openVideoFolder() 34 | assertNull(viewModel.openVideoFolderEvent.value) 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/room/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local.room 2 | 3 | import android.arch.persistence.room.Database 4 | import android.arch.persistence.room.RoomDatabase 5 | import com.youtubedl.data.local.room.dao.ConfigDao 6 | import com.youtubedl.data.local.room.dao.ProgressDao 7 | import com.youtubedl.data.local.room.dao.VideoDao 8 | import com.youtubedl.data.local.room.entity.PageInfo 9 | import com.youtubedl.data.local.room.entity.ProgressInfo 10 | import com.youtubedl.data.local.room.entity.SupportedPage 11 | import com.youtubedl.data.local.room.entity.VideoInfo 12 | 13 | /** 14 | * Created by cuongpm on 12/8/18. 15 | */ 16 | 17 | @Database(entities = [PageInfo::class, SupportedPage::class, VideoInfo::class, ProgressInfo::class], version = 1) 18 | abstract class AppDatabase : RoomDatabase() { 19 | 20 | abstract fun configDao(): ConfigDao 21 | 22 | abstract fun videoDao(): VideoDao 23 | 24 | abstract fun progressDao(): ProgressDao 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/room/dao/ConfigDao.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local.room.dao 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 com.youtubedl.data.local.room.entity.PageInfo 8 | import com.youtubedl.data.local.room.entity.SupportedPage 9 | import io.reactivex.Maybe 10 | 11 | /** 12 | * Created by cuongpm on 12/8/18. 13 | */ 14 | 15 | @Dao 16 | interface ConfigDao { 17 | 18 | @Query("SELECT * FROM PageInfo") 19 | fun getAllTopPages(): Maybe> 20 | 21 | @Query("SELECT * FROM SupportedPage") 22 | fun getSupportedPages(): Maybe> 23 | 24 | @Insert(onConflict = OnConflictStrategy.REPLACE) 25 | fun insertPage(pageInfo: PageInfo) 26 | 27 | @Insert(onConflict = OnConflictStrategy.REPLACE) 28 | fun insertSupportedPage(supportedPage: SupportedPage) 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/room/entity/PageInfo.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local.room.entity 2 | 3 | import android.arch.persistence.room.ColumnInfo 4 | import android.arch.persistence.room.Entity 5 | import android.arch.persistence.room.PrimaryKey 6 | import com.google.gson.annotations.Expose 7 | import com.google.gson.annotations.SerializedName 8 | import java.util.* 9 | 10 | /** 11 | * Created by cuongpm on 12/16/18. 12 | */ 13 | 14 | @Entity(tableName = "PageInfo") 15 | data class PageInfo constructor( 16 | @PrimaryKey 17 | @ColumnInfo(name = "id") 18 | var id: String = UUID.randomUUID().toString(), 19 | 20 | @ColumnInfo(name = "name") 21 | @SerializedName("name") 22 | @Expose 23 | var name: String = "", 24 | 25 | @ColumnInfo(name = "link") 26 | @SerializedName("link") 27 | @Expose 28 | var link: String = "", 29 | 30 | @ColumnInfo(name = "icon") 31 | @SerializedName("icon") 32 | @Expose 33 | var icon: String = "" 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/dialog/DownloadVideoDialog.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.dialog 2 | 3 | import android.app.Activity 4 | import android.support.design.widget.BottomSheetDialog 5 | import com.youtubedl.databinding.DialogDownloadVideoBinding 6 | 7 | /** 8 | * Created by cuongpm on 12/23/18. 9 | */ 10 | 11 | fun showDownloadVideoDialog(activity: Activity, downloadVideoListener: DownloadVideoListener) { 12 | val bottomSheetDialog = BottomSheetDialog(activity) 13 | val binding = DialogDownloadVideoBinding.inflate(activity.layoutInflater, null, false).apply { 14 | this.listener = downloadVideoListener 15 | this.dialog = bottomSheetDialog 16 | } 17 | bottomSheetDialog.setContentView(binding.root) 18 | bottomSheetDialog.show() 19 | } 20 | 21 | interface DownloadVideoListener { 22 | fun onPreviewVideo(dialog: BottomSheetDialog) 23 | fun onDownloadVideo(dialog: BottomSheetDialog) 24 | fun onCancel(dialog: BottomSheetDialog) 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/ext/ActivityExt.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util.ext 2 | 3 | import android.os.Bundle 4 | import android.support.v4.app.Fragment 5 | import android.support.v7.app.AppCompatActivity 6 | 7 | /** 8 | * Created by cuongpm on 12/6/18. 9 | */ 10 | 11 | inline fun AppCompatActivity.addFragment(containerViewId: Int, f: () -> Fragment): Fragment? { 12 | return f().apply { supportFragmentManager?.beginTransaction()?.add(containerViewId, this)?.commit() } 13 | } 14 | 15 | inline fun AppCompatActivity.addFragment(containerViewId: Int, bundle: Bundle, f: () -> Fragment): Fragment? { 16 | return f().apply { 17 | arguments = bundle 18 | supportFragmentManager?.beginTransaction()?.add(containerViewId, this)?.commit() 19 | } 20 | } 21 | 22 | inline fun AppCompatActivity.replaceFragment(containerViewId: Int, f: () -> Fragment): Fragment? { 23 | return f().apply { supportFragmentManager?.beginTransaction()?.replace(containerViewId, this)?.commit() } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/ProgressLocalDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local 2 | 3 | import com.youtubedl.data.local.room.dao.ProgressDao 4 | import com.youtubedl.data.local.room.entity.ProgressInfo 5 | import com.youtubedl.data.repository.ProgressRepository 6 | import io.reactivex.Flowable 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | /** 11 | * Created by cuongpm on 1/15/19. 12 | */ 13 | 14 | @Singleton 15 | class ProgressLocalDataSource @Inject constructor( 16 | private val progressDao: ProgressDao 17 | ) : ProgressRepository { 18 | 19 | override fun getProgressInfos(): Flowable> { 20 | return progressDao.getProgressInfos() 21 | } 22 | 23 | override fun saveProgressInfo(progressInfo: ProgressInfo) { 24 | progressDao.insertProgressInfo(progressInfo) 25 | } 26 | 27 | override fun deleteProgressInfo(progressInfo: ProgressInfo) { 28 | progressDao.deleteProgressInfo(progressInfo) 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/DLApplication.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl 2 | 3 | import android.content.Context 4 | import android.support.multidex.MultiDex 5 | import com.facebook.stetho.Stetho 6 | import com.youtubedl.di.component.DaggerAppComponent 7 | import dagger.android.AndroidInjector 8 | import dagger.android.DaggerApplication 9 | 10 | /** 11 | * Created by cuongpm on 12/6/18. 12 | */ 13 | 14 | open class DLApplication : DaggerApplication() { 15 | 16 | private lateinit var androidInjector: AndroidInjector 17 | 18 | override fun onCreate() { 19 | super.onCreate() 20 | 21 | // Initialize Stetho 22 | Stetho.initializeWithDefaults(this) 23 | } 24 | 25 | override fun attachBaseContext(base: Context?) { 26 | super.attachBaseContext(base) 27 | MultiDex.install(this) 28 | 29 | androidInjector = DaggerAppComponent.builder().application(this).build() 30 | } 31 | 32 | public override fun applicationInjector(): AndroidInjector = androidInjector 33 | } 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/androidTest/java/util/rule/InjectedActivityTestRule.kt: -------------------------------------------------------------------------------- 1 | package util.rule 2 | 3 | import android.app.Activity 4 | import android.support.test.InstrumentationRegistry 5 | import android.support.test.espresso.intent.rule.IntentsTestRule 6 | import dagger.android.AndroidInjector 7 | import util.HasTestInjectors 8 | 9 | /** 10 | * Created by cuongpm on 1/29/19. 11 | */ 12 | 13 | 14 | class InjectedActivityTestRule( 15 | activityClass: Class, 16 | private val activityInjector: (T) -> Unit 17 | ) : IntentsTestRule( 18 | activityClass, 19 | false, 20 | false 21 | ) { 22 | 23 | override fun beforeActivityLaunched() { 24 | super.beforeActivityLaunched() 25 | 26 | setActivityInjector() 27 | } 28 | 29 | private fun setActivityInjector() { 30 | val testApp = InstrumentationRegistry.getTargetContext().applicationContext as HasTestInjectors 31 | testApp.activityInjector = AndroidInjector { 32 | @Suppress("UNCHECKED_CAST") 33 | activityInjector(it as T) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/SystemUtil.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import android.webkit.CookieManager 6 | import android.webkit.CookieSyncManager 7 | import com.youtubedl.OpenForTesting 8 | import javax.inject.Inject 9 | 10 | /** 11 | * Created by cuongpm on 1/13/19. 12 | */ 13 | 14 | @OpenForTesting 15 | class SystemUtil @Inject constructor() { 16 | 17 | fun clearCookies(context: Context?) { 18 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { 19 | CookieManager.getInstance().removeAllCookies(null) 20 | CookieManager.getInstance().flush() 21 | } else { 22 | val cookieSyncMngr = CookieSyncManager.createInstance(context) 23 | cookieSyncMngr.startSync() 24 | val cookieManager = CookieManager.getInstance() 25 | cookieManager.removeAllCookie() 26 | cookieManager.removeSessionCookie() 27 | cookieSyncMngr.stopSync() 28 | cookieSyncMngr.sync() 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/fragment/FragmentFactory.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util.fragment 2 | 3 | import android.support.v4.app.Fragment 4 | import com.youtubedl.ui.main.home.BrowserFragment 5 | import com.youtubedl.ui.main.progress.ProgressFragment 6 | import com.youtubedl.ui.main.settings.SettingsFragment 7 | import com.youtubedl.ui.main.video.VideoFragment 8 | import javax.inject.Inject 9 | 10 | /** 11 | * Created by cuongpm on 2/13/19. 12 | */ 13 | 14 | interface FragmentFactory { 15 | fun createBrowserFragment(): Fragment 16 | fun createProgressFragment(): Fragment 17 | fun createVideoFragment(): Fragment 18 | fun createSettingsFragment(): Fragment 19 | } 20 | 21 | class FragmentFactoryImpl @Inject constructor() : FragmentFactory { 22 | override fun createBrowserFragment() = BrowserFragment.newInstance() 23 | 24 | override fun createProgressFragment() = ProgressFragment.newInstance() 25 | 26 | override fun createVideoFragment() = VideoFragment.newInstance() 27 | 28 | override fun createSettingsFragment() = SettingsFragment.newInstance() 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/module/UtilModule.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.module 2 | 3 | import android.app.Application 4 | import android.app.DownloadManager 5 | import android.content.Context 6 | import com.youtubedl.util.AppUtil 7 | import com.youtubedl.util.FileUtil 8 | import com.youtubedl.util.IntentUtil 9 | import com.youtubedl.util.SystemUtil 10 | import dagger.Module 11 | import dagger.Provides 12 | import javax.inject.Singleton 13 | 14 | /** 15 | * Created by cuongpm on 1/13/19. 16 | */ 17 | 18 | @Module 19 | class UtilModule { 20 | 21 | @Singleton 22 | @Provides 23 | fun bindDownloadManager(application: Application): DownloadManager = 24 | application.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager 25 | 26 | @Singleton 27 | @Provides 28 | fun bindFileUtil() = FileUtil() 29 | 30 | @Singleton 31 | @Provides 32 | fun bindSystemUtil() = SystemUtil() 33 | 34 | @Singleton 35 | @Provides 36 | fun bindIntentUtil() = IntentUtil() 37 | 38 | @Singleton 39 | @Provides 40 | fun bindAppUtil() = AppUtil() 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/module/ActivityBindingModule.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.module 2 | 3 | import com.youtubedl.di.ActivityScoped 4 | import com.youtubedl.ui.main.home.MainActivity 5 | import com.youtubedl.di.module.activity.MainModule 6 | import com.youtubedl.ui.main.player.VideoPlayerActivity 7 | import com.youtubedl.di.module.activity.VideoPlayerModule 8 | import com.youtubedl.ui.main.splash.SplashActivity 9 | import dagger.Module 10 | import dagger.android.ContributesAndroidInjector 11 | 12 | /** 13 | * Created by cuongpm on 12/6/18. 14 | */ 15 | 16 | @Module 17 | internal abstract class ActivityBindingModule { 18 | 19 | @ActivityScoped 20 | @ContributesAndroidInjector 21 | internal abstract fun bindSplashActivity(): SplashActivity 22 | 23 | @ActivityScoped 24 | @ContributesAndroidInjector(modules = [MainModule::class]) 25 | internal abstract fun bindMainActivity(): MainActivity 26 | 27 | @ActivityScoped 28 | @ContributesAndroidInjector(modules = [VideoPlayerModule::class]) 29 | internal abstract fun bindVideoPlayerActivity(): VideoPlayerActivity 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/room/entity/ProgressInfo.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local.room.entity 2 | 3 | import android.arch.persistence.room.Entity 4 | import android.arch.persistence.room.PrimaryKey 5 | import android.arch.persistence.room.TypeConverters 6 | import com.youtubedl.util.FileUtil.Companion.getFileSize 7 | import com.youtubedl.util.RoomConverter 8 | import java.util.* 9 | 10 | /** 11 | * Created by cuongpm on 1/8/19. 12 | */ 13 | 14 | @Entity(tableName = "ProgressInfo") 15 | @TypeConverters(RoomConverter::class) 16 | data class ProgressInfo constructor( 17 | @PrimaryKey 18 | var id: String = UUID.randomUUID().toString(), 19 | 20 | var downloadId: Long = 0, 21 | 22 | @TypeConverters(RoomConverter::class) 23 | var videoInfo: VideoInfo, 24 | 25 | var bytesDownloaded: Int = 0, 26 | 27 | var bytesTotal: Int = 0 28 | ) { 29 | 30 | var progress: Int = 0 31 | get() = (bytesDownloaded * 100f / bytesTotal).toInt() 32 | 33 | var progressSize: String = "" 34 | get() = getFileSize(bytesDownloaded.toDouble()) + "/" + getFileSize(bytesTotal.toDouble()) 35 | 36 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Cuong Pham 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_bottom_bar.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 10 | 16 | 22 | 23 | 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/player/VideoPlayerViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.player 2 | 3 | import android.databinding.ObservableField 4 | import com.youtubedl.OpenForTesting 5 | import com.youtubedl.ui.main.base.BaseViewModel 6 | import javax.inject.Inject 7 | 8 | /** 9 | * Created by cuongpm on 1/6/19. 10 | */ 11 | 12 | @OpenForTesting 13 | class VideoPlayerViewModel @Inject constructor() : BaseViewModel() { 14 | 15 | val videoName = ObservableField("") 16 | val videoUrl = ObservableField("") 17 | val currentTime = ObservableField("") 18 | val totalTime = ObservableField("") 19 | 20 | var isVolumeOn = true 21 | 22 | override fun start() { 23 | } 24 | 25 | override fun stop() { 26 | } 27 | 28 | fun pressPrev() { 29 | } 30 | 31 | fun pressPauseOrPlay() { 32 | } 33 | 34 | fun pressNext() { 35 | } 36 | 37 | fun getVolume(): Float { 38 | val amount = if (isVolumeOn) 100 else 0 39 | val max = 100.0 40 | val numerator: Double = if (max - amount > 0) Math.log((max - amount)) else 0.0 41 | return (1 - numerator / Math.log(max)).toFloat() 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/module/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.module 2 | 3 | import android.arch.persistence.room.Room 4 | import com.youtubedl.DLApplication 5 | import com.youtubedl.data.local.room.AppDatabase 6 | import com.youtubedl.data.local.room.dao.ConfigDao 7 | import com.youtubedl.data.local.room.dao.ProgressDao 8 | import com.youtubedl.data.local.room.dao.VideoDao 9 | import dagger.Module 10 | import dagger.Provides 11 | import javax.inject.Singleton 12 | 13 | /** 14 | * Created by cuongpm on 12/8/18. 15 | */ 16 | 17 | @Module 18 | class DatabaseModule { 19 | 20 | @Singleton 21 | @Provides 22 | fun provideDatabase(application: DLApplication): AppDatabase { 23 | return Room.databaseBuilder(application, AppDatabase::class.java, "dl.db").build() 24 | } 25 | 26 | @Singleton 27 | @Provides 28 | fun provideConfigDao(database: AppDatabase): ConfigDao = database.configDao() 29 | 30 | @Singleton 31 | @Provides 32 | fun provideCommentDao(database: AppDatabase): VideoDao = database.videoDao() 33 | 34 | @Singleton 35 | @Provides 36 | fun provideProgressDao(database: AppDatabase): ProgressDao = database.progressDao() 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/repository/ProgressRepository.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.repository 2 | 3 | import com.youtubedl.data.local.room.entity.ProgressInfo 4 | import com.youtubedl.di.qualifier.LocalData 5 | import io.reactivex.Flowable 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | /** 10 | * Created by cuongpm on 1/15/19. 11 | */ 12 | 13 | interface ProgressRepository { 14 | 15 | fun getProgressInfos(): Flowable> 16 | 17 | fun saveProgressInfo(progressInfo: ProgressInfo) 18 | 19 | fun deleteProgressInfo(progressInfo: ProgressInfo) 20 | } 21 | 22 | @Singleton 23 | class ProgressRepositoryImpl @Inject constructor( 24 | @LocalData private val localDataSource: ProgressRepository 25 | ) : ProgressRepository { 26 | 27 | override fun getProgressInfos(): Flowable> { 28 | return localDataSource.getProgressInfos() 29 | } 30 | 31 | override fun saveProgressInfo(progressInfo: ProgressInfo) { 32 | localDataSource.saveProgressInfo(progressInfo) 33 | } 34 | 35 | override fun deleteProgressInfo(progressInfo: ProgressInfo) { 36 | localDataSource.deleteProgressInfo(progressInfo) 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/binding/WebViewBinding.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.binding 2 | 3 | import android.databinding.BindingAdapter 4 | import android.webkit.WebChromeClient 5 | import android.webkit.WebView 6 | import android.webkit.WebViewClient 7 | 8 | /** 9 | * Created by cuongpm on 12/19/18. 10 | */ 11 | 12 | object WebViewBinding { 13 | 14 | @BindingAdapter("app:loadUrl") 15 | @JvmStatic 16 | fun WebView.loadUrl(url: String?) { 17 | url?.let { if (url.isNotEmpty()) loadUrl(it) } 18 | } 19 | 20 | @BindingAdapter("app:javaScriptEnabled") 21 | @JvmStatic 22 | fun WebView.javaScriptEnabled(isEnabled: Boolean?) { 23 | isEnabled?.let { settings.javaScriptEnabled = it } 24 | } 25 | 26 | @BindingAdapter("app:addJavascriptInterface") 27 | @JvmStatic 28 | fun WebView.addJavascriptInterface(name: String?) { 29 | name?.let { addJavascriptInterface(context, it) } 30 | } 31 | 32 | @BindingAdapter("app:webViewClient") 33 | @JvmStatic 34 | fun WebView.webViewClient(webViewClient: WebViewClient?) { 35 | webViewClient?.let { this.webViewClient = it } 36 | } 37 | 38 | @BindingAdapter("app:webChromeClient") 39 | @JvmStatic 40 | fun WebView.webChromeClient(webChromeClient: WebChromeClient?) { 41 | webChromeClient?.let { this.webChromeClient = it } 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/binding/RecyclerViewBinding.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.binding 2 | 3 | import android.databinding.BindingAdapter 4 | import android.support.v7.widget.RecyclerView 5 | import com.youtubedl.data.local.model.LocalVideo 6 | import com.youtubedl.data.local.room.entity.PageInfo 7 | import com.youtubedl.data.local.room.entity.ProgressInfo 8 | import com.youtubedl.ui.component.adapter.ProgressAdapter 9 | import com.youtubedl.ui.component.adapter.TopPageAdapter 10 | import com.youtubedl.ui.component.adapter.VideoAdapter 11 | 12 | /** 13 | * Created by cuongpm on 12/9/18. 14 | */ 15 | 16 | object RecyclerViewBinding { 17 | 18 | @BindingAdapter("app:items") 19 | @JvmStatic 20 | fun RecyclerView.setTopPages(items: List) { 21 | with(adapter as TopPageAdapter?) { 22 | this?.let { setData(items) } 23 | } 24 | } 25 | 26 | @BindingAdapter("app:items") 27 | @JvmStatic 28 | fun RecyclerView.setProgressInfos(items: List) { 29 | with(adapter as ProgressAdapter?) { 30 | this?.let { setData(items) } 31 | } 32 | } 33 | 34 | @BindingAdapter("app:items") 35 | @JvmStatic 36 | fun RecyclerView.setVideoInfos(items: List) { 37 | with(adapter as VideoAdapter?) { 38 | this?.let { setData(items) } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/local/room/entity/VideoInfo.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local.room.entity 2 | 3 | import android.arch.persistence.room.ColumnInfo 4 | import android.arch.persistence.room.Entity 5 | import android.arch.persistence.room.PrimaryKey 6 | import com.google.gson.annotations.Expose 7 | import com.google.gson.annotations.SerializedName 8 | import java.util.* 9 | 10 | /** 11 | * Created by cuongpm on 12/16/18. 12 | */ 13 | 14 | @Entity(tableName = "VideoInfo") 15 | data class VideoInfo constructor( 16 | @PrimaryKey 17 | @ColumnInfo(name = "id") 18 | var id: String = UUID.randomUUID().toString(), 19 | 20 | @ColumnInfo(name = "downloadUrl") 21 | @SerializedName("url") 22 | @Expose 23 | var downloadUrl: String = "", 24 | 25 | @ColumnInfo(name = "title") 26 | @SerializedName("title") 27 | @Expose 28 | var title: String = "", 29 | 30 | @ColumnInfo(name = "ext") 31 | @SerializedName("ext") 32 | @Expose 33 | var ext: String = "", 34 | 35 | @ColumnInfo(name = "thumbnail") 36 | @SerializedName("thumbnail") 37 | @Expose 38 | var thumbnail: String = "", 39 | 40 | @ColumnInfo(name = "duration") 41 | @SerializedName("duration") 42 | @Expose 43 | var duration: Int = 0, 44 | 45 | @ColumnInfo(name = "originalUrl") 46 | var originalUrl: String = "" 47 | ) { 48 | 49 | val name 50 | get() = "$title.$ext" 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/SingleLiveEvent.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util 2 | 3 | import android.arch.lifecycle.LifecycleOwner 4 | import android.arch.lifecycle.MutableLiveData 5 | import android.arch.lifecycle.Observer 6 | import android.support.annotation.MainThread 7 | import android.util.Log 8 | import java.util.concurrent.atomic.AtomicBoolean 9 | 10 | /** 11 | * Created by cuongpm on 12/6/18. 12 | */ 13 | 14 | class SingleLiveEvent : MutableLiveData() { 15 | 16 | private val pending = AtomicBoolean(false) 17 | 18 | @MainThread 19 | override fun observe(owner: LifecycleOwner, observer: Observer) { 20 | 21 | if (hasActiveObservers()) { 22 | Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") 23 | } 24 | 25 | // Observe the internal MutableLiveData 26 | super.observe(owner, Observer { t -> 27 | if (pending.compareAndSet(true, false)) { 28 | observer.onChanged(t) 29 | } 30 | }) 31 | } 32 | 33 | @MainThread 34 | override fun setValue(t: T?) { 35 | pending.set(true) 36 | super.setValue(t) 37 | } 38 | 39 | /** 40 | * Used for cases where T is Void, to make calls cleaner. 41 | */ 42 | @MainThread 43 | fun call() { 44 | value = null 45 | } 46 | 47 | companion object { 48 | private const val TAG = "SingleLiveEvent" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/splash/SplashActivity.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.splash 2 | 3 | import android.arch.lifecycle.ViewModelProvider 4 | import android.arch.lifecycle.ViewModelProviders 5 | import android.content.Intent 6 | import android.databinding.DataBindingUtil 7 | import android.os.Bundle 8 | import android.os.Handler 9 | import com.youtubedl.OpenForTesting 10 | import com.youtubedl.R 11 | import com.youtubedl.databinding.ActivitySplashBinding 12 | import com.youtubedl.ui.main.base.BaseActivity 13 | import com.youtubedl.ui.main.home.MainActivity 14 | import javax.inject.Inject 15 | 16 | /** 17 | * Created by cuongpm on 12/6/18. 18 | */ 19 | 20 | @OpenForTesting 21 | class SplashActivity : BaseActivity() { 22 | 23 | @Inject 24 | lateinit var viewModelFactory: ViewModelProvider.Factory 25 | 26 | private lateinit var splashViewModel: SplashViewModel 27 | 28 | private lateinit var dataBinding: ActivitySplashBinding 29 | 30 | override fun onCreate(savedInstanceState: Bundle?) { 31 | super.onCreate(savedInstanceState) 32 | 33 | splashViewModel = ViewModelProviders.of(this, viewModelFactory).get(SplashViewModel::class.java) 34 | 35 | dataBinding = DataBindingUtil.setContentView(this, R.layout.activity_splash) 36 | dataBinding.viewModel = splashViewModel 37 | 38 | Handler().postDelayed({ 39 | startActivity(Intent(this@SplashActivity, MainActivity::class.java)) 40 | finish() 41 | }, 3000) 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/data/remote/VideoRemoteDataSourceTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.remote 2 | 3 | import com.nhaarman.mockito_kotlin.doReturn 4 | import com.nhaarman.mockito_kotlin.mock 5 | import com.youtubedl.data.local.model.VideoInfoWrapper 6 | import com.youtubedl.data.local.room.entity.VideoInfo 7 | import com.youtubedl.data.remote.service.VideoService 8 | import io.reactivex.Flowable 9 | import org.junit.Before 10 | import org.junit.Test 11 | 12 | /** 13 | * Created by cuongpm on 1/14/19. 14 | */ 15 | 16 | class VideoRemoteDataSourceTest { 17 | 18 | private lateinit var videoService: VideoService 19 | 20 | private lateinit var videoRemoteDataSource: VideoRemoteDataSource 21 | 22 | private lateinit var videoInfoWrapper: VideoInfoWrapper 23 | 24 | private lateinit var videoInfo: VideoInfo 25 | 26 | private lateinit var url: String 27 | 28 | @Before 29 | fun setup() { 30 | videoService = mock() 31 | videoRemoteDataSource = VideoRemoteDataSource(videoService) 32 | 33 | url = "videoUrl" 34 | videoInfo = VideoInfo(title = "title", originalUrl = "originalUrl") 35 | videoInfoWrapper = VideoInfoWrapper(videoInfo) 36 | } 37 | 38 | @Test 39 | fun `test get video info`() { 40 | doReturn(Flowable.just(videoInfoWrapper)).`when`(videoService).getVideoInfo(url) 41 | 42 | videoRemoteDataSource.getVideoInfo(url).test() 43 | .assertNoErrors() 44 | .assertValue { it == videoInfo } 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/data/local/VideoLocalDataSourceTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local 2 | 3 | import com.nhaarman.mockito_kotlin.doReturn 4 | import com.nhaarman.mockito_kotlin.mock 5 | import com.nhaarman.mockito_kotlin.verify 6 | import com.youtubedl.data.local.room.dao.VideoDao 7 | import com.youtubedl.data.local.room.entity.VideoInfo 8 | import io.reactivex.Maybe 9 | import org.junit.Before 10 | import org.junit.Test 11 | 12 | /** 13 | * Created by cuongpm on 1/14/19. 14 | */ 15 | 16 | class VideoLocalDataSourceTest { 17 | 18 | private lateinit var videoDao: VideoDao 19 | 20 | private lateinit var videoLocalDataSource: VideoLocalDataSource 21 | 22 | private lateinit var videoInfo: VideoInfo 23 | 24 | private lateinit var url: String 25 | 26 | 27 | @Before 28 | fun setup() { 29 | videoDao = mock() 30 | videoLocalDataSource = VideoLocalDataSource(videoDao) 31 | 32 | url = "videoUrl" 33 | videoInfo = VideoInfo(title = "title", originalUrl = "originalUrl") 34 | } 35 | 36 | @Test 37 | fun `test get video info`() { 38 | doReturn(Maybe.just(videoInfo)).`when`(videoDao).getVideoById(url) 39 | 40 | videoLocalDataSource.getVideoInfo(url).test() 41 | .assertNoErrors() 42 | .assertValue { it == videoInfo } 43 | } 44 | 45 | @Test 46 | fun `test save video info`() { 47 | videoLocalDataSource.saveVideoInfo(videoInfo) 48 | verify(videoDao).insertVideo(videoInfo) 49 | } 50 | 51 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/util/TestApplication.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import android.app.Activity 4 | import android.app.Application 5 | import android.app.Service 6 | import android.content.BroadcastReceiver 7 | import android.content.ContentProvider 8 | import dagger.android.* 9 | 10 | /** 11 | * Created by cuongpm on 1/29/19. 12 | */ 13 | 14 | interface HasTestInjectors { 15 | var activityInjector: AndroidInjector 16 | var serviceInjector: AndroidInjector 17 | var broadcastReceiverInjector: AndroidInjector 18 | var contentProviderInjector: AndroidInjector 19 | } 20 | 21 | open class TestApplication : Application(), HasTestInjectors, HasActivityInjector, HasBroadcastReceiverInjector, 22 | HasServiceInjector, HasContentProviderInjector { 23 | 24 | override lateinit var activityInjector: AndroidInjector 25 | override lateinit var serviceInjector: AndroidInjector 26 | override lateinit var broadcastReceiverInjector: AndroidInjector 27 | override lateinit var contentProviderInjector: AndroidInjector 28 | 29 | override fun activityInjector(): AndroidInjector = activityInjector 30 | 31 | override fun broadcastReceiverInjector(): AndroidInjector = broadcastReceiverInjector 32 | 33 | override fun serviceInjector(): AndroidInjector = serviceInjector 34 | 35 | override fun contentProviderInjector(): AndroidInjector = contentProviderInjector 36 | 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/module/activity/MainModule.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.module.activity 2 | 3 | import android.app.Activity 4 | import com.youtubedl.di.ActivityScoped 5 | import com.youtubedl.di.FragmentScoped 6 | import com.youtubedl.ui.main.home.BrowserFragment 7 | import com.youtubedl.ui.main.home.MainActivity 8 | import com.youtubedl.ui.main.progress.ProgressFragment 9 | import com.youtubedl.ui.main.settings.SettingsFragment 10 | import com.youtubedl.ui.main.video.VideoFragment 11 | import com.youtubedl.util.fragment.FragmentFactory 12 | import com.youtubedl.util.fragment.FragmentFactoryImpl 13 | import dagger.Binds 14 | import dagger.Module 15 | import dagger.android.ContributesAndroidInjector 16 | 17 | /** 18 | * Created by cuongpm on 12/9/18. 19 | */ 20 | 21 | @Module 22 | abstract class MainModule { 23 | 24 | @FragmentScoped 25 | @ContributesAndroidInjector 26 | abstract fun bindBrowserFragment(): BrowserFragment 27 | 28 | @FragmentScoped 29 | @ContributesAndroidInjector 30 | abstract fun bindProgressFragment(): ProgressFragment 31 | 32 | @FragmentScoped 33 | @ContributesAndroidInjector 34 | abstract fun bindVideoFragment(): VideoFragment 35 | 36 | @FragmentScoped 37 | @ContributesAndroidInjector 38 | abstract fun bindSettingsFragment(): SettingsFragment 39 | 40 | @ActivityScoped 41 | @Binds 42 | abstract fun bindMainActivity(mainActivity: MainActivity): Activity 43 | 44 | @ActivityScoped 45 | @Binds 46 | abstract fun bindFragmentFactory(fragmentFactory: FragmentFactoryImpl): FragmentFactory 47 | } -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/data/local/ConfigLocalDataSourceTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local 2 | 3 | import com.nhaarman.mockito_kotlin.doReturn 4 | import com.nhaarman.mockito_kotlin.mock 5 | import com.nhaarman.mockito_kotlin.verify 6 | import com.youtubedl.data.local.room.dao.ConfigDao 7 | import com.youtubedl.data.local.room.entity.SupportedPage 8 | import io.reactivex.Maybe 9 | import org.junit.Before 10 | import org.junit.Test 11 | 12 | /** 13 | * Created by cuongpm on 2/17/19. 14 | */ 15 | class ConfigLocalDataSourceTest { 16 | 17 | private lateinit var configDao: ConfigDao 18 | 19 | private lateinit var configLocalDataSource: ConfigLocalDataSource 20 | 21 | private lateinit var supportedPages: List 22 | 23 | @Before 24 | fun setup() { 25 | configDao = mock() 26 | configLocalDataSource = ConfigLocalDataSource(configDao) 27 | supportedPages = listOf(SupportedPage(id = "id1", name = "name1"), SupportedPage(id = "id2", name = "name2")) 28 | } 29 | 30 | @Test 31 | fun `test get supported pages`() { 32 | doReturn(Maybe.just(supportedPages)).`when`(configDao).getSupportedPages() 33 | 34 | configLocalDataSource.getSupportedPages() 35 | .test() 36 | .assertNoErrors() 37 | .assertValue { it == supportedPages } 38 | } 39 | 40 | @Test 41 | fun `test save supported pages`() { 42 | configLocalDataSource.saveSupportedPages(supportedPages) 43 | verify(configDao).insertSupportedPage(supportedPages[0]) 44 | verify(configDao).insertSupportedPage(supportedPages[1]) 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/item_suggestion.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 16 | 17 | 18 | 19 | 22 | 23 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/adapter/ProgressAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.adapter 2 | 3 | import android.databinding.DataBindingUtil 4 | import android.support.v7.widget.RecyclerView 5 | import android.view.LayoutInflater 6 | import android.view.ViewGroup 7 | import com.youtubedl.R 8 | import com.youtubedl.data.local.room.entity.ProgressInfo 9 | import com.youtubedl.databinding.ItemProgressBinding 10 | 11 | /** 12 | * Created by cuongpm on 12/23/18. 13 | */ 14 | 15 | class ProgressAdapter( 16 | private var progressInfos: List 17 | ) : RecyclerView.Adapter() { 18 | 19 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProgressViewHolder { 20 | val binding = DataBindingUtil.inflate( 21 | LayoutInflater.from(parent.context), R.layout.item_progress, parent, false 22 | ) 23 | 24 | return ProgressViewHolder(binding) 25 | } 26 | 27 | override fun getItemCount() = progressInfos.size 28 | 29 | override fun onBindViewHolder(holder: ProgressViewHolder, position: Int) = holder.bind(progressInfos[position]) 30 | 31 | class ProgressViewHolder(val binding: ItemProgressBinding) : RecyclerView.ViewHolder(binding.root) { 32 | 33 | fun bind(progressInfo: ProgressInfo) { 34 | with(binding) 35 | { 36 | this.progressInfo = progressInfo 37 | executePendingBindings() 38 | } 39 | } 40 | } 41 | 42 | fun setData(progressInfos: List) { 43 | this.progressInfos = progressInfos 44 | notifyDataSetChanged() 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/IntentUtil.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.support.v4.content.FileProvider 7 | import android.widget.Toast 8 | import com.youtubedl.OpenForTesting 9 | import com.youtubedl.R 10 | import java.io.File 11 | import javax.inject.Inject 12 | 13 | /** 14 | * Created by cuongpm on 1/13/19. 15 | */ 16 | 17 | @OpenForTesting 18 | class IntentUtil @Inject constructor() { 19 | 20 | fun openFolder(context: Context?, path: String) { 21 | context?.let { 22 | val intent = Intent(Intent.ACTION_VIEW) 23 | intent.setDataAndType(Uri.parse(path), "resource/*") 24 | 25 | if (intent.resolveActivityInfo(it.packageManager, 0) != null) { 26 | it.startActivity(intent) 27 | } else { 28 | Toast.makeText(it, it.getString(R.string.settings_message_open_folder), Toast.LENGTH_SHORT) 29 | .show() 30 | } 31 | } 32 | } 33 | 34 | fun shareVideo(context: Context, file: File) { 35 | val intent = Intent(Intent.ACTION_SEND) 36 | intent.type = "video/*" 37 | val uri = FileProvider.getUriForFile(context, context.packageName + ".provider", file) 38 | intent.putExtra(Intent.EXTRA_STREAM, uri) 39 | 40 | if (intent.resolveActivityInfo(context.packageManager, 0) != null) { 41 | context.startActivity(Intent.createChooser(intent, "Share via:")) 42 | } else { 43 | Toast.makeText(context, context.getString(R.string.video_share_message), Toast.LENGTH_SHORT).show() 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | // Padding size 5 | 4dp 6 | 8dp 7 | 16dp 8 | 32dp 9 | 40dp 10 | 11 | // Text size 12 | 12sp 13 | 14sp 14 | 16sp 15 | 18sp 16 | 20sp 17 | 22sp 18 | 24sp 19 | 20 | // Splash screen 21 | 50dp 22 | 80dp 23 | 24 | // Home screen 25 | 60dp 26 | 27 | // Browser screen 28 | 45dp 29 | 2dp 30 | 60dp 31 | 3dp 32 | 50dp 33 | 34 | // Progress screen 35 | 60dp 36 | 4dp 37 | 38 | // Video screen 39 | 80dp 40 | 41 | // Player screen 42 | 50dp 43 | 44 | // Settings screen 45 | 2dp 46 | 50dp 47 | 48 | 49 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | youtube-dl-android 3 | Search Google or enter url 4 | 5 | VIDEO 6 | PROGRESS 7 | SETTINGS 8 | 9 | Video found: 10 | Preview Video 11 | Download Video 12 | Cancel 13 | 14 | You have not downloaded any video yet. 15 | Rename 16 | Delete 17 | Share 18 | Rename video 19 | Invalid name. Please try again! 20 | Video name already exists! 21 | You don\'t have any video player app installed on your device 22 | 23 | 24 | You are not downloading any video. 25 | 26 | GENERAL 27 | Videos folder 28 | folder path 29 | Clear browser cookies 30 | You don\'t have any file explorer app installed on your device 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 18 | 19 | 27 | 28 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # youtube-dl-android 2 | 3 | [![CircleCI](https://circleci.com/gh/cuongpm/youtube-dl-android.svg?style=svg)](https://circleci.com/gh/cuongpm/youtube-dl-android) [![Coverage Status](https://img.shields.io/coveralls/github/cuongpm/youtube-dl-android.svg)](https://coveralls.io/github/cuongpm/youtube-dl-android?branch=master) 4 | 5 | 📦 An Android client for youtube-dl: https://github.com/rg3/youtube-dl 6 | 7 | ## Major technologies 8 | 9 | - Language: Kotlin 10 | - Architecture: MVVM 11 | - Android architecture components: ViewModel, LiveData, Room 12 | - Dependency injection: Dagger2 13 | - Network: Retrofit, Okhttp 14 | - Testing: JUnit, Espresso, Mockito 15 | - Data layer with repository pattern and RxJava 16 | - Continuous integration with [CircleCI](https://circleci.com/) 17 | - Test report and coverage with [Coveralls](https://coveralls.io/) 18 | - Run instrumented tests with [Firebase Test Lab](https://firebase.google.com/docs/test-lab/) 19 | 20 | ## Features 21 | 22 | - Download videos from Youtube, Facebook, Twitter, Instagram, Dailymotion, Vimeo and more than [other 1000 sites](http://rg3.github.io/youtube-dl/supportedsites.html) 23 | - Browse videos with the built-in browser 24 | - Download videos with the built-in download manager 25 | - Play videos offline with the built-in player 26 | - Save your favorite videos online and watch them later without downloading them 27 | - Save bookmark and history as a real browser 28 | 29 | ## Screenshots 30 | 31 | 32 | 33 | ## License 34 | 35 | This package is licensed under the MIT license. See [LICENSE](./LICENSE) for details. 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/FileUtil.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Environment 7 | import android.provider.MediaStore 8 | import com.youtubedl.OpenForTesting 9 | import java.io.File 10 | import java.text.DecimalFormat 11 | import javax.inject.Inject 12 | 13 | /** 14 | * Created by cuongpm on 1/9/19. 15 | */ 16 | 17 | @OpenForTesting 18 | class FileUtil @Inject constructor() { 19 | 20 | companion object { 21 | const val FOLDER_NAME = "YoutubeDL" 22 | 23 | fun getFileSize(length: Double): String { 24 | val KiB = 1024 25 | val MiB = 1024 * 1024 26 | val decimalFormat = DecimalFormat("#.##") 27 | return when { 28 | length > MiB -> decimalFormat.format(length / MiB) + " MB" 29 | length > KiB -> decimalFormat.format(length / KiB) + " KB" 30 | else -> decimalFormat.format(length) + " B" 31 | } 32 | } 33 | } 34 | 35 | val folderDir: File 36 | get() = File(Environment.getExternalStorageDirectory(), FOLDER_NAME) 37 | 38 | val listFiles: List 39 | get() { 40 | val files = folderDir.listFiles() 41 | return files?.let { files.toList() } ?: run { arrayListOf() } 42 | } 43 | 44 | fun scanMedia(context: Context, file: File) { 45 | val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) 46 | intent.data = Uri.fromFile(file) 47 | context.sendBroadcast(intent) 48 | } 49 | 50 | fun deleteMedia(context: Context, file: File) { 51 | context.contentResolver.delete( 52 | MediaStore.Video.Media.EXTERNAL_CONTENT_URI, 53 | MediaStore.Video.Media.DATA + "=?", arrayOf(file.absolutePath) 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/data/local/ProgressLocalDataSourceTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.local 2 | 3 | import com.nhaarman.mockito_kotlin.doReturn 4 | import com.nhaarman.mockito_kotlin.mock 5 | import com.nhaarman.mockito_kotlin.verify 6 | import com.youtubedl.data.local.room.dao.ProgressDao 7 | import com.youtubedl.data.local.room.entity.ProgressInfo 8 | import com.youtubedl.data.local.room.entity.VideoInfo 9 | import io.reactivex.Flowable 10 | import org.junit.Before 11 | import org.junit.Test 12 | 13 | /** 14 | * Created by cuongpm on 1/16/19. 15 | */ 16 | 17 | class ProgressLocalDataSourceTest { 18 | 19 | private lateinit var progressDao: ProgressDao 20 | 21 | private lateinit var progressLocalDataSource: ProgressLocalDataSource 22 | 23 | private lateinit var progressInfo: ProgressInfo 24 | 25 | @Before 26 | fun setup() { 27 | progressDao = mock() 28 | progressLocalDataSource = ProgressLocalDataSource(progressDao) 29 | progressInfo = ProgressInfo(id = "id", downloadId = 123, videoInfo = VideoInfo()) 30 | } 31 | 32 | @Test 33 | fun `test get list downloading videos`() { 34 | doReturn(Flowable.just(listOf(progressInfo))).`when`(progressDao).getProgressInfos() 35 | progressLocalDataSource.getProgressInfos() 36 | .test() 37 | .assertNoErrors() 38 | .assertValue { it == listOf(progressInfo) } 39 | verify(progressDao).getProgressInfos() 40 | } 41 | 42 | @Test 43 | fun `test get save downloading video`() { 44 | progressLocalDataSource.saveProgressInfo(progressInfo) 45 | verify(progressDao).insertProgressInfo(progressInfo) 46 | } 47 | 48 | @Test 49 | fun `test delete downloading video`() { 50 | progressLocalDataSource.deleteProgressInfo(progressInfo) 51 | verify(progressDao).deleteProgressInfo(progressInfo) 52 | } 53 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/util/TestHelperActivity.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import android.os.Bundle 4 | import android.support.v4.app.Fragment 5 | import android.support.v7.app.AppCompatActivity 6 | import com.youtubedl.BuildConfig 7 | import dagger.android.AndroidInjector 8 | import dagger.android.support.HasSupportFragmentInjector 9 | import java.util.concurrent.CountDownLatch 10 | import java.util.concurrent.TimeUnit 11 | 12 | /** 13 | * Created by cuongpm on 1/29/19. 14 | */ 15 | 16 | class TestHelperActivity : AppCompatActivity(), HasSupportFragmentInjector { 17 | 18 | lateinit var fragmentInjector: AndroidInjector 19 | 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | if (!BuildConfig.DEBUG) error("This activity should never be called outside test build") 22 | if (intent?.getBooleanExtra(EXTRA_USE_DEFAULT_TOOLBAR_KEY, false) == true) { 23 | // assign explicit theme to this activity which enables windows action bar to test 24 | // tool bar menus and search option. 25 | // setTheme(R.style.BBMTestTheme) 26 | } 27 | super.onCreate(savedInstanceState) 28 | } 29 | 30 | fun attachFragment(fragment: Fragment) { 31 | runBlockingOnUiThread { 32 | supportFragmentManager 33 | .beginTransaction() 34 | .add(android.R.id.content, fragment) 35 | .commitNow() 36 | } 37 | } 38 | 39 | override fun supportFragmentInjector(): AndroidInjector { 40 | return fragmentInjector 41 | } 42 | 43 | fun runBlockingOnUiThread(block: () -> Unit) { 44 | val latch = CountDownLatch(1) 45 | runOnUiThread { 46 | block() 47 | latch.countDown() 48 | } 49 | latch.await(200, TimeUnit.MILLISECONDS) 50 | } 51 | 52 | companion object { 53 | const val EXTRA_USE_DEFAULT_TOOLBAR_KEY = "use_default_toolbar" 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/adapter/VideoAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.adapter 2 | 3 | import android.databinding.DataBindingUtil 4 | import android.support.v7.widget.RecyclerView 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import com.youtubedl.R 9 | import com.youtubedl.data.local.model.LocalVideo 10 | import com.youtubedl.databinding.ItemVideoBinding 11 | 12 | /** 13 | * Created by cuongpm on 12/7/18. 14 | */ 15 | 16 | class VideoAdapter( 17 | private var localVideos: List, 18 | private val videoListener: VideoListener 19 | ) : RecyclerView.Adapter() { 20 | 21 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VideoViewHolder { 22 | val binding = DataBindingUtil.inflate( 23 | LayoutInflater.from(parent.context), R.layout.item_video, parent, false 24 | ) 25 | 26 | return VideoViewHolder(binding) 27 | } 28 | 29 | override fun getItemCount() = localVideos.size 30 | 31 | override fun onBindViewHolder(holder: VideoViewHolder, position: Int) = 32 | holder.bind(localVideos[position], videoListener) 33 | 34 | class VideoViewHolder(val binding: ItemVideoBinding) : RecyclerView.ViewHolder(binding.root) { 35 | 36 | fun bind(localVideo: LocalVideo, videoListener: VideoListener) { 37 | with(binding) 38 | { 39 | this.localVideo = localVideo 40 | this.videoListener = videoListener 41 | executePendingBindings() 42 | } 43 | } 44 | } 45 | 46 | fun setData(localVideos: List) { 47 | this.localVideos = localVideos 48 | notifyDataSetChanged() 49 | } 50 | } 51 | 52 | interface VideoListener { 53 | fun onItemClicked(localVideo: LocalVideo) 54 | fun onMenuClicked(view: View, localVideo: LocalVideo) 55 | } -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/data/repository/ProgressRepositoryImplTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.repository 2 | 3 | import com.nhaarman.mockito_kotlin.doReturn 4 | import com.nhaarman.mockito_kotlin.mock 5 | import com.nhaarman.mockito_kotlin.verify 6 | import com.youtubedl.data.local.ProgressLocalDataSource 7 | import com.youtubedl.data.local.room.entity.ProgressInfo 8 | import com.youtubedl.data.local.room.entity.VideoInfo 9 | import io.reactivex.Flowable 10 | import org.junit.Before 11 | import org.junit.Test 12 | 13 | /** 14 | * Created by cuongpm on 1/16/19. 15 | */ 16 | 17 | class ProgressRepositoryImplTest { 18 | 19 | private lateinit var progressLocalDataSource: ProgressLocalDataSource 20 | 21 | private lateinit var progressRepository: ProgressRepositoryImpl 22 | 23 | private lateinit var progressInfo: ProgressInfo 24 | 25 | @Before 26 | fun setup() { 27 | progressLocalDataSource = mock() 28 | progressRepository = ProgressRepositoryImpl(progressLocalDataSource) 29 | progressInfo = ProgressInfo(id = "id", downloadId = 123, videoInfo = VideoInfo()) 30 | } 31 | 32 | @Test 33 | fun `test get list downloading videos`() { 34 | doReturn(Flowable.just(listOf(progressInfo))).`when`(progressLocalDataSource).getProgressInfos() 35 | progressRepository.getProgressInfos() 36 | .test() 37 | .assertNoErrors() 38 | .assertValue { it == listOf(progressInfo) } 39 | verify(progressLocalDataSource).getProgressInfos() 40 | } 41 | 42 | @Test 43 | fun `test save downloading video`() { 44 | progressRepository.saveProgressInfo(progressInfo) 45 | verify(progressLocalDataSource).saveProgressInfo(progressInfo) 46 | } 47 | 48 | @Test 49 | fun `test delete downloading video`() { 50 | progressRepository.deleteProgressInfo(progressInfo) 51 | verify(progressLocalDataSource).deleteProgressInfo(progressInfo) 52 | } 53 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/dialog/RenameVideoDialog.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.dialog 2 | 3 | import android.content.Context 4 | import android.graphics.Color 5 | import android.support.v7.app.AlertDialog 6 | import android.text.InputType 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import android.view.inputmethod.EditorInfo 10 | import android.widget.EditText 11 | import android.widget.LinearLayout 12 | import com.youtubedl.R 13 | import com.youtubedl.util.AppUtil 14 | 15 | /** 16 | * Created by cuongpm on 1/20/19. 17 | */ 18 | 19 | fun showRenameVideoDialog( 20 | context: Context, 21 | appUtil: AppUtil, 22 | currentName: String, 23 | onClickListener: View.OnClickListener 24 | ) { 25 | val etName = EditText(context).apply { 26 | layoutParams = 27 | ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) 28 | setText(currentName) 29 | setSelection(text.length) 30 | setTextColor(Color.BLACK) 31 | imeOptions = EditorInfo.IME_ACTION_DONE 32 | inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES 33 | setSingleLine() 34 | } 35 | 36 | val layout = LinearLayout(context).apply { 37 | layoutParams = 38 | ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) 39 | orientation = LinearLayout.VERTICAL 40 | setPadding(80, 40, 80, 20) 41 | addView(etName) 42 | } 43 | 44 | appUtil.showSoftKeyboard(etName) 45 | 46 | AlertDialog.Builder(context) 47 | .setTitle(context.getString(R.string.video_rename_title)) 48 | .setView(layout) 49 | .setNegativeButton(context.getString(android.R.string.cancel)) { _, _ -> 50 | appUtil.hideSoftKeyboard(etName) 51 | } 52 | .setPositiveButton(context.getString(android.R.string.ok)) { _, _ -> 53 | appUtil.hideSoftKeyboard(etName) 54 | onClickListener.onClick(etName) 55 | }.show() 56 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 27 | 30 | 31 | 32 | 37 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/adapter/SuggestionAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.adapter 2 | 3 | import android.content.Context 4 | import android.databinding.DataBindingUtil 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import android.widget.ArrayAdapter 9 | import com.youtubedl.data.local.model.Suggestion 10 | import com.youtubedl.databinding.ItemSuggestionBinding 11 | import com.youtubedl.ui.main.home.BrowserViewModel 12 | 13 | /** 14 | * Created by cuongpm on 12/23/18. 15 | */ 16 | 17 | class SuggestionAdapter( 18 | context: Context?, 19 | private var suggestions: List, 20 | private val browserViewModel: BrowserViewModel? 21 | ) : ArrayAdapter(context, 0) { 22 | 23 | override fun getCount() = suggestions.size 24 | 25 | override fun getItem(position: Int) = suggestions[position] 26 | 27 | override fun getItemId(position: Int) = position.toLong() 28 | 29 | override fun getView(position: Int, view: View?, viewGroup: ViewGroup): View { 30 | val binding = if (view == null) { 31 | val inflater = LayoutInflater.from(viewGroup.context) 32 | ItemSuggestionBinding.inflate(inflater, viewGroup, false) 33 | } else { 34 | DataBindingUtil.getBinding(view)!! 35 | } 36 | 37 | val suggestionListener = object : SuggestionListener { 38 | override fun onItemClicked(suggestion: Suggestion) { 39 | browserViewModel?.loadPage(suggestion.content) 40 | } 41 | } 42 | 43 | with(binding) { 44 | this.suggestion = suggestions[position] 45 | this.listener = suggestionListener 46 | executePendingBindings() 47 | } 48 | 49 | return binding.root 50 | } 51 | 52 | fun setData(suggestions: List) { 53 | this.suggestions = suggestions 54 | notifyDataSetChanged() 55 | } 56 | 57 | interface SuggestionListener { 58 | fun onItemClicked(suggestion: Suggestion) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/component/adapter/TopPageAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.component.adapter 2 | 3 | import android.databinding.DataBindingUtil 4 | import android.support.v7.widget.RecyclerView 5 | import android.view.LayoutInflater 6 | import android.view.ViewGroup 7 | import com.youtubedl.R 8 | import com.youtubedl.data.local.room.entity.PageInfo 9 | import com.youtubedl.databinding.ItemTopPageBinding 10 | import com.youtubedl.ui.main.home.BrowserViewModel 11 | 12 | /** 13 | * Created by cuongpm on 12/16/18. 14 | */ 15 | 16 | class TopPageAdapter( 17 | private var pageInfos: List, 18 | private val browserViewModel: BrowserViewModel? 19 | ) : RecyclerView.Adapter() { 20 | 21 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TopPageViewHolder { 22 | val binding = DataBindingUtil.inflate( 23 | LayoutInflater.from(parent.context), R.layout.item_top_page, parent, false 24 | ) 25 | 26 | return TopPageViewHolder(binding) 27 | } 28 | 29 | override fun getItemCount() = pageInfos.size 30 | 31 | override fun onBindViewHolder(holder: TopPageViewHolder, position: Int) = 32 | holder.bind(pageInfos[position], object : TopPagesListener { 33 | override fun onItemClicked(pageInfo: PageInfo) { 34 | browserViewModel?.loadPage(pageInfo.link) 35 | } 36 | }) 37 | 38 | class TopPageViewHolder(val binding: ItemTopPageBinding) : RecyclerView.ViewHolder(binding.root) { 39 | 40 | fun bind(pageInfo: PageInfo, topPagesListener: TopPagesListener) { 41 | with(binding) 42 | { 43 | this.pageInfo = pageInfo 44 | this.listener = topPagesListener 45 | executePendingBindings() 46 | } 47 | } 48 | } 49 | 50 | fun setData(pageInfos: List) { 51 | this.pageInfos = pageInfos 52 | notifyDataSetChanged() 53 | } 54 | 55 | interface TopPagesListener { 56 | fun onItemClicked(pageInfo: PageInfo) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/repository/VideoRepository.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.repository 2 | 3 | import android.support.annotation.VisibleForTesting 4 | import com.youtubedl.data.local.room.entity.VideoInfo 5 | import com.youtubedl.di.qualifier.LocalData 6 | import com.youtubedl.di.qualifier.RemoteData 7 | import io.reactivex.Flowable 8 | import javax.inject.Inject 9 | import javax.inject.Singleton 10 | 11 | /** 12 | * Created by cuongpm on 1/6/19. 13 | */ 14 | 15 | interface VideoRepository { 16 | 17 | fun getVideoInfo(url: String): Flowable 18 | 19 | fun saveVideoInfo(videoInfo: VideoInfo) 20 | } 21 | 22 | @Singleton 23 | class VideoRepositoryImpl @Inject constructor( 24 | @LocalData private val localDataSource: VideoRepository, 25 | @RemoteData private val remoteDataSource: VideoRepository 26 | ) : VideoRepository { 27 | 28 | @VisibleForTesting 29 | internal var cachedVideos: MutableMap = mutableMapOf() 30 | 31 | override fun getVideoInfo(url: String): Flowable { 32 | cachedVideos[url]?.let { return Flowable.just(it) } 33 | 34 | val localVideo = getAndCacheLocalVideo(url) 35 | val remoteVideo = getAndSaveRemoteVideo(url) 36 | return Flowable.concat(localVideo, remoteVideo).take(1) 37 | } 38 | 39 | override fun saveVideoInfo(videoInfo: VideoInfo) { 40 | remoteDataSource.saveVideoInfo(videoInfo) 41 | localDataSource.saveVideoInfo(videoInfo) 42 | cachedVideos[videoInfo.originalUrl] = videoInfo 43 | } 44 | 45 | private fun getAndCacheLocalVideo(url: String): Flowable { 46 | return localDataSource.getVideoInfo(url) 47 | .doOnNext { videoInfo -> 48 | cachedVideos[url] = videoInfo 49 | } 50 | } 51 | 52 | private fun getAndSaveRemoteVideo(url: String): Flowable { 53 | return remoteDataSource.getVideoInfo(url) 54 | .doOnNext { videoInfo -> 55 | videoInfo.originalUrl = url 56 | localDataSource.saveVideoInfo(videoInfo) 57 | cachedVideos[url] = videoInfo 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/module/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.module 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import android.arch.lifecycle.ViewModelProvider 5 | import com.youtubedl.di.ViewModelKey 6 | import com.youtubedl.ui.main.home.BrowserViewModel 7 | import com.youtubedl.ui.main.home.MainViewModel 8 | import com.youtubedl.ui.main.player.VideoPlayerViewModel 9 | import com.youtubedl.ui.main.progress.ProgressViewModel 10 | import com.youtubedl.ui.main.settings.SettingsViewModel 11 | import com.youtubedl.ui.main.splash.SplashViewModel 12 | import com.youtubedl.ui.main.video.VideoViewModel 13 | import com.youtubedl.util.ViewModelFactory 14 | import dagger.Binds 15 | import dagger.Module 16 | import dagger.multibindings.IntoMap 17 | import javax.inject.Singleton 18 | 19 | /** 20 | * Created by cuongpm on 12/6/18. 21 | */ 22 | 23 | @Module(includes = [AppModule::class]) 24 | abstract class ViewModelModule { 25 | 26 | @Singleton 27 | @Binds 28 | abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory 29 | 30 | @Binds 31 | @IntoMap 32 | @ViewModelKey(SplashViewModel::class) 33 | abstract fun bindSplashViewModel(viewModel: SplashViewModel): ViewModel 34 | 35 | @Binds 36 | @IntoMap 37 | @ViewModelKey(MainViewModel::class) 38 | abstract fun bindMainViewModel(viewModel: MainViewModel): ViewModel 39 | 40 | @Binds 41 | @IntoMap 42 | @ViewModelKey(BrowserViewModel::class) 43 | abstract fun bindBrowserViewModel(viewModel: BrowserViewModel): ViewModel 44 | 45 | @Binds 46 | @IntoMap 47 | @ViewModelKey(VideoPlayerViewModel::class) 48 | abstract fun bindVideoPlayerViewModel(viewModel: VideoPlayerViewModel): ViewModel 49 | 50 | @Binds 51 | @IntoMap 52 | @ViewModelKey(ProgressViewModel::class) 53 | abstract fun bindProgressViewModel(viewModel: ProgressViewModel): ViewModel 54 | 55 | @Binds 56 | @IntoMap 57 | @ViewModelKey(VideoViewModel::class) 58 | abstract fun bindVideoViewModel(viewModel: VideoViewModel): ViewModel 59 | 60 | @Binds 61 | @IntoMap 62 | @ViewModelKey(SettingsViewModel::class) 63 | abstract fun bindSettingsViewModel(viewModel: SettingsViewModel): ViewModel 64 | } 65 | -------------------------------------------------------------------------------- /app/src/androidTest/java/util/rule/InjectedFragmentTestRule.kt: -------------------------------------------------------------------------------- 1 | package util.rule 2 | 3 | import android.content.Intent 4 | import android.support.test.espresso.intent.rule.IntentsTestRule 5 | import android.support.v4.app.DialogFragment 6 | import android.support.v4.app.Fragment 7 | import dagger.android.AndroidInjector 8 | import util.TestHelperActivity 9 | 10 | /** 11 | * Created by cuongpm on 1/29/19. 12 | */ 13 | 14 | class InjectedFragmentTestRule( 15 | private val fragmentInjector: (T) -> Unit, 16 | private val useDefaultToolbar: Boolean 17 | ) : IntentsTestRule(TestHelperActivity::class.java, false, false) { 18 | 19 | constructor(fragmentInjector: (T) -> Unit) : this(fragmentInjector, false) 20 | 21 | lateinit var fragment: T 22 | 23 | fun launchFragment(fragment: T) { 24 | super.launchActivity(Intent().also { 25 | it.putExtra(TestHelperActivity.EXTRA_USE_DEFAULT_TOOLBAR_KEY, useDefaultToolbar) 26 | }) 27 | attachFragment(fragment) 28 | } 29 | 30 | override fun launchActivity(startIntent: Intent?): TestHelperActivity { 31 | error("You should use launchFragment() instead") 32 | } 33 | 34 | override fun afterActivityLaunched() { 35 | super.afterActivityLaunched() 36 | 37 | activity.fragmentInjector = AndroidInjector { 38 | @Suppress("UNCHECKED_CAST") 39 | fragmentInjector(it as T) 40 | } 41 | } 42 | 43 | fun launchFragment(fragment: () -> T) { 44 | super.launchActivity(Intent().also { 45 | it.putExtra(TestHelperActivity.EXTRA_USE_DEFAULT_TOOLBAR_KEY, useDefaultToolbar) 46 | }) 47 | activity.runBlockingOnUiThread { 48 | attachFragment(fragment.invoke()) 49 | } 50 | } 51 | 52 | fun launchDialogFragment(fragment: T) { 53 | super.launchActivity(Intent().also { 54 | it.putExtra(TestHelperActivity.EXTRA_USE_DEFAULT_TOOLBAR_KEY, useDefaultToolbar) 55 | }) 56 | activity.runBlockingOnUiThread { 57 | (fragment as DialogFragment).show(activity.supportFragmentManager, "test-tag") 58 | } 59 | } 60 | 61 | private fun attachFragment(fragment: T) { 62 | this.fragment = fragment 63 | activity.attachFragment(fragment) 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/util/ScriptUtil.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util 2 | 3 | /** 4 | * Created by cuongpm on 12/23/18. 5 | */ 6 | 7 | class ScriptUtil { 8 | 9 | companion object { 10 | const val FACEBOOK_SCRIPT = "javascript:function clickOnVideo(link, classValueName){" + 11 | "browser.getVideoData(link);" + 12 | "var element = document.getElementById(\"mInlineVideoPlayer\");" + 13 | "element.muted = true;" + 14 | "var parent = element.parentNode; " + 15 | "parent.removeChild(element);" + 16 | "parent.setAttribute('class', classValueName);}" + 17 | "function getVideoLink(){" + 18 | "try{var items = document.getElementsByTagName(\"div\");" + 19 | "for(i = 0; i < items.length; i++){" + 20 | "if(items[i].getAttribute(\"data-sigil\") == \"inlineVideo\"){" + 21 | "var classValueName = items[i].getAttribute(\"class\");" + 22 | "var jsonString = items[i].getAttribute(\"data-store\");" + 23 | "var obj = JSON && JSON.parse(jsonString) || $.parseJSON(jsonString);" + 24 | "var videoLink = obj.src;" + 25 | "var videoName = obj.videoID;" + 26 | "items[i].setAttribute('onclick', \"clickOnVideo('\"+videoLink+\"','\"+classValueName+\"')\");}}" + 27 | "var links = document.getElementsByTagName(\"a\");" + 28 | "for(i = 0; i < links.length; i++){" + 29 | "if(links[ i ].hasAttribute(\"data-store\")){" + 30 | "var jsonString = links[i].getAttribute(\"data-store\");" + 31 | "var obj = JSON && JSON.parse(jsonString) || $.parseJSON(jsonString);" + 32 | "var videoName = obj.videoID;" + 33 | "var videoLink = links[i].getAttribute(\"href\");" + 34 | "var res = videoLink.split(\"src=\");" + 35 | "var myLink = res[1];" + 36 | "links[i].parentNode.setAttribute('onclick', \"browser.getVideoData('\"+myLink+\"')\");" + 37 | "while (links[i].firstChild){" + 38 | "links[i].parentNode.insertBefore(links[i].firstChild," + 39 | "links[i]);}" + 40 | "links[i].parentNode.removeChild(links[i]);}}}catch(e){}}" + 41 | "getVideoLink();" 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/video/VideoViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.video 2 | 3 | import android.content.Context 4 | import android.databinding.ObservableArrayList 5 | import android.databinding.ObservableList 6 | import com.youtubedl.OpenForTesting 7 | import com.youtubedl.data.local.model.LocalVideo 8 | import com.youtubedl.ui.main.base.BaseViewModel 9 | import com.youtubedl.util.FileUtil 10 | import com.youtubedl.util.SingleLiveEvent 11 | import java.io.File 12 | import javax.inject.Inject 13 | 14 | /** 15 | * Created by cuongpm on 12/7/18. 16 | */ 17 | 18 | @OpenForTesting 19 | class VideoViewModel @Inject constructor( 20 | private val fileUtil: FileUtil 21 | ) : BaseViewModel() { 22 | 23 | companion object { 24 | const val FILE_EXIST_ERROR_CODE = 1 25 | const val FILE_INVALID_ERROR_CODE = 2 26 | } 27 | 28 | val localVideos: ObservableList = ObservableArrayList() 29 | val renameErrorEvent = SingleLiveEvent() 30 | 31 | override fun start() { 32 | getListDownloadedVideos() 33 | } 34 | 35 | override fun stop() { 36 | } 37 | 38 | private fun getListDownloadedVideos() { 39 | val listVideos: MutableList = mutableListOf() 40 | fileUtil.listFiles.map { file -> listVideos.add(LocalVideo(file)) } 41 | with(localVideos) { 42 | clear() 43 | addAll(listVideos) 44 | } 45 | } 46 | 47 | fun deleteVideo(file: File) { 48 | if (file.exists()) { 49 | localVideos.find { it.file.path == file.path }?.let { 50 | localVideos.remove(it) 51 | file.delete() 52 | } 53 | } 54 | } 55 | 56 | fun renameVideo(context: Context, file: File, newName: String, newFile: File) { 57 | if (newName.isNotEmpty()) { 58 | if (newFile.exists()) { 59 | renameErrorEvent.value = FILE_EXIST_ERROR_CODE 60 | } else if (file.renameTo(newFile)) { 61 | fileUtil.deleteMedia(context, file) 62 | fileUtil.scanMedia(context, newFile) 63 | localVideos.find { it.file.path == file.path }?.let { 64 | it.file = newFile 65 | localVideos[localVideos.indexOf(it)] = it 66 | } 67 | } 68 | } else { 69 | renameErrorEvent.value = FILE_INVALID_ERROR_CODE 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/data/repository/ConfigRepository.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.repository 2 | 3 | import android.support.annotation.VisibleForTesting 4 | import com.youtubedl.data.local.room.entity.SupportedPage 5 | import com.youtubedl.di.qualifier.LocalData 6 | import com.youtubedl.di.qualifier.RemoteData 7 | import io.reactivex.Flowable 8 | import javax.inject.Inject 9 | import javax.inject.Singleton 10 | 11 | /** 12 | * Created by cuongpm on 12/8/18. 13 | */ 14 | 15 | interface ConfigRepository { 16 | 17 | fun getSupportedPages(): Flowable> 18 | 19 | fun saveSupportedPages(supportedPages: List) 20 | } 21 | 22 | @Singleton 23 | class ConfigRepositoryImpl @Inject constructor( 24 | @LocalData private val localDataSource: ConfigRepository, 25 | @RemoteData private val remoteDataSource: ConfigRepository 26 | ) : ConfigRepository { 27 | 28 | @VisibleForTesting 29 | internal var cachedSupportedPages = listOf() 30 | 31 | override fun getSupportedPages(): Flowable> { 32 | 33 | return if (cachedSupportedPages.isNotEmpty()) { 34 | Flowable.just(cachedSupportedPages) 35 | } else { 36 | getAndCacheLocalSupportedPages() 37 | .flatMap { supportedPages -> 38 | if (supportedPages.isEmpty()) { 39 | getAndSaveRemoteSupportedPages() 40 | } else { 41 | Flowable.just(supportedPages) 42 | } 43 | } 44 | } 45 | } 46 | 47 | override fun saveSupportedPages(supportedPages: List) { 48 | remoteDataSource.saveSupportedPages(supportedPages) 49 | localDataSource.saveSupportedPages(supportedPages) 50 | cachedSupportedPages = supportedPages 51 | } 52 | 53 | private fun getAndCacheLocalSupportedPages(): Flowable> { 54 | return localDataSource.getSupportedPages() 55 | .doOnNext { supportedPages -> 56 | cachedSupportedPages = supportedPages 57 | } 58 | } 59 | 60 | private fun getAndSaveRemoteSupportedPages(): Flowable> { 61 | return remoteDataSource.getSupportedPages() 62 | .doOnNext { supportedPages -> 63 | localDataSource.saveSupportedPages(supportedPages) 64 | cachedSupportedPages = supportedPages 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/settings/SettingsFragment.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.settings 2 | 3 | import android.arch.lifecycle.Observer 4 | import android.arch.lifecycle.ViewModelProvider 5 | import android.arch.lifecycle.ViewModelProviders 6 | import android.os.Bundle 7 | import android.view.LayoutInflater 8 | import android.view.View 9 | import android.view.ViewGroup 10 | import com.youtubedl.OpenForTesting 11 | import com.youtubedl.databinding.FragmentSettingsBinding 12 | import com.youtubedl.ui.main.base.BaseFragment 13 | import com.youtubedl.util.FileUtil 14 | import com.youtubedl.util.IntentUtil 15 | import com.youtubedl.util.SystemUtil 16 | import javax.inject.Inject 17 | 18 | /** 19 | * Created by cuongpm on 12/7/18. 20 | */ 21 | 22 | @OpenForTesting 23 | class SettingsFragment : BaseFragment() { 24 | 25 | companion object { 26 | fun newInstance() = SettingsFragment() 27 | } 28 | 29 | @Inject 30 | lateinit var fileUtil: FileUtil 31 | 32 | @Inject 33 | lateinit var intentUtil: IntentUtil 34 | 35 | @Inject 36 | lateinit var systemUtil: SystemUtil 37 | 38 | @Inject 39 | lateinit var viewModelFactory: ViewModelProvider.Factory 40 | 41 | private lateinit var dataBinding: FragmentSettingsBinding 42 | 43 | private lateinit var settingsViewModel: SettingsViewModel 44 | 45 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 46 | settingsViewModel = ViewModelProviders.of(this, viewModelFactory).get(SettingsViewModel::class.java) 47 | 48 | dataBinding = FragmentSettingsBinding.inflate(inflater, container, false).apply { 49 | this.viewModel = settingsViewModel 50 | } 51 | 52 | return dataBinding.root 53 | } 54 | 55 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 56 | super.onViewCreated(view, savedInstanceState) 57 | settingsViewModel.start() 58 | handleUIEvents() 59 | } 60 | 61 | override fun onDestroyView() { 62 | super.onDestroyView() 63 | settingsViewModel.stop() 64 | } 65 | 66 | private fun handleUIEvents() { 67 | settingsViewModel.apply { 68 | clearCookiesEvent.observe(this@SettingsFragment, Observer { 69 | systemUtil.clearCookies(context) 70 | }) 71 | 72 | openVideoFolderEvent.observe(this@SettingsFragment, Observer { 73 | intentUtil.openFolder(context, fileUtil.folderDir.path) 74 | }) 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/module/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.module 2 | 3 | import android.app.Application 4 | import com.facebook.stetho.okhttp3.StethoInterceptor 5 | import com.youtubedl.data.remote.service.ConfigService 6 | import com.youtubedl.data.remote.service.VideoService 7 | import com.youtubedl.util.Memory 8 | import dagger.Module 9 | import dagger.Provides 10 | import okhttp3.Cache 11 | import okhttp3.OkHttpClient 12 | import retrofit2.Retrofit 13 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 14 | import retrofit2.converter.gson.GsonConverterFactory 15 | import java.io.File 16 | import java.util.concurrent.TimeUnit 17 | import javax.inject.Singleton 18 | 19 | /** 20 | * Created by cuongpm on 12/6/18. 21 | */ 22 | 23 | @Module 24 | class NetworkModule { 25 | 26 | companion object { 27 | private const val DATA_URL = "https://generaldata-79d9b.firebaseapp.com/youtube-dl/" 28 | private const val YTDL_URL = "http://youtube-dl-android.herokuapp.com/api/" 29 | } 30 | 31 | private fun buildOkHttpClient(application: Application): OkHttpClient = 32 | OkHttpClient.Builder() 33 | .addNetworkInterceptor(StethoInterceptor()) 34 | .connectTimeout(10L, TimeUnit.SECONDS) 35 | .writeTimeout(10L, TimeUnit.SECONDS) 36 | .readTimeout(30L, TimeUnit.SECONDS) 37 | .cache( 38 | Cache( 39 | File(application.cacheDir, "YoutubeDLCache"), 40 | Memory.calcCacheSize(application, .25f) 41 | ) 42 | ) 43 | .build() 44 | 45 | @Provides 46 | @Singleton 47 | fun provideOkHttpClient(application: Application): OkHttpClient = buildOkHttpClient(application) 48 | 49 | @Provides 50 | @Singleton 51 | fun provideConfigService(okHttpClient: OkHttpClient): ConfigService = Retrofit.Builder() 52 | .baseUrl(DATA_URL) 53 | .client(okHttpClient) 54 | .addConverterFactory(GsonConverterFactory.create()) 55 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 56 | .build() 57 | .create(ConfigService::class.java) 58 | 59 | @Provides 60 | @Singleton 61 | fun provideVideoService(okHttpClient: OkHttpClient): VideoService = Retrofit.Builder() 62 | .baseUrl(YTDL_URL) 63 | .client(okHttpClient) 64 | .addConverterFactory(GsonConverterFactory.create()) 65 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 66 | .build() 67 | .create(VideoService::class.java) 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/di/module/RepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.di.module 2 | 3 | import com.youtubedl.data.local.ConfigLocalDataSource 4 | import com.youtubedl.data.local.ProgressLocalDataSource 5 | import com.youtubedl.data.local.TopPagesLocalDataSource 6 | import com.youtubedl.data.local.VideoLocalDataSource 7 | import com.youtubedl.data.remote.ConfigRemoteDataSource 8 | import com.youtubedl.data.remote.TopPagesRemoteDataSource 9 | import com.youtubedl.data.remote.VideoRemoteDataSource 10 | import com.youtubedl.data.repository.* 11 | import com.youtubedl.di.qualifier.LocalData 12 | import com.youtubedl.di.qualifier.RemoteData 13 | import dagger.Binds 14 | import dagger.Module 15 | import javax.inject.Singleton 16 | 17 | /** 18 | * Created by cuongpm on 12/6/18. 19 | */ 20 | 21 | @Module 22 | abstract class RepositoryModule { 23 | 24 | @Singleton 25 | @Binds 26 | @LocalData 27 | abstract fun bindConfigLocalDataSource(localDataSource: ConfigLocalDataSource): ConfigRepository 28 | 29 | @Singleton 30 | @Binds 31 | @RemoteData 32 | abstract fun bindConfigRemoteDataSource(remoteDataSource: ConfigRemoteDataSource): ConfigRepository 33 | 34 | @Singleton 35 | @Binds 36 | abstract fun bindConfigRepositoryImpl(configRepository: ConfigRepositoryImpl): ConfigRepository 37 | 38 | @Singleton 39 | @Binds 40 | @LocalData 41 | abstract fun bindTopPagesLocalDataSource(localDataSource: TopPagesLocalDataSource): TopPagesRepository 42 | 43 | @Singleton 44 | @Binds 45 | @RemoteData 46 | abstract fun bindTopPagesRemoteDataSource(remoteDataSource: TopPagesRemoteDataSource): TopPagesRepository 47 | 48 | @Singleton 49 | @Binds 50 | abstract fun bindTopPagesRepositoryImpl(topPagesRepository: TopPagesRepositoryImpl): TopPagesRepository 51 | 52 | @Singleton 53 | @Binds 54 | @LocalData 55 | abstract fun bindVideoLocalDataSource(localDataSource: VideoLocalDataSource): VideoRepository 56 | 57 | @Singleton 58 | @Binds 59 | @RemoteData 60 | abstract fun bindVideoRemoteDataSource(remoteDataSource: VideoRemoteDataSource): VideoRepository 61 | 62 | @Singleton 63 | @Binds 64 | abstract fun bindVideoRepositoryImpl(videoRepository: VideoRepositoryImpl): VideoRepository 65 | 66 | @Singleton 67 | @Binds 68 | @LocalData 69 | abstract fun bindProgressLocalDataSource(localDataSource: ProgressLocalDataSource): ProgressRepository 70 | 71 | @Singleton 72 | @Binds 73 | abstract fun bindProgressRepositoryImpl(progressRepository: ProgressRepositoryImpl): ProgressRepository 74 | } -------------------------------------------------------------------------------- /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 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 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 Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 19 | 20 | 30 | 31 | 43 | 44 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/util/SingleLiveEventTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.util 2 | 3 | import android.arch.core.executor.testing.InstantTaskExecutorRule 4 | import android.arch.lifecycle.Lifecycle 5 | import android.arch.lifecycle.LifecycleOwner 6 | import android.arch.lifecycle.LifecycleRegistry 7 | import android.arch.lifecycle.Observer 8 | import com.nhaarman.mockito_kotlin.* 9 | import org.junit.Before 10 | import org.junit.Rule 11 | import org.junit.Test 12 | import org.mockito.ArgumentMatchers.anyInt 13 | 14 | /** 15 | * Created by cuongpm on 1/13/19. 16 | */ 17 | 18 | class SingleLiveEventTest { 19 | 20 | @get:Rule 21 | var instantExecutorRule = InstantTaskExecutorRule() 22 | 23 | private lateinit var owner: LifecycleOwner 24 | 25 | private lateinit var eventObserver: Observer 26 | 27 | private lateinit var lifecycle: LifecycleRegistry 28 | 29 | private val singleLiveEvent = SingleLiveEvent() 30 | 31 | @Before 32 | fun setup() { 33 | owner = mock() 34 | eventObserver = mock() 35 | lifecycle = LifecycleRegistry(owner) 36 | doReturn(lifecycle).`when`(owner).lifecycle 37 | singleLiveEvent.observe(owner, eventObserver) 38 | lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) 39 | } 40 | 41 | @Test 42 | fun `test value is not set on the first onResume`() { 43 | lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) 44 | verify(eventObserver, never()).onChanged(anyInt()) 45 | } 46 | 47 | @Test 48 | fun `test update only once`() { 49 | singleLiveEvent.value = 33 50 | 51 | with(lifecycle) { 52 | handleLifecycleEvent(android.arch.lifecycle.Lifecycle.Event.ON_RESUME) 53 | handleLifecycleEvent(android.arch.lifecycle.Lifecycle.Event.ON_STOP) 54 | handleLifecycleEvent(android.arch.lifecycle.Lifecycle.Event.ON_RESUME) 55 | } 56 | 57 | verify(eventObserver, times(1)).onChanged(anyInt()) 58 | } 59 | 60 | @Test 61 | fun `test update twice`() { 62 | singleLiveEvent.value = 33 63 | lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) 64 | singleLiveEvent.value = 33 65 | 66 | verify(eventObserver, times(2)).onChanged(anyInt()) 67 | } 68 | 69 | @Test 70 | fun `test no update util active`() { 71 | singleLiveEvent.value = 33 72 | 73 | verify(eventObserver, never()).onChanged(33) 74 | 75 | singleLiveEvent.value = 33 76 | lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) 77 | 78 | verify(eventObserver, times(1)).onChanged(anyInt()) 79 | } 80 | 81 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/progress/ProgressFragment.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.progress 2 | 3 | import android.arch.lifecycle.Observer 4 | import android.arch.lifecycle.ViewModelProvider 5 | import android.arch.lifecycle.ViewModelProviders 6 | import android.os.Bundle 7 | import android.support.v7.widget.LinearLayoutManager 8 | import android.view.LayoutInflater 9 | import android.view.View 10 | import android.view.ViewGroup 11 | import com.youtubedl.OpenForTesting 12 | import com.youtubedl.databinding.FragmentProgressBinding 13 | import com.youtubedl.ui.component.adapter.ProgressAdapter 14 | import com.youtubedl.ui.main.base.BaseFragment 15 | import com.youtubedl.ui.main.home.MainActivity 16 | import com.youtubedl.ui.main.home.MainViewModel 17 | import javax.inject.Inject 18 | 19 | /** 20 | * Created by cuongpm on 12/7/18. 21 | */ 22 | 23 | @OpenForTesting 24 | class ProgressFragment : BaseFragment() { 25 | 26 | companion object { 27 | fun newInstance() = ProgressFragment() 28 | } 29 | 30 | @Inject 31 | lateinit var viewModelFactory: ViewModelProvider.Factory 32 | 33 | @Inject 34 | lateinit var mainActivity: MainActivity 35 | 36 | private lateinit var progressViewModel: ProgressViewModel 37 | 38 | private lateinit var mainViewModel: MainViewModel 39 | 40 | private lateinit var dataBinding: FragmentProgressBinding 41 | 42 | private lateinit var progressAdapter: ProgressAdapter 43 | 44 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 45 | mainViewModel = mainActivity.mainViewModel 46 | progressViewModel = ViewModelProviders.of(this, viewModelFactory).get(ProgressViewModel::class.java) 47 | progressAdapter = ProgressAdapter(ArrayList(0)) 48 | 49 | dataBinding = FragmentProgressBinding.inflate(inflater, container, false).apply { 50 | this.viewModel = progressViewModel 51 | this.rvProgress.layoutManager = LinearLayoutManager(context) 52 | this.rvProgress.adapter = progressAdapter 53 | } 54 | 55 | return dataBinding.root 56 | } 57 | 58 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 59 | super.onViewCreated(view, savedInstanceState) 60 | progressViewModel.start() 61 | handleDownloadVideoEvent() 62 | } 63 | 64 | override fun onDestroyView() { 65 | super.onDestroyView() 66 | progressViewModel.stop() 67 | } 68 | 69 | private fun handleDownloadVideoEvent() { 70 | mainViewModel.downloadVideoEvent.observe(this, Observer { videoInfo -> 71 | progressViewModel.downloadVideo(videoInfo) 72 | }) 73 | } 74 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/util/RecyclerViewMatcher.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import android.content.res.Resources 4 | import android.support.annotation.IdRes 5 | import android.support.v7.widget.RecyclerView 6 | import android.view.View 7 | import org.hamcrest.Description 8 | import org.hamcrest.Matcher 9 | import org.hamcrest.TypeSafeMatcher 10 | 11 | /** 12 | * Created by cuongpm on 2/4/19. 13 | */ 14 | 15 | class RecyclerViewMatcher(val id: Int) { 16 | 17 | fun atPosition(position: Int): Matcher { 18 | return atPositionOnView(position, -1) 19 | } 20 | 21 | fun atPositionOnView(position: Int, targetViewId: Int): Matcher { 22 | 23 | return object : TypeSafeMatcher() { 24 | var resources: Resources? = null 25 | var childView: View? = null 26 | 27 | override fun describeTo(description: Description) { 28 | var idDescription = Integer.toString(id) 29 | if (this.resources != null) { 30 | idDescription = try { 31 | this.resources!!.getResourceName(id) 32 | } catch (var4: Resources.NotFoundException) { 33 | String.format("%s (resource name not found)", id) 34 | } 35 | 36 | } 37 | 38 | description.appendText( 39 | "RecyclerView with id: $idDescription at position: $position" 40 | ) 41 | } 42 | 43 | public override fun matchesSafely(view: View): Boolean { 44 | 45 | this.resources = view.resources 46 | 47 | if (childView == null) { 48 | val recyclerView = view.rootView.findViewById(id) as RecyclerView 49 | if (recyclerView != null && recyclerView.id == id) { 50 | val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) 51 | if (viewHolder != null) { 52 | childView = viewHolder.itemView 53 | } 54 | } else { 55 | return false 56 | } 57 | } 58 | 59 | return when { 60 | targetViewId == -1 -> view === childView 61 | childView == null -> false 62 | else -> { 63 | val targetView = childView!!.findViewById(targetViewId) 64 | view === targetView 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | companion object { 72 | fun recyclerViewWithId(@IdRes id: Int): RecyclerViewMatcher { 73 | return RecyclerViewMatcher(id) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/player/VideoPlayerFragment.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.player 2 | 3 | import android.arch.lifecycle.ViewModelProvider 4 | import android.arch.lifecycle.ViewModelProviders 5 | import android.media.MediaPlayer 6 | import android.os.Bundle 7 | import android.support.annotation.VisibleForTesting 8 | import android.view.LayoutInflater 9 | import android.view.View 10 | import android.view.ViewGroup 11 | import com.youtubedl.OpenForTesting 12 | import com.youtubedl.databinding.FragmentPlayerBinding 13 | import com.youtubedl.ui.main.base.BaseFragment 14 | import com.youtubedl.util.TimeUtil 15 | import javax.inject.Inject 16 | 17 | /** 18 | * Created by cuongpm on 1/6/19. 19 | */ 20 | 21 | @OpenForTesting 22 | class VideoPlayerFragment : BaseFragment() { 23 | 24 | companion object { 25 | const val VIDEO_URL = "video_url" 26 | const val VIDEO_NAME = "video_name" 27 | private const val SEEK_INTERVAL = 5000 28 | } 29 | 30 | @Inject 31 | lateinit var viewModelFactory: ViewModelProvider.Factory 32 | 33 | private lateinit var videoPlayerViewModel: VideoPlayerViewModel 34 | 35 | private lateinit var dataBinding: FragmentPlayerBinding 36 | 37 | @VisibleForTesting 38 | internal var mediaPlayer: MediaPlayer? = null 39 | 40 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 41 | videoPlayerViewModel = ViewModelProviders.of(this, viewModelFactory).get(VideoPlayerViewModel::class.java) 42 | 43 | dataBinding = FragmentPlayerBinding.inflate(inflater, container, false).apply { 44 | this.viewModel = videoPlayerViewModel 45 | this.toolbar.setNavigationOnClickListener(navigationIconClickListener) 46 | this.videoView.setOnPreparedListener(onPreparedVideoListener) 47 | } 48 | 49 | return dataBinding.root 50 | } 51 | 52 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 53 | super.onViewCreated(view, savedInstanceState) 54 | arguments?.getString(VIDEO_URL)?.let { videoPlayerViewModel.videoUrl.set(it) } 55 | arguments?.getString(VIDEO_NAME)?.let { videoPlayerViewModel.videoName.set(it) } 56 | 57 | videoPlayerViewModel.start() 58 | } 59 | 60 | override fun onDestroyView() { 61 | super.onDestroyView() 62 | videoPlayerViewModel.stop() 63 | } 64 | 65 | private val navigationIconClickListener = View.OnClickListener { activity?.finish() } 66 | 67 | private val onPreparedVideoListener = MediaPlayer.OnPreparedListener { player -> 68 | mediaPlayer = player 69 | val volume = videoPlayerViewModel.getVolume() 70 | mediaPlayer?.setVolume(volume, volume) 71 | 72 | val totalTime = TimeUtil.convertMilliSecondsToTimer(dataBinding.videoView.duration.toLong()) 73 | videoPlayerViewModel.currentTime.set("00:00") 74 | videoPlayerViewModel.totalTime.set(totalTime) 75 | } 76 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/item_top_page.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 18 | 19 | 20 | 26 | 27 | 35 | 36 | 49 | 50 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/data/repository/ConfigRepositoryImplTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.repository 2 | 3 | import com.nhaarman.mockito_kotlin.doReturn 4 | import com.nhaarman.mockito_kotlin.mock 5 | import com.nhaarman.mockito_kotlin.verify 6 | import com.youtubedl.data.local.ConfigLocalDataSource 7 | import com.youtubedl.data.local.room.entity.SupportedPage 8 | import com.youtubedl.data.remote.ConfigRemoteDataSource 9 | import io.reactivex.Flowable 10 | import org.junit.Assert.assertEquals 11 | import org.junit.Before 12 | import org.junit.Test 13 | 14 | /** 15 | * Created by cuongpm on 1/28/19. 16 | */ 17 | class ConfigRepositoryImplTest { 18 | 19 | private lateinit var localData: ConfigLocalDataSource 20 | 21 | private lateinit var remoteData: ConfigRemoteDataSource 22 | 23 | private lateinit var configRepository: ConfigRepositoryImpl 24 | 25 | private lateinit var supportedPage1: SupportedPage 26 | 27 | private lateinit var supportedPage2: SupportedPage 28 | 29 | private lateinit var supportedPages: List 30 | 31 | @Before 32 | fun setup() { 33 | localData = mock() 34 | remoteData = mock() 35 | configRepository = ConfigRepositoryImpl(localData, remoteData) 36 | 37 | supportedPage1 = SupportedPage(id = "id1") 38 | supportedPage2 = SupportedPage(id = "id2") 39 | supportedPages = listOf(supportedPage1, supportedPage2) 40 | } 41 | 42 | @Test 43 | fun `save config info into cache, local and remote source`() { 44 | configRepository.saveSupportedPages(supportedPages) 45 | 46 | assertEquals(supportedPages, configRepository.cachedSupportedPages) 47 | verify(remoteData).saveSupportedPages(supportedPages) 48 | verify(localData).saveSupportedPages(supportedPages) 49 | } 50 | 51 | @Test 52 | fun `get config from cache`() { 53 | configRepository.cachedSupportedPages = supportedPages 54 | 55 | configRepository.getSupportedPages().test() 56 | .assertNoErrors() 57 | .assertValue { it == supportedPages } 58 | } 59 | 60 | @Test 61 | fun `get config from local source should save config to cache`() { 62 | doReturn(Flowable.just(supportedPages)).`when`(localData).getSupportedPages() 63 | 64 | configRepository.getSupportedPages().test() 65 | .assertNoErrors() 66 | .assertValue { it == supportedPages } 67 | 68 | assertEquals(supportedPages, configRepository.cachedSupportedPages) 69 | } 70 | 71 | @Test 72 | fun `get config from remote source should save config to cache and local source`() { 73 | doReturn(Flowable.just(listOf())).`when`(localData).getSupportedPages() 74 | doReturn(Flowable.just(supportedPages)).`when`(remoteData).getSupportedPages() 75 | 76 | configRepository.getSupportedPages().test() 77 | .assertNoErrors() 78 | .assertValue { it == supportedPages } 79 | 80 | assertEquals(supportedPages, configRepository.cachedSupportedPages) 81 | verify(localData).saveSupportedPages(supportedPages) 82 | } 83 | } -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/data/repository/VideoRepositoryImplTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.data.repository 2 | 3 | import com.nhaarman.mockito_kotlin.doReturn 4 | import com.nhaarman.mockito_kotlin.mock 5 | import com.nhaarman.mockito_kotlin.verify 6 | import com.youtubedl.data.local.VideoLocalDataSource 7 | import com.youtubedl.data.local.room.entity.VideoInfo 8 | import com.youtubedl.data.remote.VideoRemoteDataSource 9 | import io.reactivex.Flowable 10 | import io.reactivex.Maybe 11 | import org.junit.Assert.assertEquals 12 | import org.junit.Before 13 | import org.junit.Test 14 | 15 | /** 16 | * Created by cuongpm on 1/14/19. 17 | */ 18 | 19 | class VideoRepositoryImplTest { 20 | 21 | private lateinit var localData: VideoLocalDataSource 22 | 23 | private lateinit var remoteData: VideoRemoteDataSource 24 | 25 | private lateinit var videoRepository: VideoRepositoryImpl 26 | 27 | private lateinit var videoInfo: VideoInfo 28 | 29 | private lateinit var videoInfo1: VideoInfo 30 | 31 | private lateinit var url: String 32 | 33 | @Before 34 | fun setup() { 35 | localData = mock() 36 | remoteData = mock() 37 | videoRepository = VideoRepositoryImpl(localData, remoteData) 38 | 39 | videoInfo = VideoInfo(title = "title", originalUrl = "originalUrl") 40 | videoInfo1 = VideoInfo(title = "title1", originalUrl = "originalUrl1") 41 | url = "videoUrl" 42 | } 43 | 44 | @Test 45 | fun `save video info into cache, local and remote source`() { 46 | videoRepository.saveVideoInfo(videoInfo) 47 | 48 | assertEquals(videoInfo, videoRepository.cachedVideos[videoInfo.originalUrl]) 49 | verify(remoteData).saveVideoInfo(videoInfo) 50 | verify(localData).saveVideoInfo(videoInfo) 51 | } 52 | 53 | @Test 54 | fun `get video info from cache`() { 55 | videoRepository.cachedVideos[url] = videoInfo 56 | 57 | videoRepository.getVideoInfo(url).test() 58 | .assertNoErrors() 59 | .assertValue { it == videoInfo } 60 | } 61 | 62 | @Test 63 | fun `get video info from local source should save data to cache`() { 64 | doReturn(Flowable.just(videoInfo)).`when`(localData).getVideoInfo(url) 65 | doReturn(Flowable.just(videoInfo1)).`when`(remoteData).getVideoInfo(url) 66 | 67 | videoRepository.getVideoInfo(url).test() 68 | .assertNoErrors() 69 | .assertValue { it == videoInfo } 70 | 71 | assertEquals(videoInfo, videoRepository.cachedVideos[url]) 72 | } 73 | 74 | @Test 75 | fun `get video info from remote source should save data to cache and local source`() { 76 | doReturn( 77 | Maybe.create { it.onComplete() }.toFlowable() 78 | ).`when`(localData).getVideoInfo(url) 79 | doReturn(Flowable.just(videoInfo)).`when`(remoteData).getVideoInfo(url) 80 | 81 | videoRepository.getVideoInfo(url).test() 82 | .assertNoErrors() 83 | .assertValue { it == videoInfo } 84 | 85 | assertEquals(videoInfo, videoRepository.cachedVideos[url]) 86 | assertEquals(url, videoInfo.originalUrl) 87 | verify(localData).saveVideoInfo(videoInfo) 88 | } 89 | 90 | } -------------------------------------------------------------------------------- /app/src/main/java/com/youtubedl/ui/main/home/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.home 2 | 3 | import android.Manifest 4 | import android.arch.lifecycle.ViewModelProvider 5 | import android.arch.lifecycle.ViewModelProviders 6 | import android.content.pm.PackageManager 7 | import android.databinding.DataBindingUtil 8 | import android.os.Bundle 9 | import android.support.design.widget.BottomNavigationView 10 | import android.support.v4.app.ActivityCompat 11 | import android.support.v4.content.ContextCompat 12 | import android.support.v4.view.ViewPager 13 | import com.youtubedl.OpenForTesting 14 | import com.youtubedl.R 15 | import com.youtubedl.databinding.ActivityMainBinding 16 | import com.youtubedl.ui.component.adapter.MainAdapter 17 | import com.youtubedl.ui.main.base.BaseActivity 18 | import com.youtubedl.util.fragment.FragmentFactory 19 | import javax.inject.Inject 20 | 21 | @OpenForTesting 22 | class MainActivity : BaseActivity() { 23 | 24 | @Inject 25 | lateinit var fragmentFactory: FragmentFactory 26 | 27 | @Inject 28 | lateinit var viewModelFactory: ViewModelProvider.Factory 29 | 30 | lateinit var mainViewModel: MainViewModel 31 | 32 | private lateinit var dataBinding: ActivityMainBinding 33 | 34 | private lateinit var mainAdapter: MainAdapter 35 | 36 | override fun onCreate(savedInstanceState: Bundle?) { 37 | super.onCreate(savedInstanceState) 38 | 39 | dataBinding = DataBindingUtil.setContentView(this, R.layout.activity_main) 40 | 41 | mainViewModel = ViewModelProviders.of(this, viewModelFactory).get(MainViewModel::class.java) 42 | mainAdapter = MainAdapter(supportFragmentManager, fragmentFactory) 43 | 44 | dataBinding.viewPager.adapter = mainAdapter 45 | dataBinding.viewPager.addOnPageChangeListener(onPageChangeListener) 46 | dataBinding.bottomBar.setOnNavigationItemSelectedListener(itemSelectedListener) 47 | dataBinding.viewModel = mainViewModel 48 | 49 | grantPermissions() 50 | } 51 | 52 | override fun onBackPressed() { 53 | if (mainViewModel.currentItem.get() != 0) { 54 | mainViewModel.currentItem.set(0) 55 | } else { 56 | mainViewModel.pressBackBtnEvent.call() 57 | } 58 | } 59 | 60 | private fun grantPermissions() { 61 | // Grant permissions 62 | if (ContextCompat.checkSelfPermission( 63 | this, Manifest.permission.WRITE_EXTERNAL_STORAGE 64 | ) != PackageManager.PERMISSION_GRANTED 65 | ) { 66 | ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 0) 67 | } 68 | } 69 | 70 | private val onPageChangeListener = object : ViewPager.OnPageChangeListener { 71 | override fun onPageScrollStateChanged(p0: Int) { 72 | } 73 | 74 | override fun onPageScrolled(p0: Int, p1: Float, p2: Int) { 75 | } 76 | 77 | override fun onPageSelected(postion: Int) { 78 | mainViewModel.currentItem.set(postion) 79 | } 80 | } 81 | 82 | private val itemSelectedListener = BottomNavigationView.OnNavigationItemSelectedListener { menuItem -> 83 | when (menuItem.itemId) { 84 | R.id.tab_browser -> mainViewModel.currentItem.set(0) 85 | R.id.tab_progress -> mainViewModel.currentItem.set(1) 86 | R.id.tab_video -> mainViewModel.currentItem.set(2) 87 | else -> mainViewModel.currentItem.set(3) 88 | } 89 | true 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_video.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 19 | 20 | 28 | 29 | 36 | 37 | 45 | 46 | 57 | 58 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_progress.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 16 | 17 | 18 | 19 | 23 | 24 | 32 | 33 | 40 | 41 | 49 | 50 | 61 | 62 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /app/src/main/res/layout/dialog_download_video.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | 15 | 16 | 17 | 18 | 21 | 22 | 32 | 33 | 46 | 47 | 60 | 61 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_progress.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 21 | 22 | 29 | 30 | 45 | 46 | 61 | 62 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/youtubedl/ui/main/settings/SettingsFragmentTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.settings 2 | 3 | import android.arch.core.executor.testing.InstantTaskExecutorRule 4 | import android.arch.lifecycle.ViewModelProvider 5 | import android.support.test.espresso.Espresso.onView 6 | import android.support.test.espresso.assertion.ViewAssertions.matches 7 | import android.support.test.espresso.matcher.ViewMatchers.isDisplayed 8 | import android.support.test.espresso.matcher.ViewMatchers.withId 9 | import com.nhaarman.mockito_kotlin.doReturn 10 | import com.nhaarman.mockito_kotlin.mock 11 | import com.nhaarman.mockito_kotlin.verify 12 | import com.youtubedl.R 13 | import com.youtubedl.util.FileUtil 14 | import com.youtubedl.util.IntentUtil 15 | import com.youtubedl.util.SingleLiveEvent 16 | import com.youtubedl.util.SystemUtil 17 | import org.junit.Before 18 | import org.junit.Rule 19 | import org.junit.Test 20 | import util.ViewModelUtil 21 | import util.rule.InjectedFragmentTestRule 22 | import java.io.File 23 | 24 | 25 | /** 26 | * Created by cuongpm on 1/29/19. 27 | */ 28 | 29 | class SettingsFragmentTest { 30 | 31 | private lateinit var fileUtil: FileUtil 32 | 33 | private lateinit var systemUtil: SystemUtil 34 | 35 | private lateinit var intentUtil: IntentUtil 36 | 37 | private lateinit var viewModelFactory: ViewModelProvider.Factory 38 | 39 | private lateinit var settingsFragment: SettingsFragment 40 | 41 | private lateinit var settingsViewModel: SettingsViewModel 42 | 43 | private lateinit var clearCookiesEvent: SingleLiveEvent 44 | 45 | private lateinit var openVideoFolderEvent: SingleLiveEvent 46 | 47 | private lateinit var file: File 48 | 49 | private val screen = Screen() 50 | 51 | private val path = "videoPath" 52 | 53 | @get:Rule 54 | val instantExecutorRule = InstantTaskExecutorRule() 55 | 56 | @get:Rule 57 | val uiRule = InjectedFragmentTestRule { 58 | it.fileUtil = fileUtil 59 | it.systemUtil = systemUtil 60 | it.intentUtil = intentUtil 61 | it.viewModelFactory = viewModelFactory 62 | } 63 | 64 | @Before 65 | fun setup() { 66 | fileUtil = mock() 67 | systemUtil = mock() 68 | intentUtil = mock() 69 | file = mock() 70 | settingsViewModel = mock() 71 | clearCookiesEvent = SingleLiveEvent() 72 | openVideoFolderEvent = SingleLiveEvent() 73 | viewModelFactory = ViewModelUtil.createFor(settingsViewModel) 74 | doReturn(clearCookiesEvent).`when`(settingsViewModel).clearCookiesEvent 75 | doReturn(openVideoFolderEvent).`when`(settingsViewModel).openVideoFolderEvent 76 | doReturn(file).`when`(fileUtil).folderDir 77 | doReturn(path).`when`(file).path 78 | } 79 | 80 | @Test 81 | fun initial_ui() { 82 | screen.start() 83 | screen.hasSettings() 84 | } 85 | 86 | @Test 87 | fun clear_cookies() { 88 | screen.start() 89 | clearCookiesEvent.call() 90 | verify(systemUtil).clearCookies(uiRule.fragment.context) 91 | } 92 | 93 | @Test 94 | fun open_video_folder() { 95 | screen.start() 96 | openVideoFolderEvent.call() 97 | verify(intentUtil).openFolder(uiRule.fragment.context, path) 98 | } 99 | 100 | inner class Screen { 101 | fun start() { 102 | settingsFragment = SettingsFragment() 103 | uiRule.launchFragment(settingsFragment) 104 | } 105 | 106 | fun hasSettings() { 107 | onView(withId(R.id.tv_general)).check(matches(isDisplayed())) 108 | onView(withId(R.id.layout_folder)).check(matches(isDisplayed())) 109 | onView(withId(R.id.layout_clear_cookie)).check(matches(isDisplayed())) 110 | } 111 | } 112 | } -------------------------------------------------------------------------------- /app/src/test/java/com/youtubedl/ui/main/video/VideoViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.video 2 | 3 | import android.arch.core.executor.testing.InstantTaskExecutorRule 4 | import android.content.ContentResolver 5 | import android.content.Context 6 | import com.nhaarman.mockito_kotlin.doReturn 7 | import com.nhaarman.mockito_kotlin.mock 8 | import com.nhaarman.mockito_kotlin.verify 9 | import com.youtubedl.ui.main.video.VideoViewModel.Companion.FILE_EXIST_ERROR_CODE 10 | import com.youtubedl.ui.main.video.VideoViewModel.Companion.FILE_INVALID_ERROR_CODE 11 | import com.youtubedl.util.FileUtil 12 | import org.junit.Assert.assertEquals 13 | import org.junit.Before 14 | import org.junit.Rule 15 | import org.junit.Test 16 | import java.io.File 17 | 18 | /** 19 | * Created by cuongpm on 1/13/19. 20 | */ 21 | 22 | class VideoViewModelTest { 23 | 24 | @get:Rule 25 | var instantExecutorRule = InstantTaskExecutorRule() 26 | 27 | private lateinit var viewModel: VideoViewModel 28 | 29 | private lateinit var fileUtil: FileUtil 30 | 31 | private lateinit var file: File 32 | 33 | private lateinit var newFile: File 34 | 35 | private lateinit var listFiles: List 36 | 37 | private lateinit var context: Context 38 | 39 | private lateinit var contentResolver: ContentResolver 40 | 41 | @Before 42 | fun setup() { 43 | file = mock() 44 | newFile = mock() 45 | fileUtil = mock() 46 | context = mock() 47 | contentResolver = mock() 48 | viewModel = VideoViewModel(fileUtil) 49 | listFiles = listOf(File("path1"), File("path2")) 50 | doReturn(listFiles).`when`(fileUtil).listFiles 51 | } 52 | 53 | @Test 54 | fun `show list downloaded videos`() { 55 | viewModel.start() 56 | assertEquals(2, viewModel.localVideos.size) 57 | assertEquals("path1", viewModel.localVideos[0].file.path) 58 | assertEquals("path2", viewModel.localVideos[1].file.path) 59 | } 60 | 61 | @Test 62 | fun `delete a non-existent file should not change list videos`() { 63 | doReturn(false).`when`(file).exists() 64 | viewModel.start() 65 | viewModel.deleteVideo(file) 66 | 67 | assertEquals(2, viewModel.localVideos.size) 68 | } 69 | 70 | @Test 71 | fun `delete an existent file should update list videos`() { 72 | doReturn(true).`when`(file).exists() 73 | doReturn(("path1")).`when`(file).path 74 | viewModel.start() 75 | viewModel.deleteVideo(file) 76 | 77 | assertEquals(1, viewModel.localVideos.size) 78 | assertEquals("path2", viewModel.localVideos[0].file.path) 79 | verify(file).delete() 80 | } 81 | 82 | @Test 83 | fun `rename video with an invalid name should throw invalid error code`() { 84 | viewModel.renameVideo(context, file, "", newFile) 85 | 86 | assertEquals(FILE_INVALID_ERROR_CODE, viewModel.renameErrorEvent.value) 87 | } 88 | 89 | @Test 90 | fun `rename video with a existent name should throw exist error code`() { 91 | doReturn(true).`when`(newFile).exists() 92 | viewModel.renameVideo(context, file, "newName", newFile) 93 | 94 | verify(newFile).exists() 95 | assertEquals(FILE_EXIST_ERROR_CODE, viewModel.renameErrorEvent.value) 96 | } 97 | 98 | @Test 99 | fun `rename video with a valid name should update list videos`() { 100 | doReturn(false).`when`(newFile).exists() 101 | doReturn(true).`when`(file).renameTo(newFile) 102 | doReturn(("path1")).`when`(file).path 103 | doReturn(contentResolver).`when`(context).contentResolver 104 | 105 | viewModel.start() 106 | viewModel.renameVideo(context, file, "newName", newFile) 107 | 108 | verify(fileUtil).deleteMedia(context, file) 109 | verify(fileUtil).scanMedia(context, newFile) 110 | assertEquals(newFile, viewModel.localVideos[0].file) 111 | } 112 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/item_video.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 18 | 19 | 20 | 21 | 28 | 29 | 38 | 39 | 54 | 55 | 69 | 70 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/youtubedl/ui/main/player/VideoPlayerFragmentTest.kt: -------------------------------------------------------------------------------- 1 | package com.youtubedl.ui.main.player 2 | 3 | import android.arch.lifecycle.ViewModelProvider 4 | import android.databinding.ObservableField 5 | import android.os.Bundle 6 | import android.support.test.espresso.Espresso.onView 7 | import android.support.test.espresso.action.ViewActions.click 8 | import android.support.test.espresso.assertion.ViewAssertions.matches 9 | import android.support.test.espresso.matcher.ViewMatchers.* 10 | import android.support.v7.widget.Toolbar 11 | import android.widget.ImageButton 12 | import com.nhaarman.mockito_kotlin.doReturn 13 | import com.nhaarman.mockito_kotlin.mock 14 | import com.youtubedl.R 15 | import org.hamcrest.CoreMatchers.allOf 16 | import org.junit.Assert.assertEquals 17 | import org.junit.Assert.assertTrue 18 | import org.junit.Before 19 | import org.junit.Rule 20 | import org.junit.Test 21 | import util.ViewModelUtil 22 | import util.rule.InjectedFragmentTestRule 23 | 24 | /** 25 | * Created by cuongpm on 1/29/19. 26 | */ 27 | 28 | class VideoPlayerFragmentTest { 29 | 30 | private lateinit var viewModelFactory: ViewModelProvider.Factory 31 | 32 | private lateinit var videoPlayerViewModel: VideoPlayerViewModel 33 | 34 | private lateinit var videoPlayerFragment: VideoPlayerFragment 35 | 36 | private val videoName = ObservableField("") 37 | 38 | private val videoUrl = ObservableField("") 39 | 40 | private lateinit var videoNameTest: String 41 | 42 | private lateinit var videoUrlTest: String 43 | 44 | private val screen = Screen() 45 | 46 | 47 | @get:Rule 48 | val uiRule = InjectedFragmentTestRule { 49 | it.viewModelFactory = viewModelFactory 50 | } 51 | 52 | @Before 53 | fun setup() { 54 | videoNameTest = "video_name" 55 | videoUrlTest = "http://video_url" 56 | videoPlayerViewModel = mock() 57 | viewModelFactory = ViewModelUtil.createFor(videoPlayerViewModel) 58 | doReturn(videoName).`when`(videoPlayerViewModel).videoName 59 | doReturn(videoUrl).`when`(videoPlayerViewModel).videoUrl 60 | } 61 | 62 | @Test 63 | fun initial_ui() { 64 | screen.start() 65 | screen.hasToolBar() 66 | screen.hasVideoView() 67 | screen.hasVideoPlayer() 68 | 69 | assertEquals(videoUrlTest, videoUrl.get()) 70 | assertEquals(videoNameTest, videoName.get()) 71 | } 72 | 73 | @Test 74 | fun press_back_button_should_close_activity() { 75 | screen.start() 76 | screen.pressBackButton() 77 | 78 | assertTrue(uiRule.activity.isFinishing) 79 | } 80 | 81 | inner class Screen { 82 | fun start() { 83 | val bundle = Bundle().apply { 84 | putString(VideoPlayerFragment.VIDEO_URL, videoUrlTest) 85 | putString(VideoPlayerFragment.VIDEO_NAME, videoNameTest) 86 | } 87 | videoPlayerFragment = VideoPlayerFragment().apply { 88 | arguments = bundle 89 | } 90 | uiRule.launchFragment(videoPlayerFragment) 91 | } 92 | 93 | fun hasToolBar() { 94 | onView(withId(R.id.toolbar)).check(matches(isDisplayed())) 95 | } 96 | 97 | fun hasVideoView() { 98 | onView(withId(R.id.video_view)).check(matches(isDisplayed())) 99 | } 100 | 101 | fun hasVideoPlayer() { 102 | onView(withId(R.id.tv_current_time)).check(matches(isDisplayed())) 103 | onView(withId(R.id.seek_bar)).check(matches(isDisplayed())) 104 | onView(withId(R.id.tv_total_time)).check(matches(isDisplayed())) 105 | onView(withId(R.id.iv_prev)).check(matches(isDisplayed())) 106 | onView(withId(R.id.iv_play)).check(matches(isDisplayed())) 107 | onView(withId(R.id.iv_next)).check(matches(isDisplayed())) 108 | } 109 | 110 | fun pressBackButton() { 111 | onView( 112 | allOf( 113 | isAssignableFrom(ImageButton::class.java), 114 | withParent(isAssignableFrom(Toolbar::class.java)) 115 | ) 116 | ).perform(click()) 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /versions.gradle: -------------------------------------------------------------------------------- 1 | ext.deps = [:] 2 | def versions = [:] 3 | versions.gradle_plugin = '3.4.0' 4 | versions.kotlin = "1.3.11" 5 | versions.arch_core = "1.1.1" 6 | versions.room = "1.1.1" 7 | versions.lifecycle = "1.1.1" 8 | versions.support = "28.0.0" 9 | versions.constraintlayout = "1.1.3" 10 | versions.multidex = "1.0.3" 11 | versions.dagger = "2.19" 12 | versions.test_runner = "1.0.2" 13 | versions.junit = "4.12" 14 | versions.espresso = "3.0.2" 15 | versions.mockito = "2.21.0" 16 | versions.mockitoKotlin = "1.6.0" 17 | versions.rxjava2 = "2.2.2" 18 | versions.rxandroid = "2.1.0" 19 | versions.retrofit = "2.3.0" 20 | versions.okhttp = "3.11.0" 21 | versions.stetho = "1.5.0" 22 | versions.glide = "4.8.0" 23 | versions.coveralls = "2.8.1" 24 | def deps = [:] 25 | 26 | def android = [:] 27 | android.multidex = "com.android.support:multidex:$versions.multidex" 28 | android.gradle_plugin = "com.android.tools.build:gradle:$versions.gradle_plugin" 29 | deps.android = android 30 | 31 | def support = [:] 32 | support.appcompat = "com.android.support:appcompat-v7:$versions.support" 33 | support.design = "com.android.support:design:$versions.support" 34 | support.constraintlayout = "com.android.support.constraint:constraint-layout:$versions.constraintlayout" 35 | deps.support = support 36 | 37 | def room = [:] 38 | room.runtime = "android.arch.persistence.room:runtime:$versions.room" 39 | room.compiler = "android.arch.persistence.room:compiler:$versions.room" 40 | room.rxjava2 = "android.arch.persistence.room:rxjava2:$versions.room" 41 | deps.room = room 42 | 43 | def lifecycle = [:] 44 | lifecycle.extensions = "android.arch.lifecycle:extensions:$versions.lifecycle" 45 | lifecycle.compiler = "android.arch.lifecycle:compiler:$versions.lifecycle" 46 | deps.lifecycle = lifecycle 47 | 48 | def arch_core = [:] 49 | arch_core.testing = "android.arch.core:core-testing:$versions.arch_core" 50 | deps.arch_core = arch_core 51 | 52 | def retrofit = [:] 53 | retrofit.runtime = "com.squareup.retrofit2:retrofit:$versions.retrofit" 54 | retrofit.gson = "com.squareup.retrofit2:converter-gson:$versions.retrofit" 55 | retrofit.rxjava2 = "com.squareup.retrofit2:adapter-rxjava2:$versions.retrofit" 56 | deps.retrofit = retrofit 57 | 58 | def okhttp = [:] 59 | okhttp.runtime = "com.squareup.okhttp3:okhttp:$versions.okhttp" 60 | okhttp.logging = "com.squareup.okhttp3:logging-interceptor:$versions.okhttp" 61 | deps.okhttp = okhttp 62 | 63 | def dagger = [:] 64 | dagger.runtime = "com.google.dagger:dagger:$versions.dagger" 65 | dagger.android = "com.google.dagger:dagger-android:$versions.dagger" 66 | dagger.android_support = "com.google.dagger:dagger-android-support:$versions.dagger" 67 | dagger.compiler = "com.google.dagger:dagger-compiler:$versions.dagger" 68 | dagger.android_processor = "com.google.dagger:dagger-android-processor:$versions.dagger" 69 | deps.dagger = dagger 70 | 71 | def kotlin = [:] 72 | kotlin.stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$versions.kotlin" 73 | kotlin.plugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin" 74 | kotlin.allopen = "org.jetbrains.kotlin:kotlin-allopen:$versions.kotlin" 75 | deps.kotlin = kotlin 76 | 77 | def glide = [:] 78 | glide.runtime = "com.github.bumptech.glide:glide:$versions.glide" 79 | glide.compiler = "com.github.bumptech.glide:compiler:$versions.glide" 80 | deps.glide = glide 81 | 82 | def testing = [:] 83 | testing.test_runner = "com.android.support.test:runner:$versions.test_runner" 84 | testing.junit = "junit:junit:$versions.junit" 85 | testing.mockito_core = "org.mockito:mockito-core:$versions.mockito" 86 | testing.mockito_android = "org.mockito:mockito-android:$versions.mockito" 87 | testing.mockito_kotlin = "com.nhaarman:mockito-kotlin:$versions.mockitoKotlin" 88 | testing.espresso_core = "com.android.support.test.espresso:espresso-core:$versions.espresso" 89 | testing.espresso_intents = "com.android.support.test.espresso:espresso-intents:$versions.espresso" 90 | deps.testing = testing 91 | 92 | def reactivex = [:] 93 | reactivex.rxjava2 = "io.reactivex.rxjava2:rxjava:$versions.rxjava2" 94 | reactivex.rxandroid = "io.reactivex.rxjava2:rxandroid:$versions.rxandroid" 95 | deps.reactivex = reactivex 96 | 97 | def stetho = [:] 98 | stetho.runtime = "com.facebook.stetho:stetho:$versions.stetho" 99 | stetho.okhttp = "com.facebook.stetho:stetho-okhttp3:$versions.stetho" 100 | deps.stetho = stetho 101 | 102 | def coveralls = [:] 103 | coveralls.plugin = "org.kt3k.gradle.plugin:coveralls-gradle-plugin:$versions.coveralls" 104 | deps.coveralls = coveralls 105 | 106 | ext.deps = deps 107 | 108 | def build_versions = [:] 109 | build_versions.min_sdk = 15 110 | build_versions.target_sdk = 28 111 | ext.build_versions = build_versions --------------------------------------------------------------------------------