diff --git a/.gitignore b/.gitignore index 56cc642..0bbdd00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,85 +1,20 @@ -# Built application files -*.apk -*.aar -*.ap_ -*.aab - -# Files for the ART/Dalvik VM -*.dex - -# Java class files -*.class - -# Generated files -bin/ -gen/ -out/ -# Uncomment the following line in case you need and you don't have the release build type files in your app -# release/ - -# Gradle files -.gradle/ -build/ - -# Local configuration file (sdk path, etc) -local.properties - -# Proguard folder generated by Eclipse -proguard/ - -# Log Files -*.log - -# Android Studio Navigation editor temp files -.navigation/ - -# Android Studio captures folder -captures/ - -# IntelliJ *.iml -.idea/workspace.xml -.idea/tasks.xml -.idea/gradle.xml -.idea/assetWizardSettings.xml -.idea/dictionaries -.idea/libraries -# Android Studio 3 in .gitignore file. -.idea/caches -.idea/modules.xml -# Comment next line if keeping position of elements in Navigation Editor is relevant for you -.idea/navEditor.xml - -# Keystore files -# Uncomment the following lines if you do not want to check your keystore files in. -#*.jks -#*.keystore - -# External native build folder generated in Android Studio 2.2 and later +.gradle +/.css +/.idea +/.img +/org** +/index* +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures .externalNativeBuild -.cxx/ - -# Google Services (e.g. APIs or Firebase) -# google-services.json - -# Freeline -freeline.py -freeline/ -freeline_project_description.json - -# fastlane -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots -fastlane/test_output -fastlane/readme.md - -# Version control -vcs.xml - -# lint -lint/intermediates/ -lint/generated/ -lint/outputs/ -lint/tmp/ -# lint/reports/ +.cxx +local.properties diff --git a/README.md b/README.md index ba54447..2297ca0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,16 @@ # TikTok-Downloader -Simple Open Source App to Download TikTok Videos using share link. +Simple Open Source App to Download TikTok Videos using share feature of the App. + +The idea is that some of the videos cannot be downloaded within the app, or one simply doesn't want tiktok grant access to external storage, but they can be downloaded from the Website. This small project automates that process, by levreging the share / other feature of the TikTok app. + +There is already plenty of applications in the Play Store doing the same so this is not released. Probably won't be updated as often as they are. However feel free to look over the inner workings of the code if your heath desires. + +If you wish to try it out, here is the QR code to download the application. + + + +There is also a medium post about the project, feel free to check it out here. (yet to be published and link added here) + +Here is another Secret Video QR code just for the curious minded + + diff --git a/app-release-v1-0-0.apk b/app-release-v1-0-0.apk new file mode 100644 index 0000000..02321b5 Binary files /dev/null and b/app-release-v1-0-0.apk differ diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..97869e2 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,78 @@ +*.iml +.gradle +.idea +/local.properties +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +release/ +*.jks +signing.config.gradle + +# ktlint +!.idea/codeStyles +!.idea/inspectionProfiles + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### Android template +# Built application files +*.apk +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..10102fa --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,91 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "kotlin-kapt" +} +apply from: 'signing.config.gradle' + +android { + compileSdkVersion 30 + buildToolsVersion "30.0.2" + + defaultConfig { + applicationId "org.fnives.tiktokdownloader" + minSdkVersion 23 + targetSdkVersion 30 + versionCode 1 + versionName "1.0.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + signingConfigs { + release { + keyAlias "$KEY_ALIAS" + keyPassword "$KEY_PASSWORD" + storeFile file("$KEYSTORE_FILE") + storePassword "$STORE_PASSWORD" + } + } + buildTypes { + debug { + versionNameSuffix "-dev" + debuggable true + shrinkResources false + minifyEnabled false + } + release { + signingConfig signingConfigs.release + debuggable false + shrinkResources true + minifyEnabled true + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + lintOptions { + abortOnError true + } +} + +dependencies { + + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "androidx.core:core-ktx:1.3.2" + implementation "androidx.appcompat:appcompat:1.2.0" + implementation "androidx.activity:activity-ktx:1.2.0-beta01" + implementation "androidx.fragment:fragment-ktx:1.3.0-beta01" + implementation "com.google.android.material:material:1.2.1" + implementation "androidx.constraintlayout:constraintlayout:2.0.4" + + // Coroutines + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.0-M1" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0" + implementation "androidx.fragment:fragment-ktx:1.2.5" + + implementation "com.github.bumptech.glide:glide:4.11.0" + kapt "com.github.bumptech.glide:compiler:4.11.0" + + implementation "com.squareup.retrofit2:retrofit:2.9.0" + implementation "com.squareup.okhttp3:logging-interceptor:4.7.2" + implementation 'com.pierfrancescosoffritti.androidyoutubeplayer:core:10.0.5' + + testImplementation "org.junit.jupiter:junit-jupiter-engine:5.7.0" + testImplementation "org.junit.jupiter:junit-jupiter-params:5.7.0" + testImplementation 'com.jraska.livedata:testing-ktx:1.1.2' + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" + testImplementation "com.squareup.okhttp3:mockwebserver:4.2.1" + testImplementation "commons-io:commons-io:2.8.0" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.0-M1" + testImplementation "androidx.arch.core:core-testing:2.1.0" + + androidTestImplementation "androidx.test.ext:junit:1.1.2" + androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/org/fnives/tiktokdownloader/ExampleInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/tiktokdownloader/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..2f76c0a --- /dev/null +++ b/app/src/androidTest/java/org/fnives/tiktokdownloader/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package org.fnives.tiktokdownloader + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("org.fnives.tiktokdownloader", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b79b473 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/App.kt b/app/src/main/java/org/fnives/tiktokdownloader/App.kt new file mode 100644 index 0000000..0aa344b --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/App.kt @@ -0,0 +1,12 @@ +package org.fnives.tiktokdownloader + +import android.app.Application +import org.fnives.tiktokdownloader.di.ServiceLocator + +class App : Application() { + + override fun onCreate() { + super.onCreate() + ServiceLocator.start(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/CaptchaTimeoutLocalSource.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/CaptchaTimeoutLocalSource.kt new file mode 100644 index 0000000..6d910bb --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/CaptchaTimeoutLocalSource.kt @@ -0,0 +1,16 @@ +package org.fnives.tiktokdownloader.data.local + +import org.fnives.tiktokdownloader.data.local.persistent.SharedPreferencesManager + +class CaptchaTimeoutLocalSource( + private val appSharedPreferencesManager: SharedPreferencesManager, + private val timeOutInMillis : Long +) { + + fun isInCaptchaTimeout(): Boolean = + System.currentTimeMillis() < appSharedPreferencesManager.captchaTimeoutUntil + + fun onCaptchaResponseReceived() { + appSharedPreferencesManager.captchaTimeoutUntil = System.currentTimeMillis() + timeOutInMillis + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/VideoDownloadedLocalSource.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/VideoDownloadedLocalSource.kt new file mode 100644 index 0000000..0941333 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/VideoDownloadedLocalSource.kt @@ -0,0 +1,73 @@ +package org.fnives.tiktokdownloader.data.local + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import org.fnives.tiktokdownloader.data.local.exceptions.StorageException +import org.fnives.tiktokdownloader.data.local.persistent.SharedPreferencesManager +import org.fnives.tiktokdownloader.data.local.persistent.addTimeAtStart +import org.fnives.tiktokdownloader.data.local.persistent.getTimeAndOriginal +import org.fnives.tiktokdownloader.data.local.persistent.joinNormalized +import org.fnives.tiktokdownloader.data.local.persistent.separateIntoDenormalized +import org.fnives.tiktokdownloader.data.local.save.video.SaveVideoFile +import org.fnives.tiktokdownloader.data.local.verify.exists.VerifyFileForUriExists +import org.fnives.tiktokdownloader.data.model.VideoDownloaded +import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile + +class VideoDownloadedLocalSource( + private val saveVideoFile: SaveVideoFile, + private val sharedPreferencesManagerImpl: SharedPreferencesManager, + private val verifyFileForUriExists: VerifyFileForUriExists +) { + + val savedVideos: Flow> + get() = sharedPreferencesManagerImpl.downloadedVideosFlow.map { stringSet -> + val currentStored = stringSet.map { savedText -> + val (time, data) = savedText.getTimeAndOriginal() + Triple(savedText, time, data) + } + .sortedByDescending { it.second } + .map { it.first to it.third.asVideoDownloaded() } + + val filtered = currentStored.filter { verifyFileForUriExists(it.second.uri) } + if (currentStored != filtered) { + sharedPreferencesManagerImpl.downloadedVideos = filtered.map { it.first }.toSet() + } + filtered.map { it.second } + } + .distinctUntilChanged() + + @Throws(StorageException::class) + fun saveVideo(videoInProcess: VideoInSavingIntoFile): VideoDownloaded { + val fileName = videoInProcess.fileName() + val uri = try { + saveVideoFile("TikTok_Downloader", fileName, videoInProcess) + } catch (throwable: Throwable) { + throw StorageException(cause = throwable) + } + uri ?: throw StorageException("Uri couldn't be created") + + val result = VideoDownloaded(id = videoInProcess.id, url = videoInProcess.url, uri = uri) + saveVideoDownloaded(result) + + return result + } + + private fun saveVideoDownloaded(videoDownloaded: VideoDownloaded) { + sharedPreferencesManagerImpl.downloadedVideos = sharedPreferencesManagerImpl.downloadedVideos + .plus(videoDownloaded.asString().addTimeAtStart()) + } + + companion object { + + private fun VideoDownloaded.asString(): String = + listOf(id, url, uri).joinNormalized() + + private fun String.asVideoDownloaded(): VideoDownloaded = + separateIntoDenormalized().let { (id, url, uri) -> + VideoDownloaded(id = id, url = url, uri = uri) + } + + private fun VideoInSavingIntoFile.fileName() = "$id.${contentType?.subType ?: "mp4"}" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/VideoInPendingLocalSource.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/VideoInPendingLocalSource.kt new file mode 100644 index 0000000..ed4d856 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/VideoInPendingLocalSource.kt @@ -0,0 +1,44 @@ +package org.fnives.tiktokdownloader.data.local + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.fnives.tiktokdownloader.data.local.persistent.SharedPreferencesManager +import org.fnives.tiktokdownloader.data.local.persistent.addTimeAtStart +import org.fnives.tiktokdownloader.data.local.persistent.getTimeAndOriginal +import org.fnives.tiktokdownloader.data.local.persistent.joinNormalized +import org.fnives.tiktokdownloader.data.local.persistent.separateIntoDenormalized +import org.fnives.tiktokdownloader.data.model.VideoInPending + +class VideoInPendingLocalSource( + private val sharedPreferencesManager: SharedPreferencesManager, +) { + val pendingVideos: Flow> + get() = sharedPreferencesManager.pendingVideosFlow + .map { stringSet -> + stringSet.asSequence().map { timeThenUrl -> timeThenUrl.getTimeAndOriginal() } + .sortedByDescending { it.first } + .map { it.second } + .map { it.asVideoInPending() } + .toList() + } + + fun saveUrlIntoQueue(videoInPending: VideoInPending) { + sharedPreferencesManager.pendingVideos = sharedPreferencesManager.pendingVideos + .plus(videoInPending.asString().addTimeAtStart()) + } + + fun removeVideoFromQueue(videoInPending: VideoInPending) { + sharedPreferencesManager.pendingVideos = sharedPreferencesManager.pendingVideos + .filterNot { it.getTimeAndOriginal().second.asVideoInPending() == videoInPending } + .toSet() + } + + companion object { + private fun VideoInPending.asString(): String = listOf(id, url).joinNormalized() + + private fun String.asVideoInPending(): VideoInPending = + separateIntoDenormalized().let { (id, url) -> + VideoInPending(id = id, url = url) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/VideoInProgressLocalSource.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/VideoInProgressLocalSource.kt new file mode 100644 index 0000000..2e28822 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/VideoInProgressLocalSource.kt @@ -0,0 +1,22 @@ +package org.fnives.tiktokdownloader.data.local + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.fnives.tiktokdownloader.data.model.VideoInProgress + +class VideoInProgressLocalSource { + + private val _videoInProcessFlow = MutableStateFlow(null) + val videoInProcessFlow: Flow = _videoInProcessFlow + + fun markVideoAsInProgress(videoInPending: VideoInPending) { + _videoInProcessFlow.value = VideoInProgress(id = videoInPending.id, url = videoInPending.url) + } + + fun removeVideoAsInProgress(videoInPending: VideoInPending) { + if (_videoInProcessFlow.value?.id == videoInPending.id) { + _videoInProcessFlow.value = null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/exceptions/StorageException.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/exceptions/StorageException.kt new file mode 100644 index 0000000..59dd9d6 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/exceptions/StorageException.kt @@ -0,0 +1,3 @@ +package org.fnives.tiktokdownloader.data.local.exceptions + +class StorageException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/persistent/SerializationHelper.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/persistent/SerializationHelper.kt new file mode 100644 index 0000000..d34d35b --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/persistent/SerializationHelper.kt @@ -0,0 +1,22 @@ +package org.fnives.tiktokdownloader.data.local.persistent + +private const val SEPARATOR = ";" +private const val TIME_SEPARATOR = '_' + +fun String.normalize() = replace(SEPARATOR, "\\$SEPARATOR") +fun String.denormalize() = replace("\\$SEPARATOR", SEPARATOR) + +fun List.joinNormalized() : String = + map { it.normalize() }.joinToString("$SEPARATOR$SEPARATOR") + +fun String.separateIntoDenormalized() : List = + split("$SEPARATOR$SEPARATOR").map { it.denormalize() } + +fun String.addTimeAtStart(index: Int = 0) = + "${System.currentTimeMillis() + index}$TIME_SEPARATOR$this" + +fun String.getTimeAndOriginal(): Pair { + val time = takeWhile { it != TIME_SEPARATOR }.toLong() + val original = dropWhile { it != TIME_SEPARATOR }.drop(1) + return time to original +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/persistent/SharedPreferencesManager.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/persistent/SharedPreferencesManager.kt new file mode 100644 index 0000000..45c29d1 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/persistent/SharedPreferencesManager.kt @@ -0,0 +1,11 @@ +package org.fnives.tiktokdownloader.data.local.persistent + +import kotlinx.coroutines.flow.Flow + +interface SharedPreferencesManager { + var captchaTimeoutUntil: Long + var pendingVideos: Set + val pendingVideosFlow: Flow> + var downloadedVideos: Set + val downloadedVideosFlow: Flow> +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/persistent/SharedPreferencesManagerImpl.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/persistent/SharedPreferencesManagerImpl.kt new file mode 100644 index 0000000..a5b5246 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/persistent/SharedPreferencesManagerImpl.kt @@ -0,0 +1,72 @@ +package org.fnives.tiktokdownloader.data.local.persistent + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlin.properties.ReadOnlyProperty +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +class SharedPreferencesManagerImpl private constructor(private val sharedPreferences: SharedPreferences) : SharedPreferencesManager { + + override var captchaTimeoutUntil: Long by LongDelegate(CAPTCHA_TIMEOUT_KEY) + override var pendingVideos: Set by StringSetDelegate(PENDING_VIDEO_KEY) + override val pendingVideosFlow by StringSetFlowDelegate(PENDING_VIDEO_KEY) + override var downloadedVideos: Set by StringSetDelegate(DOWNLOADED_VIDEO_KEY) + override val downloadedVideosFlow by StringSetFlowDelegate(DOWNLOADED_VIDEO_KEY) + + class LongDelegate(private val key: String) : ReadWriteProperty { + override fun setValue(thisRef: SharedPreferencesManagerImpl, property: KProperty<*>, value: Long) { + thisRef.sharedPreferences.edit().putLong(key, value).apply() + } + + override fun getValue(thisRef: SharedPreferencesManagerImpl, property: KProperty<*>): Long = + thisRef.sharedPreferences.getLong(key, 0) + } + + class StringSetDelegate(private val key: String) : ReadWriteProperty> { + override fun setValue(thisRef: SharedPreferencesManagerImpl, property: KProperty<*>, value: Set) { + thisRef.sharedPreferences.edit().putStringSet(key, value).apply() + } + + override fun getValue(thisRef: SharedPreferencesManagerImpl, property: KProperty<*>): Set = + thisRef.sharedPreferences.getStringSet(key, emptySet()).orEmpty() + } + + class StringSetFlowDelegate(private val key: String) : ReadOnlyProperty>> { + override fun getValue(thisRef: SharedPreferencesManagerImpl, property: KProperty<*>): Flow> = + callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> + offer(thisRef.getValues()) + } + thisRef.sharedPreferences.registerOnSharedPreferenceChangeListener(listener) + offer(thisRef.getValues()) + + awaitClose { + thisRef.sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) + } + } + + private fun SharedPreferencesManagerImpl.getValues() : Set = + sharedPreferences.getStringSet(key, emptySet()).orEmpty() + + } + + companion object { + + private const val SHARED_PREF_NAME = "SHARED_PREF_NAME" + private const val CAPTCHA_TIMEOUT_KEY = "CAPTCHA_TIMEOUT_KEY" + private const val PENDING_VIDEO_KEY = "PENDING_VIDEO_KEY" + private const val DOWNLOADED_VIDEO_KEY = "DOWNLOADED_VIDEO_KEY" + + fun create(context: Context): SharedPreferencesManagerImpl = + SharedPreferencesManagerImpl( + context.getSharedPreferences( + SHARED_PREF_NAME, + Context.MODE_PRIVATE + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/save/video/FileExtensions.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/save/video/FileExtensions.kt new file mode 100644 index 0000000..10c0005 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/save/video/FileExtensions.kt @@ -0,0 +1,34 @@ +package org.fnives.tiktokdownloader.data.local.save.video + +import android.content.ContentValues +import android.provider.MediaStore +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.TimeUnit + +fun InputStream.safeCopyInto(outputStream: OutputStream) { + outputStream.use { out -> + use { input -> + input.copyTo(out) + } + } +} + +fun buildDefaultVideoContentValues( + fileName: String, + contentType: String?, + update: ContentValues.() -> Unit +): ContentValues { + val contentValues = ContentValues() + update(contentValues) + contentValues.put(MediaStore.Video.Media.TITLE, fileName) + contentValues.put(MediaStore.Video.Media.DISPLAY_NAME, fileName) + contentValues.put(MediaStore.Video.Media.MIME_TYPE, contentType) + val dateAddedInSeconds = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + contentValues.put(MediaStore.Video.Media.DATE_ADDED, dateAddedInSeconds) + + return contentValues +} + +const val IS_PENDING_YES = 1 +const val IS_PENDING_NO = 0 \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/save/video/SaveVideoFile.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/save/video/SaveVideoFile.kt new file mode 100644 index 0000000..d5616cf --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/save/video/SaveVideoFile.kt @@ -0,0 +1,22 @@ +package org.fnives.tiktokdownloader.data.local.save.video + +import android.content.ContentResolver +import android.os.Build +import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile +import java.io.IOException + +interface SaveVideoFile { + + @Throws(IOException::class) + operator fun invoke(directory: String, fileName: String, videoInProcess: VideoInSavingIntoFile): String? + + class Factory(private val contentResolver: ContentResolver) { + + fun create(): SaveVideoFile = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + SaveVideoFileApi29(contentResolver) + } else { + SaveVideoFileBelowApi29(contentResolver) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/save/video/SaveVideoFileApi29.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/save/video/SaveVideoFileApi29.kt new file mode 100644 index 0000000..3bf3189 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/save/video/SaveVideoFileApi29.kt @@ -0,0 +1,39 @@ +package org.fnives.tiktokdownloader.data.local.save.video + +import android.content.ContentResolver +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.provider.MediaStore.Video.Media +import androidx.annotation.RequiresApi +import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile +import java.io.FileOutputStream + +class SaveVideoFileApi29(private val resolver: ContentResolver) : SaveVideoFile { + + @RequiresApi(Build.VERSION_CODES.Q) + override fun invoke(directory: String, fileName: String, videoInProcess: VideoInSavingIntoFile): String? { + val values = buildDefaultVideoContentValues( + fileName = fileName, + contentType = videoInProcess.contentType?.toString() + ) { + put(Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES + "/" + directory) + put(Media.DATE_TAKEN, System.currentTimeMillis()) + put(Media.IS_PENDING, IS_PENDING_YES) + } + + val collection = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val uriSavedVideo = resolver.insert(collection, values) + if (uriSavedVideo != null) { + resolver.openFileDescriptor(uriSavedVideo, "w") + ?.fileDescriptor + ?.let(::FileOutputStream) + ?.let(videoInProcess.byteStream::safeCopyInto) + + values.clear() + values.put(Media.IS_PENDING, IS_PENDING_NO) + resolver.update(uriSavedVideo, values, null, null) + } + return uriSavedVideo?.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/save/video/SaveVideoFileBelowApi29.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/save/video/SaveVideoFileBelowApi29.kt new file mode 100644 index 0000000..0b8b19b --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/save/video/SaveVideoFileBelowApi29.kt @@ -0,0 +1,31 @@ +package org.fnives.tiktokdownloader.data.local.save.video + +import android.content.ContentResolver +import android.os.Environment +import android.provider.MediaStore +import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile +import java.io.File + +@Suppress("DEPRECATION") +class SaveVideoFileBelowApi29(private val resolver: ContentResolver) : SaveVideoFile { + + override fun invoke(directory: String, fileName: String, videoInProcess: VideoInSavingIntoFile): String? { + val externalDirectory = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES) + externalDirectory.mkdir() + val videoDirectory = File(externalDirectory, directory) + videoDirectory.mkdir() + val videoFile = File(videoDirectory, fileName) + videoFile.createNewFile() + videoInProcess.byteStream.safeCopyInto(videoFile.outputStream()) + + val values = buildDefaultVideoContentValues( + fileName = fileName, + contentType = videoInProcess.contentType?.toString() + ) { + put(MediaStore.Video.Media.DATA, videoFile.path) + } + + return resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)?.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/verify/exists/VerifyFileForUriExists.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/verify/exists/VerifyFileForUriExists.kt new file mode 100644 index 0000000..6c781a8 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/verify/exists/VerifyFileForUriExists.kt @@ -0,0 +1,6 @@ +package org.fnives.tiktokdownloader.data.local.verify.exists + +interface VerifyFileForUriExists { + + suspend operator fun invoke(uri: String): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/local/verify/exists/VerifyFileForUriExistsImpl.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/local/verify/exists/VerifyFileForUriExistsImpl.kt new file mode 100644 index 0000000..d28bbb0 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/local/verify/exists/VerifyFileForUriExistsImpl.kt @@ -0,0 +1,17 @@ +package org.fnives.tiktokdownloader.data.local.verify.exists + +import android.content.ContentResolver +import android.provider.BaseColumns +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class VerifyFileForUriExistsImpl( + private val contentResolver: ContentResolver +) : VerifyFileForUriExists { + + override suspend fun invoke(uri: String): Boolean = withContext(Dispatchers.IO) { + true == contentResolver.query(uri.toUri(), arrayOf(BaseColumns._ID), null, null, null) + ?.use { it.moveToFirst() } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/model/ProcessState.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/model/ProcessState.kt new file mode 100644 index 0000000..8d8cea6 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/model/ProcessState.kt @@ -0,0 +1,13 @@ +package org.fnives.tiktokdownloader.data.model + +sealed class ProcessState { + + data class Processing(val videoInPending: VideoInPending) : ProcessState() + data class Processed(val videoDownloaded: VideoDownloaded) : ProcessState() + object NetworkError : ProcessState() + object ParsingError : ProcessState() + object CaptchaError : ProcessState() + object UnknownError : ProcessState() + object StorageError : ProcessState() + object Finished: ProcessState() +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoDownloaded.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoDownloaded.kt new file mode 100644 index 0000000..32bf117 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoDownloaded.kt @@ -0,0 +1,3 @@ +package org.fnives.tiktokdownloader.data.model + +data class VideoDownloaded(val id: String, val url: String, val uri: String) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoInPending.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoInPending.kt new file mode 100644 index 0000000..a44385c --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoInPending.kt @@ -0,0 +1,3 @@ +package org.fnives.tiktokdownloader.data.model + +data class VideoInPending(val id: String, val url: String) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoInProgress.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoInProgress.kt new file mode 100644 index 0000000..bd9663e --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoInProgress.kt @@ -0,0 +1,3 @@ +package org.fnives.tiktokdownloader.data.model + +data class VideoInProgress(val id: String, val url: String) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoInSavingIntoFile.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoInSavingIntoFile.kt new file mode 100644 index 0000000..73a3bc9 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoInSavingIntoFile.kt @@ -0,0 +1,15 @@ +package org.fnives.tiktokdownloader.data.model + +import java.io.InputStream + +class VideoInSavingIntoFile( + val id: String, + val url: String, + val contentType: ContentType?, + val byteStream: InputStream +) { + data class ContentType(val type: String, val subType: String) { + + override fun toString(): String = "$type/$subType" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoState.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoState.kt new file mode 100644 index 0000000..e374c33 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/model/VideoState.kt @@ -0,0 +1,24 @@ +package org.fnives.tiktokdownloader.data.model + +sealed class VideoState { + + abstract val id: String + abstract val url: String + + abstract override fun equals(other: Any?): Boolean + + abstract override fun hashCode(): Int + + data class InProcess(val videoInProcess: VideoInProgress) : VideoState() { + override val id: String get() = videoInProcess.id + override val url: String get() = videoInProcess.url + } + data class InPending(val videoInPending: VideoInPending) : VideoState() { + override val id: String get() = videoInPending.id + override val url: String get() = videoInPending.url + } + data class Downloaded(val videoDownloaded: VideoDownloaded) : VideoState() { + override val id: String get() = videoDownloaded.id + override val url: String get() = videoDownloaded.url + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSource.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSource.kt new file mode 100644 index 0000000..4e15411 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSource.kt @@ -0,0 +1,50 @@ +package org.fnives.tiktokdownloader.data.network + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile +import org.fnives.tiktokdownloader.data.network.exceptions.CaptchaRequiredException +import org.fnives.tiktokdownloader.data.network.exceptions.NetworkException +import org.fnives.tiktokdownloader.data.network.exceptions.ParsingException +import org.fnives.tiktokdownloader.data.network.session.CookieStore + +class TikTokDownloadRemoteSource( + private val delayBeforeRequest: Long, + private val service: TikTokRetrofitService, + private val cookieStore: CookieStore +) { + + @Throws(ParsingException::class, NetworkException::class, CaptchaRequiredException::class) + suspend fun getVideo(videoInPending: VideoInPending): VideoInSavingIntoFile = withContext(Dispatchers.IO) { + cookieStore.clear() + wrapIntoProperException { + delay(delayBeforeRequest) // added just so captcha trigger may not happen + val actualUrl = service.getContentActualUrlAndCookie(videoInPending.url) + delay(delayBeforeRequest) // added just so captcha trigger may not happen + val videoUrl = service.getVideoUrl(actualUrl.url) + delay(delayBeforeRequest) // added just so captcha trigger may not happen + val response = service.getVideo(videoUrl.videoFileUrl) + + VideoInSavingIntoFile( + id = videoInPending.id, + url = videoInPending.url, + contentType = response.mediaType?.let { VideoInSavingIntoFile.ContentType(it.type, it.subtype) }, + byteStream = response.videoInputStream + ) + } + } + + @Throws(ParsingException::class, NetworkException::class) + private suspend fun wrapIntoProperException(request: suspend () -> T): T = + try { + request() + } catch (parsingException: ParsingException) { + throw parsingException + } catch (captchaRequiredException: CaptchaRequiredException) { + throw captchaRequiredException + } catch (throwable: Throwable) { + throw NetworkException(cause = throwable) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/TikTokRetrofitService.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/TikTokRetrofitService.kt new file mode 100644 index 0000000..8db9442 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/TikTokRetrofitService.kt @@ -0,0 +1,48 @@ +package org.fnives.tiktokdownloader.data.network + +import org.fnives.tiktokdownloader.data.network.parsing.response.ActualVideoPageUrl +import org.fnives.tiktokdownloader.data.network.parsing.response.VideoFileUrl +import org.fnives.tiktokdownloader.data.network.parsing.response.VideoResponse +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.Url + +interface TikTokRetrofitService { + + @GET + @Headers( + "Origin: https://www.tiktok.com", + "Referer: https://www.tiktok.com/", + "Sec-Fetch-Dest: empty", + "Sec-Fetch-Mode: cors", + "Sec-Fetch-Site: cross-site", + "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36" + ) + suspend fun getContentActualUrlAndCookie(@Url tiktokLink: String): ActualVideoPageUrl + + @GET + @Headers( + "Origin: https://www.tiktok.com", + "Referer: https://www.tiktok.com/", + "Sec-Fetch-Dest: empty", + "Sec-Fetch-Mode: cors", + "Sec-Fetch-Site: cross-site", + "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36" + ) + suspend fun getVideoUrl(@Url tiktokLink: String): VideoFileUrl + + @Headers( + "Referer: https://www.tiktok.com/", + "Sec-Fetch-Dest: video", + "Sec-Fetch-Mode: no-cors", + "Sec-Fetch-Site: cross-site", + "Accept: */*", + "Accept-Encoding: identity;q=1, *;q=0", + "Accept-Language: en-US,en;q=0.9,hu-HU;q=0.8,hu;q=0.7,ro;q=0.6", + "Connection: keep-alive", + "Range: bytes=0-", + "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36" + ) + @GET + suspend fun getVideo(@Url videoUrl: String): VideoResponse +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/CaptchaRequiredException.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/CaptchaRequiredException.kt new file mode 100644 index 0000000..98348a6 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/CaptchaRequiredException.kt @@ -0,0 +1,3 @@ +package org.fnives.tiktokdownloader.data.network.exceptions + +class CaptchaRequiredException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/NetworkException.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/NetworkException.kt new file mode 100644 index 0000000..4187df9 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/NetworkException.kt @@ -0,0 +1,3 @@ +package org.fnives.tiktokdownloader.data.network.exceptions + +class NetworkException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/ParsingException.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/ParsingException.kt new file mode 100644 index 0000000..8e23218 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/ParsingException.kt @@ -0,0 +1,5 @@ +package org.fnives.tiktokdownloader.data.network.exceptions + +import java.io.IOException + +class ParsingException(message: String? = null, cause: Throwable? = null) : IOException(message, cause) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/TikTokWebPageConverterFactory.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/TikTokWebPageConverterFactory.kt new file mode 100644 index 0000000..caed6c5 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/TikTokWebPageConverterFactory.kt @@ -0,0 +1,28 @@ +package org.fnives.tiktokdownloader.data.network.parsing + +import okhttp3.ResponseBody +import org.fnives.tiktokdownloader.data.network.parsing.converter.ActualVideoPageUrlConverter +import org.fnives.tiktokdownloader.data.network.parsing.converter.ThrowIfIsCaptchaResponse +import org.fnives.tiktokdownloader.data.network.parsing.converter.VideoFileUrlConverter +import org.fnives.tiktokdownloader.data.network.parsing.converter.VideoResponseConverter +import org.fnives.tiktokdownloader.data.network.parsing.response.ActualVideoPageUrl +import org.fnives.tiktokdownloader.data.network.parsing.response.VideoFileUrl +import org.fnives.tiktokdownloader.data.network.parsing.response.VideoResponse +import retrofit2.Converter +import retrofit2.Retrofit +import java.lang.reflect.Type + +class TikTokWebPageConverterFactory(private val throwIfIsCaptchaResponse: ThrowIfIsCaptchaResponse) : Converter.Factory() { + + override fun responseBodyConverter( + type: Type, + annotations: Array, + retrofit: Retrofit + ): Converter? = + when (type) { + ActualVideoPageUrl::class.java -> ActualVideoPageUrlConverter(throwIfIsCaptchaResponse) + VideoFileUrl::class.java -> VideoFileUrlConverter(throwIfIsCaptchaResponse) + VideoResponse::class.java -> VideoResponseConverter() + else -> super.responseBodyConverter(type, annotations, retrofit) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ActualVideoPageUrlConverter.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ActualVideoPageUrlConverter.kt new file mode 100644 index 0000000..b7f3d3e --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ActualVideoPageUrlConverter.kt @@ -0,0 +1,19 @@ +package org.fnives.tiktokdownloader.data.network.parsing.converter + +import okhttp3.ResponseBody +import org.fnives.tiktokdownloader.data.network.exceptions.CaptchaRequiredException +import org.fnives.tiktokdownloader.data.network.parsing.response.ActualVideoPageUrl +import kotlin.jvm.Throws + +class ActualVideoPageUrlConverter( + private val throwIfIsCaptchaResponse: ThrowIfIsCaptchaResponse +) : ParsingExceptionThrowingConverter() { + + @Throws(IndexOutOfBoundsException::class, CaptchaRequiredException::class) + override fun convertSafely(responseBody: ResponseBody): ActualVideoPageUrl? = + responseBody.string() + .also(throwIfIsCaptchaResponse::invoke) + .split("rel=\"canonical\" href=\"")[1] + .split("\"")[0] + .let(::ActualVideoPageUrl) +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ParsingExceptionThrowingConverter.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ParsingExceptionThrowingConverter.kt new file mode 100644 index 0000000..ee96af6 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ParsingExceptionThrowingConverter.kt @@ -0,0 +1,21 @@ +package org.fnives.tiktokdownloader.data.network.parsing.converter + +import okhttp3.ResponseBody +import org.fnives.tiktokdownloader.data.network.exceptions.CaptchaRequiredException +import org.fnives.tiktokdownloader.data.network.exceptions.ParsingException +import retrofit2.Converter + +abstract class ParsingExceptionThrowingConverter : Converter { + + @Throws(ParsingException::class, CaptchaRequiredException::class) + final override fun convert(value: ResponseBody): T? = + try { + convertSafely(value) + } catch (captchaRequiredException: CaptchaRequiredException) { + throw captchaRequiredException + } catch (throwable: Throwable) { + throw ParsingException(cause = throwable) + } + + abstract fun convertSafely(responseBody: ResponseBody): T? +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ThrowIfIsCaptchaResponse.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ThrowIfIsCaptchaResponse.kt new file mode 100644 index 0000000..0d80052 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ThrowIfIsCaptchaResponse.kt @@ -0,0 +1,16 @@ +package org.fnives.tiktokdownloader.data.network.parsing.converter + +import org.fnives.tiktokdownloader.data.network.exceptions.CaptchaRequiredException +import kotlin.jvm.Throws + +class ThrowIfIsCaptchaResponse { + + @Throws(CaptchaRequiredException::class) + fun invoke(html: String) { + if (html.isEmpty()) { + throw CaptchaRequiredException("Empty body") + } else if (html.contains("captcha.js")) { + throw CaptchaRequiredException("Contains Captcha keyword") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/VideoFileUrlConverter.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/VideoFileUrlConverter.kt new file mode 100644 index 0000000..7bfb433 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/VideoFileUrlConverter.kt @@ -0,0 +1,48 @@ +package org.fnives.tiktokdownloader.data.network.parsing.converter + +import okhttp3.ResponseBody +import org.fnives.tiktokdownloader.data.network.exceptions.CaptchaRequiredException +import org.fnives.tiktokdownloader.data.network.parsing.response.VideoFileUrl +import kotlin.jvm.Throws + +class VideoFileUrlConverter( + private val throwIfIsCaptchaResponse: ThrowIfIsCaptchaResponse +) : ParsingExceptionThrowingConverter() { + + @Throws(IllegalArgumentException::class, IndexOutOfBoundsException::class, CaptchaRequiredException::class) + override fun convertSafely(responseBody: ResponseBody): VideoFileUrl? { + val html = responseBody.string().also(throwIfIsCaptchaResponse::invoke) + val url = tryToParseDownloadLink(html) + ?: tryToParseVideoSrc(html) + ?: throw IllegalArgumentException("Couldn't parse url from HTML: $html") + + return VideoFileUrl(url) + } + + companion object { + + private fun tryToParseDownloadLink(html: String): String? = + if (html.contains("\"playAddr\"")) { + html.split("\"playAddr\"")[1] + .dropWhile { it != '\"' }.drop(1) + .takeWhile { it != '\"' } + .replace("\\u0026", "&") + } else { + null + } + + private fun tryToParseVideoSrc(html: String): String? = + if (html.contains("")[0] + .split("src")[1] + .dropWhile { it != '=' } + .dropWhile { it != '\"' }.drop(1) + .takeWhile { it != '\"' } + .replace("\\u0026", "&") + } else { + null + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/VideoResponseConverter.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/VideoResponseConverter.kt new file mode 100644 index 0000000..ba1ac8d --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/VideoResponseConverter.kt @@ -0,0 +1,10 @@ +package org.fnives.tiktokdownloader.data.network.parsing.converter + +import okhttp3.ResponseBody +import org.fnives.tiktokdownloader.data.network.parsing.response.VideoResponse + +class VideoResponseConverter : ParsingExceptionThrowingConverter() { + + override fun convertSafely(value: ResponseBody): VideoResponse? = + VideoResponse(value.contentType(), value.byteStream()) +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/response/ActualVideoPageUrl.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/response/ActualVideoPageUrl.kt new file mode 100644 index 0000000..60296eb --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/response/ActualVideoPageUrl.kt @@ -0,0 +1,3 @@ +package org.fnives.tiktokdownloader.data.network.parsing.response + +class ActualVideoPageUrl(val url: String) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/response/VideoFileUrl.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/response/VideoFileUrl.kt new file mode 100644 index 0000000..7349559 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/response/VideoFileUrl.kt @@ -0,0 +1,3 @@ +package org.fnives.tiktokdownloader.data.network.parsing.response + +class VideoFileUrl(val videoFileUrl: String) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/response/VideoResponse.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/response/VideoResponse.kt new file mode 100644 index 0000000..cc3adc5 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/response/VideoResponse.kt @@ -0,0 +1,6 @@ +package org.fnives.tiktokdownloader.data.network.parsing.response + +import okhttp3.MediaType +import java.io.InputStream + +class VideoResponse(val mediaType: MediaType?, val videoInputStream: InputStream) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/session/CookieSavingInterceptor.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/session/CookieSavingInterceptor.kt new file mode 100644 index 0000000..d7c4ba0 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/session/CookieSavingInterceptor.kt @@ -0,0 +1,41 @@ +package org.fnives.tiktokdownloader.data.network.session + +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +class CookieSavingInterceptor : Interceptor, CookieStore { + + override var cookie: String? = null + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val savedCookies = cookie + val request = if (savedCookies == null) { + chain.request() + } else { + chain.request().newBuilder().header("Cookie", savedCookies).build() + } + + val response = chain.proceed(request) + parseCookie(response)?.let { + this.cookie = it + } + return response + } + + override fun clear() { + cookie = null + } + + companion object { + private fun parseCookie(response: Response): String? { + val allCookiesToBeSet = response.headers.toMultimap()["Set-Cookie"] ?: return null + val cookieWithoutExtraData = allCookiesToBeSet + .map { cookieWithExtra -> cookieWithExtra.takeWhile { it != ';' } } + .toSet() + + return cookieWithoutExtraData.joinToString("; ") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/session/CookieStore.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/session/CookieStore.kt new file mode 100644 index 0000000..32343b5 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/session/CookieStore.kt @@ -0,0 +1,8 @@ +package org.fnives.tiktokdownloader.data.network.session + +interface CookieStore { + + var cookie: String? + + fun clear() +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/usecase/AddVideoToQueueUseCase.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/usecase/AddVideoToQueueUseCase.kt new file mode 100644 index 0000000..1f79ca7 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/usecase/AddVideoToQueueUseCase.kt @@ -0,0 +1,21 @@ +package org.fnives.tiktokdownloader.data.usecase + +import org.fnives.tiktokdownloader.data.local.VideoInPendingLocalSource +import org.fnives.tiktokdownloader.data.model.VideoInPending +import java.util.* + +class AddVideoToQueueUseCase( + private val urlVerificationUseCase: UrlVerificationUseCase, + private val videoInPendingLocalSource: VideoInPendingLocalSource +) { + + operator fun invoke(url: String) : Boolean { + if (!urlVerificationUseCase(url)) { + return false + } + val newVideoInPending = VideoInPending(id = UUID.randomUUID().toString(), url = url) + videoInPendingLocalSource.saveUrlIntoQueue(newVideoInPending) + + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/usecase/StateOfVideosObservableUseCase.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/usecase/StateOfVideosObservableUseCase.kt new file mode 100644 index 0000000..3e7969b --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/usecase/StateOfVideosObservableUseCase.kt @@ -0,0 +1,59 @@ +package org.fnives.tiktokdownloader.data.usecase + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import org.fnives.tiktokdownloader.data.local.VideoDownloadedLocalSource +import org.fnives.tiktokdownloader.data.local.VideoInPendingLocalSource +import org.fnives.tiktokdownloader.data.local.VideoInProgressLocalSource +import org.fnives.tiktokdownloader.data.model.VideoDownloaded +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.fnives.tiktokdownloader.data.model.VideoInProgress +import org.fnives.tiktokdownloader.data.model.VideoState + +class StateOfVideosObservableUseCase( + videoInProgressLocalSource: VideoInProgressLocalSource, + videoInPendingLocalSource: VideoInPendingLocalSource, + videoDownloadedLocalSource: VideoDownloadedLocalSource, + private val dispatcher: CoroutineDispatcher +) { + + private val videoStateFlow by lazy { + combine( + videoInProgressLocalSource.videoInProcessFlow, + videoInPendingLocalSource.pendingVideos, + videoDownloadedLocalSource.savedVideos, + ::combineTogether + ) + .debounce(WORK_FLOW_DEBOUNCE) + .distinctUntilChanged() + .flowOn(dispatcher) + } + + operator fun invoke(): Flow> = videoStateFlow + + private suspend fun combineTogether( + videoInProgress: VideoInProgress?, + pendingVideos: List, + downloaded: List + ): List = withContext(dispatcher) { + val result = mutableListOf() + if (videoInProgress != null) { + result.add(VideoState.InProcess(videoInProgress)) + } + result.addAll(pendingVideos.filter { it.url != videoInProgress?.url } + .map(VideoState::InPending)) + result.addAll(downloaded.map(VideoState::Downloaded)) + + return@withContext result + } + + companion object { + private const val WORK_FLOW_DEBOUNCE = 200L + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/usecase/UrlVerificationUseCase.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/usecase/UrlVerificationUseCase.kt new file mode 100644 index 0000000..068b001 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/usecase/UrlVerificationUseCase.kt @@ -0,0 +1,6 @@ +package org.fnives.tiktokdownloader.data.usecase + +class UrlVerificationUseCase { + + operator fun invoke(url: String) = url.contains("tiktok") +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/usecase/VideoDownloadingProcessorUseCase.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/usecase/VideoDownloadingProcessorUseCase.kt new file mode 100644 index 0000000..53baba4 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/usecase/VideoDownloadingProcessorUseCase.kt @@ -0,0 +1,138 @@ +package org.fnives.tiktokdownloader.data.usecase + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import org.fnives.tiktokdownloader.data.local.CaptchaTimeoutLocalSource +import org.fnives.tiktokdownloader.data.local.VideoDownloadedLocalSource +import org.fnives.tiktokdownloader.data.local.VideoInPendingLocalSource +import org.fnives.tiktokdownloader.data.local.VideoInProgressLocalSource +import org.fnives.tiktokdownloader.data.local.exceptions.StorageException +import org.fnives.tiktokdownloader.data.model.ProcessState +import org.fnives.tiktokdownloader.data.model.VideoDownloaded +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile +import org.fnives.tiktokdownloader.data.network.TikTokDownloadRemoteSource +import org.fnives.tiktokdownloader.data.network.exceptions.CaptchaRequiredException +import org.fnives.tiktokdownloader.data.network.exceptions.NetworkException +import org.fnives.tiktokdownloader.data.network.exceptions.ParsingException + +class VideoDownloadingProcessorUseCase( + private val tikTokDownloadRemoteSource: TikTokDownloadRemoteSource, + private val videoInProgressLocalSource: VideoInProgressLocalSource, + private val videoInPendingLocalSource: VideoInPendingLocalSource, + private val videoDownloadedLocalSource: VideoDownloadedLocalSource, + private val captchaTimeoutLocalSource: CaptchaTimeoutLocalSource, + dispatcher: CoroutineDispatcher +) { + + private val fetch = MutableStateFlow(ProcessingState.RUNNING) + private val _processState by lazy { + combineIntoPair(fetch, videoInPendingLocalSource.observeLastPendingVideo()) + .filter { it.first == ProcessingState.RUNNING } + .map { it.second } + .debounce(WORK_FLOW_DEBOUNCE) + .flatMapConcat(::finishedOrProcessItem) + .distinctUntilChanged() + .onEach { + if (it.isError()) { + fetch.value = ProcessingState.ERROR + } + } + .shareIn(CoroutineScope(dispatcher), SharingStarted.Lazily) + } + val processState: Flow get() = _processState + + fun fetchVideoInState() { + fetch.value = ProcessingState.RUNNING + } + + private fun finishedOrProcessItem(videoInPending: VideoInPending?): Flow = + if (videoInPending == null) { + flowOf(ProcessState.Finished) + } else { + processItemFlow(videoInPending) + } + + private fun processItemFlow(videoInPending: VideoInPending): Flow = + flow { + emit(ProcessState.Processing(videoInPending)) + emit(downloadVideo(videoInPending)) + } + + private suspend fun downloadVideo(videoInPending: VideoInPending): ProcessState = + try { + val alreadyDownloaded = videoDownloadedLocalSource.savedVideos.first() + .firstOrNull { it.id == videoInPending.id } + val videoDownloaded = when { + alreadyDownloaded != null -> { + videoInPendingLocalSource.removeVideoFromQueue(videoInPending) + alreadyDownloaded + } + captchaTimeoutLocalSource.isInCaptchaTimeout() -> { + throw CaptchaRequiredException("In Captcha Timeout!") + } + else -> { + videoInProgressLocalSource.markVideoAsInProgress(videoInPending) + val videoInSavingIntoFile: VideoInSavingIntoFile = tikTokDownloadRemoteSource.getVideo(videoInPending) + val videoDownloaded: VideoDownloaded = videoDownloadedLocalSource.saveVideo(videoInSavingIntoFile) + videoInPendingLocalSource.removeVideoFromQueue(videoInPending) + + videoDownloaded + } + } + + ProcessState.Processed(videoDownloaded) + } catch (networkException: NetworkException) { + ProcessState.NetworkError + } catch (parsingException: ParsingException) { + ProcessState.ParsingError + } catch (storageException: StorageException) { + ProcessState.StorageError + } catch (captchaRequiredException: CaptchaRequiredException) { + captchaTimeoutLocalSource.onCaptchaResponseReceived() + ProcessState.CaptchaError + } catch (throwable: Throwable) { + ProcessState.UnknownError + } finally { + videoInProgressLocalSource.removeVideoAsInProgress(videoInPending) + } + + private enum class ProcessingState { + RUNNING, ERROR + } + + companion object { + private const val WORK_FLOW_DEBOUNCE = 200L + + private fun ProcessState.isError() = when (this) { + is ProcessState.Processing, + is ProcessState.Processed, + ProcessState.Finished -> false + ProcessState.NetworkError, + ProcessState.ParsingError, + ProcessState.StorageError, + ProcessState.UnknownError, + ProcessState.CaptchaError -> true + } + + private fun combineIntoPair(flow1: Flow, flow2: Flow): Flow> = + combine(flow1, flow2) { item1, item2 -> item1 to item2 } + + private fun VideoInPendingLocalSource.observeLastPendingVideo(): Flow = + pendingVideos.map { it.lastOrNull() }.distinctUntilChanged() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/di/ServiceLocator.kt b/app/src/main/java/org/fnives/tiktokdownloader/di/ServiceLocator.kt new file mode 100644 index 0000000..0f591ae --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/di/ServiceLocator.kt @@ -0,0 +1,45 @@ +package org.fnives.tiktokdownloader.di + +import android.content.Context +import android.os.Bundle +import androidx.lifecycle.ViewModelProvider +import androidx.savedstate.SavedStateRegistryOwner +import org.fnives.tiktokdownloader.di.module.AndroidFileManagementModule +import org.fnives.tiktokdownloader.di.module.LocalSourceModule +import org.fnives.tiktokdownloader.di.module.NetworkModule +import org.fnives.tiktokdownloader.di.module.PermissionModule +import org.fnives.tiktokdownloader.di.module.UseCaseModule +import org.fnives.tiktokdownloader.di.module.ViewModelModule +import org.fnives.tiktokdownloader.ui.service.QueueServiceViewModel +import java.util.concurrent.TimeUnit + +@Suppress("ObjectPropertyName", "MemberVisibilityCanBePrivate") +object ServiceLocator { + + private val DEFAULT_DELAY_BEFORE_REQUEST = TimeUnit.SECONDS.toMillis(4) + private var _viewModelModule: ViewModelModule? = null + private val viewModelModule: ViewModelModule + get() = _viewModelModule ?: throw IllegalStateException("$this.start has not been called!") + + private var _permissionModule: PermissionModule? = null + val permissionModule: PermissionModule + get() = _permissionModule ?: throw IllegalStateException("$this.start has not been called!") + + fun viewModelFactory( + savedStateRegistryOwner: SavedStateRegistryOwner, + defaultArgs: Bundle + ): ViewModelProvider.Factory = + ViewModelFactory(savedStateRegistryOwner, defaultArgs, viewModelModule) + + val queueServiceViewModel: QueueServiceViewModel + get() = viewModelModule.queueServiceViewModel + + fun start(context: Context) { + val androidFileManagementModule = AndroidFileManagementModule(context) + val localSourceModule = LocalSourceModule(androidFileManagementModule) + val networkModule = NetworkModule(DEFAULT_DELAY_BEFORE_REQUEST) + val useCaseModule = UseCaseModule(localSourceModule, networkModule) + _permissionModule = PermissionModule() + _viewModelModule = ViewModelModule(useCaseModule) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/di/ViewModelDelegate.kt b/app/src/main/java/org/fnives/tiktokdownloader/di/ViewModelDelegate.kt new file mode 100644 index 0000000..9372632 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/di/ViewModelDelegate.kt @@ -0,0 +1,26 @@ +package org.fnives.tiktokdownloader.di + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelLazy + +inline fun ComponentActivity.provideViewModels() = + ViewModelLazy( + viewModelClass = VM::class, + storeProducer = { viewModelStore }, + factoryProducer = { createViewModelFactory() } + ) + +inline fun Fragment.provideViewModels() = + ViewModelLazy( + viewModelClass = VM::class, + storeProducer = { viewModelStore }, + factoryProducer = { createViewModelFactory() }) + +fun ComponentActivity.createViewModelFactory() = + ServiceLocator.viewModelFactory(this, intent?.extras ?: Bundle.EMPTY) + +fun Fragment.createViewModelFactory() = + ServiceLocator.viewModelFactory(this, arguments ?: Bundle.EMPTY) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/di/ViewModelFactory.kt b/app/src/main/java/org/fnives/tiktokdownloader/di/ViewModelFactory.kt new file mode 100644 index 0000000..afb98c3 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/di/ViewModelFactory.kt @@ -0,0 +1,26 @@ +package org.fnives.tiktokdownloader.di + +import android.os.Bundle +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.savedstate.SavedStateRegistryOwner +import org.fnives.tiktokdownloader.di.module.ViewModelModule +import org.fnives.tiktokdownloader.ui.main.MainViewModel +import org.fnives.tiktokdownloader.ui.main.queue.QueueViewModel + +class ViewModelFactory( + savedStateRegistryOwner: SavedStateRegistryOwner, + defaultArgs: Bundle, + private val viewModelModule: ViewModelModule, +) : AbstractSavedStateViewModelFactory(savedStateRegistryOwner, defaultArgs) { + + override fun create(key: String, modelClass: Class, handle: SavedStateHandle): T { + val viewModel = when (modelClass) { + MainViewModel::class.java -> viewModelModule.mainViewModel(handle) + QueueViewModel::class.java -> viewModelModule.queueViewModel + else -> throw IllegalArgumentException("Can't create viewModel for $modelClass ") + } + return viewModel as T + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/di/module/AndroidFileManagementModule.kt b/app/src/main/java/org/fnives/tiktokdownloader/di/module/AndroidFileManagementModule.kt new file mode 100644 index 0000000..8c7a426 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/di/module/AndroidFileManagementModule.kt @@ -0,0 +1,28 @@ +package org.fnives.tiktokdownloader.di.module + +import android.content.ContentResolver +import android.content.Context +import org.fnives.tiktokdownloader.data.local.persistent.SharedPreferencesManager +import org.fnives.tiktokdownloader.data.local.persistent.SharedPreferencesManagerImpl +import org.fnives.tiktokdownloader.data.local.save.video.SaveVideoFile +import org.fnives.tiktokdownloader.data.local.verify.exists.VerifyFileForUriExists +import org.fnives.tiktokdownloader.data.local.verify.exists.VerifyFileForUriExistsImpl +import org.fnives.tiktokdownloader.di.ServiceLocator + +class AndroidFileManagementModule(private val context: Context) { + private val contentResolver: ContentResolver + get() = context.contentResolver + + val verifyFileForUriExists: VerifyFileForUriExists + get() = VerifyFileForUriExistsImpl(contentResolver) + + val sharedPreferencesManager: SharedPreferencesManager by lazy { + SharedPreferencesManagerImpl.create(context) + } + + private val saveVideoFileFactory: SaveVideoFile.Factory + get() = SaveVideoFile.Factory(contentResolver) + + val saveVideoFile: SaveVideoFile + get() = saveVideoFileFactory.create() +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/di/module/LocalSourceModule.kt b/app/src/main/java/org/fnives/tiktokdownloader/di/module/LocalSourceModule.kt new file mode 100644 index 0000000..4c311b0 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/di/module/LocalSourceModule.kt @@ -0,0 +1,34 @@ +package org.fnives.tiktokdownloader.di.module + +import org.fnives.tiktokdownloader.data.local.CaptchaTimeoutLocalSource +import org.fnives.tiktokdownloader.data.local.VideoDownloadedLocalSource +import org.fnives.tiktokdownloader.data.local.VideoInPendingLocalSource +import org.fnives.tiktokdownloader.data.local.VideoInProgressLocalSource +import java.util.concurrent.TimeUnit + +class LocalSourceModule(private val androidFileManagementModule: AndroidFileManagementModule) { + + val videoDownloadedLocalSource: VideoDownloadedLocalSource + get() = VideoDownloadedLocalSource( + saveVideoFile = androidFileManagementModule.saveVideoFile, + sharedPreferencesManagerImpl = androidFileManagementModule.sharedPreferencesManager, + verifyFileForUriExists = androidFileManagementModule.verifyFileForUriExists + ) + + val videoInPendingLocalSource: VideoInPendingLocalSource + get() = VideoInPendingLocalSource( + sharedPreferencesManager = androidFileManagementModule.sharedPreferencesManager + ) + + val videoInProgressLocalSource: VideoInProgressLocalSource by lazy { VideoInProgressLocalSource() } + + val captchaTimeoutLocalSource: CaptchaTimeoutLocalSource + get() = CaptchaTimeoutLocalSource( + androidFileManagementModule.sharedPreferencesManager, + DEFAULT_CAPTCHA_TIMEOUT + ) + + companion object { + private val DEFAULT_CAPTCHA_TIMEOUT = TimeUnit.MINUTES.toMillis(10) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/di/module/NetworkModule.kt b/app/src/main/java/org/fnives/tiktokdownloader/di/module/NetworkModule.kt new file mode 100644 index 0000000..51aac60 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/di/module/NetworkModule.kt @@ -0,0 +1,51 @@ +package org.fnives.tiktokdownloader.di.module + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.fnives.tiktokdownloader.BuildConfig +import org.fnives.tiktokdownloader.data.network.TikTokDownloadRemoteSource +import org.fnives.tiktokdownloader.data.network.TikTokRetrofitService +import org.fnives.tiktokdownloader.data.network.parsing.TikTokWebPageConverterFactory +import org.fnives.tiktokdownloader.data.network.parsing.converter.ThrowIfIsCaptchaResponse +import org.fnives.tiktokdownloader.data.network.session.CookieSavingInterceptor +import org.fnives.tiktokdownloader.data.network.session.CookieStore +import retrofit2.Converter +import retrofit2.Retrofit + +class NetworkModule(private val delayBeforeRequest: Long) { + + private val throwIfIsCaptchaResponse: ThrowIfIsCaptchaResponse + get() = ThrowIfIsCaptchaResponse() + + private val tikTokConverterFactory: Converter.Factory + get() = TikTokWebPageConverterFactory(throwIfIsCaptchaResponse) + + private val cookieSavingInterceptor: CookieSavingInterceptor by lazy { CookieSavingInterceptor() } + + private val cookieStore: CookieStore get() = cookieSavingInterceptor + + private val okHttpClient: OkHttpClient + get() = OkHttpClient.Builder() + .addInterceptor(cookieSavingInterceptor) + .let { + if (BuildConfig.DEBUG) { + it.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }) + } else { + it + } + } + .build() + + private val retrofit: Retrofit + get() = Retrofit.Builder() + .baseUrl("https://google.com") + .addConverterFactory(tikTokConverterFactory) + .client(okHttpClient) + .build() + + private val tikTokRetrofitService: TikTokRetrofitService + get() = retrofit.create(TikTokRetrofitService::class.java) + + val tikTokDownloadRemoteSource: TikTokDownloadRemoteSource + get() = TikTokDownloadRemoteSource(delayBeforeRequest, tikTokRetrofitService, cookieStore) +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/di/module/PermissionModule.kt b/app/src/main/java/org/fnives/tiktokdownloader/di/module/PermissionModule.kt new file mode 100644 index 0000000..8c0f5fd --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/di/module/PermissionModule.kt @@ -0,0 +1,16 @@ +package org.fnives.tiktokdownloader.di.module + +import org.fnives.tiktokdownloader.ui.permission.PermissionRationaleDialogFactory +import org.fnives.tiktokdownloader.ui.permission.PermissionRequester + +class PermissionModule { + + private val permissionRationaleDialogFactory: PermissionRationaleDialogFactory + get() = PermissionRationaleDialogFactory() + + private val permissionRequesterFactory: PermissionRequester.Factory + get() = PermissionRequester.Factory(permissionRationaleDialogFactory) + + val permissionRequester: PermissionRequester + get() = permissionRequesterFactory.invoke() +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/di/module/UseCaseModule.kt b/app/src/main/java/org/fnives/tiktokdownloader/di/module/UseCaseModule.kt new file mode 100644 index 0000000..c2a3977 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/di/module/UseCaseModule.kt @@ -0,0 +1,39 @@ +package org.fnives.tiktokdownloader.di.module + +import kotlinx.coroutines.Dispatchers +import org.fnives.tiktokdownloader.data.usecase.AddVideoToQueueUseCase +import org.fnives.tiktokdownloader.data.usecase.StateOfVideosObservableUseCase +import org.fnives.tiktokdownloader.data.usecase.UrlVerificationUseCase +import org.fnives.tiktokdownloader.data.usecase.VideoDownloadingProcessorUseCase + +class UseCaseModule( + private val localSourceModule: LocalSourceModule, + private val networkModule: NetworkModule) { + + val stateOfVideosObservableUseCase: StateOfVideosObservableUseCase + get() = StateOfVideosObservableUseCase( + videoInPendingLocalSource = localSourceModule.videoInPendingLocalSource, + videoDownloadedLocalSource = localSourceModule.videoDownloadedLocalSource, + videoInProgressLocalSource = localSourceModule.videoInProgressLocalSource, + dispatcher = Dispatchers.IO + ) + + private val urlVerificationUseCase: UrlVerificationUseCase + get() = UrlVerificationUseCase() + + val addVideoToQueueUseCase: AddVideoToQueueUseCase + get() = AddVideoToQueueUseCase( + urlVerificationUseCase, + localSourceModule.videoInPendingLocalSource) + + val videoDownloadingProcessorUseCase: VideoDownloadingProcessorUseCase by lazy { + VideoDownloadingProcessorUseCase( + tikTokDownloadRemoteSource = networkModule.tikTokDownloadRemoteSource, + videoInPendingLocalSource = localSourceModule.videoInPendingLocalSource, + videoDownloadedLocalSource = localSourceModule.videoDownloadedLocalSource, + videoInProgressLocalSource = localSourceModule.videoInProgressLocalSource, + captchaTimeoutLocalSource = localSourceModule.captchaTimeoutLocalSource, + dispatcher = Dispatchers.IO + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/di/module/ViewModelModule.kt b/app/src/main/java/org/fnives/tiktokdownloader/di/module/ViewModelModule.kt new file mode 100644 index 0000000..e12b6bf --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/di/module/ViewModelModule.kt @@ -0,0 +1,29 @@ +package org.fnives.tiktokdownloader.di.module + +import androidx.lifecycle.SavedStateHandle +import org.fnives.tiktokdownloader.ui.main.MainViewModel +import org.fnives.tiktokdownloader.ui.main.queue.QueueViewModel +import org.fnives.tiktokdownloader.ui.service.QueueServiceViewModel + +class ViewModelModule(private val useCaseModule: UseCaseModule) { + + val queueServiceViewModel: QueueServiceViewModel + get() = QueueServiceViewModel( + useCaseModule.addVideoToQueueUseCase, + useCaseModule.videoDownloadingProcessorUseCase + ) + + fun mainViewModel(savedStateHandle: SavedStateHandle): MainViewModel = + MainViewModel( + useCaseModule.videoDownloadingProcessorUseCase, + useCaseModule.addVideoToQueueUseCase, + savedStateHandle + ) + + val queueViewModel: QueueViewModel + get() = QueueViewModel( + useCaseModule.stateOfVideosObservableUseCase, + useCaseModule.addVideoToQueueUseCase, + useCaseModule.videoDownloadingProcessorUseCase + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/main/MainActivity.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/MainActivity.kt new file mode 100644 index 0000000..6219503 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/MainActivity.kt @@ -0,0 +1,128 @@ +package org.fnives.tiktokdownloader.ui.main + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.OvershootInterpolator +import androidx.activity.viewModels +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.snackbar.Snackbar +import org.fnives.tiktokdownloader.R +import org.fnives.tiktokdownloader.di.ServiceLocator +import org.fnives.tiktokdownloader.di.provideViewModels +import org.fnives.tiktokdownloader.ui.main.help.HelpFragment +import org.fnives.tiktokdownloader.ui.main.queue.QueueFragment +import org.fnives.tiktokdownloader.ui.permission.PermissionRequester +import org.fnives.tiktokdownloader.ui.service.QueueService + +class MainActivity : AppCompatActivity() { + + private val viewModel by provideViewModels() + private val permissionRequester: PermissionRequester by lazy { + ServiceLocator.permissionModule.permissionRequester + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + permissionRequester(this@MainActivity) + stopService(QueueService.buildIntent(this)) + setContentView(R.layout.activity_main) + + val bottomNavigationView = findViewById(R.id.bottom_navigation) + val downloadFab = findViewById(R.id.download_fab) + val snackBarAnchor = findViewById(R.id.snack_bar_anchor) + + setupBottomNavigationView(bottomNavigationView, savedInstanceState) + downloadFab.setOnClickListener { + animateFabClicked(downloadFab) + viewModel.onFetchDownloadClicked() + } + viewModel.refreshActionVisibility.observe(this, { + animateFabVisibility(downloadFab, it == true) + }) + viewModel.errorMessage.observe(this, { + val stringRes = it?.item?.stringRes ?: return@observe + Snackbar.make(snackBarAnchor, stringRes, Snackbar.LENGTH_SHORT).show() + }) + } + + private fun setupBottomNavigationView(bottomNavigationView: BottomNavigationView, savedInstanceState: Bundle?) { + bottomNavigationView.setOnNavigationItemSelectedListener(BottomNavigationView.OnNavigationItemSelectedListener { item -> + val fragment = when (item.itemId) { + R.id.help_menu_item -> HelpFragment.newInstance() + R.id.queue_menu_item -> QueueFragment.newInstance() + else -> return@OnNavigationItemSelectedListener false + } + item.toScreen()?.let(viewModel::onScreenSelected) + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_holder, fragment) + .commit() + return@OnNavigationItemSelectedListener true + }) + if (savedInstanceState == null) { + bottomNavigationView.selectedItemId = R.id.queue_menu_item + } + } + + companion object { + + private val fabRotationInterpolator = OvershootInterpolator() + private val fabVisibilityInterpolator = AccelerateDecelerateInterpolator() + private const val FAB_ROTATION_ANIMATION_DURATION = 800L + private const val FAB_VISIBILITY_ANIMATION_DURATION = 500L + private const val FULL_ROTATION = 360f + const val INTENT_EXTRA_URL = "INTENT_EXTRA_URL" + + fun buildIntent(context: Context): Intent = + Intent(context, MainActivity::class.java) + + fun buildIntent(context: Context, url: String): Intent = + Intent(context, MainActivity::class.java) + .putExtra(INTENT_EXTRA_URL, url) + + private fun MenuItem.toScreen(): MainViewModel.Screen? = + when (itemId) { + R.id.help_menu_item -> MainViewModel.Screen.HELP + R.id.queue_menu_item -> MainViewModel.Screen.QUEUE + else -> null + } + + @get:StringRes + private val MainViewModel.ErrorMessage.stringRes: Int + get() = when (this) { + MainViewModel.ErrorMessage.NETWORK -> R.string.network_error + MainViewModel.ErrorMessage.PARSING -> R.string.parsing_error + MainViewModel.ErrorMessage.STORAGE -> R.string.storage_error + MainViewModel.ErrorMessage.CAPTCHA -> R.string.captcha_error + MainViewModel.ErrorMessage.UNKNOWN -> R.string.unexpected_error + } + + private fun animateFabClicked(downloadFab: FloatingActionButton) { + downloadFab.clearAnimation() + downloadFab.animate() + .rotationBy(FULL_ROTATION) + .setDuration(FAB_ROTATION_ANIMATION_DURATION) + .setInterpolator(fabRotationInterpolator) + .start() + } + + private fun animateFabVisibility(downloadFab: FloatingActionButton, visible: Boolean) { + val scale = if (visible) 1f else 0f + val translation = if (visible) 0f else downloadFab.height * 2 / 3f + downloadFab.clearAnimation() + downloadFab.animate() + .scaleX(scale) + .scaleY(scale) + .setDuration(FAB_VISIBILITY_ANIMATION_DURATION) + .setInterpolator(fabVisibilityInterpolator) + .translationY(translation) + .start() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/main/MainViewModel.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/MainViewModel.kt new file mode 100644 index 0000000..25d21e1 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/MainViewModel.kt @@ -0,0 +1,80 @@ +package org.fnives.tiktokdownloader.ui.main + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.fnives.tiktokdownloader.data.model.ProcessState +import org.fnives.tiktokdownloader.data.usecase.AddVideoToQueueUseCase +import org.fnives.tiktokdownloader.data.usecase.VideoDownloadingProcessorUseCase +import org.fnives.tiktokdownloader.ui.main.MainActivity.Companion.INTENT_EXTRA_URL +import org.fnives.tiktokdownloader.ui.shared.Event +import org.fnives.tiktokdownloader.ui.shared.combineNullable + +class MainViewModel( + private val processor: VideoDownloadingProcessorUseCase, + private val addVideoToQueueUseCase: AddVideoToQueueUseCase, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val _refreshActionVisibility = MutableLiveData() + private val currentScreen = MutableLiveData() + val refreshActionVisibility: LiveData = combineNullable(_refreshActionVisibility, currentScreen) { refreshVisibility, screen -> + refreshVisibility == true && screen == Screen.QUEUE + } + private val _errorMessage = MutableLiveData>() + val errorMessage: LiveData?> = combineNullable(_errorMessage, currentScreen) { event, screen -> + event?.takeIf { screen == Screen.QUEUE } + } + + init { + savedStateHandle.get(INTENT_EXTRA_URL)?.let(addVideoToQueueUseCase::invoke) + savedStateHandle.set(INTENT_EXTRA_URL, null) + processor.fetchVideoInState() + viewModelScope.launch { + processor.processState.collect { + val errorMessage = when (it) { + is ProcessState.Processing, + is ProcessState.Processed, + ProcessState.Finished -> null + ProcessState.NetworkError -> ErrorMessage.NETWORK + ProcessState.ParsingError -> ErrorMessage.PARSING + ProcessState.StorageError -> ErrorMessage.STORAGE + ProcessState.CaptchaError -> ErrorMessage.CAPTCHA + ProcessState.UnknownError -> ErrorMessage.UNKNOWN + } + val refreshActionVisibility = when (it) { + is ProcessState.Processing, + is ProcessState.Processed, + ProcessState.Finished -> false + ProcessState.NetworkError, + ProcessState.ParsingError, + ProcessState.StorageError, + ProcessState.UnknownError, + ProcessState.CaptchaError -> true + } + _errorMessage.postValue(errorMessage?.let(::Event)) + _refreshActionVisibility.postValue(refreshActionVisibility) + } + } + } + + fun onFetchDownloadClicked() { + processor.fetchVideoInState() + } + + fun onScreenSelected(screen: Screen) { + currentScreen.value = screen + } + + enum class ErrorMessage { + NETWORK, PARSING, STORAGE, CAPTCHA, UNKNOWN + } + + enum class Screen { + QUEUE, HELP + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/main/help/HelpFragment.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/help/HelpFragment.kt new file mode 100644 index 0000000..fff3f9e --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/help/HelpFragment.kt @@ -0,0 +1,33 @@ +package org.fnives.tiktokdownloader.ui.main.help + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.fragment.app.Fragment +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.YouTubePlayerView +import org.fnives.tiktokdownloader.R + +class HelpFragment : Fragment(R.layout.fragment_help) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val inAppYoutubeView = view.findViewById(R.id.in_app_youtube_view) + val inTikTokYoutubeView = view.findViewById(R.id.in_tiktok_youtube_view) + viewLifecycleOwner.lifecycle.addObserver(inAppYoutubeView) + viewLifecycleOwner.lifecycle.addObserver(inTikTokYoutubeView) + val repositoryLinkView = view.findViewById(R.id.repository_link_view) + repositoryLinkView.setOnClickListener { + startActivity(createRepositoryIntent()) + } + } + + companion object { + + fun createRepositoryIntent(): Intent = + Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/fknives/TikTok-Downloader")) + + fun newInstance(): HelpFragment = HelpFragment() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/main/queue/QueueFragment.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/queue/QueueFragment.kt new file mode 100644 index 0000000..9baaf19 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/queue/QueueFragment.kt @@ -0,0 +1,72 @@ +package org.fnives.tiktokdownloader.ui.main.queue + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.EditText +import androidx.core.net.toUri +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.RecyclerView +import org.fnives.tiktokdownloader.R +import org.fnives.tiktokdownloader.data.model.VideoState +import org.fnives.tiktokdownloader.di.provideViewModels + +class QueueFragment : Fragment(R.layout.fragment_queue) { + + private val viewModel by provideViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val recycler = view.findViewById(R.id.recycler) + val adapter = QueueItemAdapter( + itemClicked = viewModel::onItemClicked, + urlClicked = viewModel::onUrlClicked + ) + recycler.adapter = adapter + val saveUrlCta = view.findViewById(R.id.save_cta) + val input = view.findViewById(R.id.download_url_input) + input.doAfterTextChanged { + saveUrlCta.isEnabled = it?.isNotBlank() == true + } + saveUrlCta.setOnClickListener { + recycler.smoothScrollToPosition(0) + viewModel.onSaveClicked(input.text?.toString().orEmpty()) + input.setText("") + } + + viewModel.navigationEvent.observe(viewLifecycleOwner, Observer { + val intent = when (val data = it.item) { + is QueueViewModel.NavigationEvent.OpenBrowser -> { + createBrowserIntent(data.url) + } + is QueueViewModel.NavigationEvent.OpenGallery -> + createGalleryIntent(data.uri) + null -> return@Observer + } + startActivity(intent) + }) + + viewModel.downloads.observe(viewLifecycleOwner, { videoStates -> + adapter.submitList(videoStates, Runnable { + val indexToScrollTo = videoStates.indexOfFirst { it is VideoState.InProcess } + .takeIf { it != -1 } ?: return@Runnable + recycler.smoothScrollToPosition(indexToScrollTo) + }) + }) + } + + companion object { + + fun newInstance(): QueueFragment = QueueFragment() + + fun createBrowserIntent(url: String): Intent = + Intent(Intent.ACTION_VIEW, Uri.parse(url)) + + fun createGalleryIntent(uri: String): Intent = + Intent(Intent.ACTION_VIEW, uri.toUri()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/main/queue/QueueItemAdapter.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/queue/QueueItemAdapter.kt new file mode 100644 index 0000000..2808093 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/queue/QueueItemAdapter.kt @@ -0,0 +1,75 @@ +package org.fnives.tiktokdownloader.ui.main.queue + +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.fnives.tiktokdownloader.R +import org.fnives.tiktokdownloader.data.model.VideoState +import org.fnives.tiktokdownloader.ui.shared.inflate +import org.fnives.tiktokdownloader.ui.shared.loadUri + +class QueueItemAdapter( + private val itemClicked: (path: String) -> Unit, + private val urlClicked: (url: String) -> Unit +) : + ListAdapter(DiffUtilItemCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadActionsViewHolder = + DownloadActionsViewHolder(parent) + + override fun onBindViewHolder(holder: DownloadActionsViewHolder, position: Int) { + val item = getItem(position) + holder.urlView.text = item.url + val statusTextRes = when (item) { + is VideoState.InPending -> R.string.status_pending + is VideoState.Downloaded -> R.string.status_finished + is VideoState.InProcess -> R.string.status_pending + } + holder.statusView.isInvisible = item is VideoState.InProcess + holder.progress.isVisible = item is VideoState.InProcess + if (item is VideoState.Downloaded) { + holder.itemView.setOnClickListener { + itemClicked(item.videoDownloaded.uri) + } + holder.itemView.isEnabled = true + } else { + holder.itemView.isEnabled = false + } + holder.urlView.setOnClickListener { + urlClicked(item.url) + } + holder.statusView.setText(statusTextRes) + when (item) { + is VideoState.InProcess, + is VideoState.InPending -> holder.thumbNailView.setImageResource(R.drawable.ic_twotone_image) + is VideoState.Downloaded -> + holder.thumbNailView.loadUri(item.videoDownloaded.uri) + } + } + + class DownloadActionsViewHolder(parent: ViewGroup) : + RecyclerView.ViewHolder(parent.inflate(R.layout.item_downloaded)) { + val urlView: TextView = itemView.findViewById(R.id.url) + val statusView: TextView = itemView.findViewById(R.id.status) + val thumbNailView: ImageView = itemView.findViewById(R.id.thumbnail) + val progress: ProgressBar = itemView.findViewById(R.id.progress) + } + + class DiffUtilItemCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: VideoState, newItem: VideoState): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: VideoState, newItem: VideoState): Boolean = + oldItem == newItem + + override fun getChangePayload(oldItem: VideoState, newItem: VideoState): Any? = + this + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/main/queue/QueueViewModel.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/queue/QueueViewModel.kt new file mode 100644 index 0000000..e29e5ae --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/queue/QueueViewModel.kt @@ -0,0 +1,39 @@ +package org.fnives.tiktokdownloader.ui.main.queue + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import org.fnives.tiktokdownloader.data.usecase.AddVideoToQueueUseCase +import org.fnives.tiktokdownloader.data.usecase.StateOfVideosObservableUseCase +import org.fnives.tiktokdownloader.data.usecase.VideoDownloadingProcessorUseCase +import org.fnives.tiktokdownloader.ui.shared.Event +import org.fnives.tiktokdownloader.ui.shared.asLiveData + +class QueueViewModel( + stateOfVideosObservableUseCase: StateOfVideosObservableUseCase, + private val addVideoToQueueUseCase: AddVideoToQueueUseCase, + private val videoDownloadingProcessorUseCase: VideoDownloadingProcessorUseCase +) : ViewModel() { + + val downloads = asLiveData(stateOfVideosObservableUseCase()) + private val _navigationEvent = MutableLiveData>() + val navigationEvent: LiveData> = _navigationEvent + + fun onSaveClicked(url: String) { + addVideoToQueueUseCase(url) + videoDownloadingProcessorUseCase.fetchVideoInState() + } + + fun onItemClicked(path: String) { + _navigationEvent.value = Event(NavigationEvent.OpenGallery(path)) + } + + fun onUrlClicked(url: String) { + _navigationEvent.value = Event(NavigationEvent.OpenBrowser(url)) + } + + sealed class NavigationEvent { + data class OpenBrowser(val url: String) : NavigationEvent() + data class OpenGallery(val uri: String) : NavigationEvent() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/permission/PermissionRationaleDialogFactory.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/permission/PermissionRationaleDialogFactory.kt new file mode 100644 index 0000000..c00108c --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/permission/PermissionRationaleDialogFactory.kt @@ -0,0 +1,27 @@ +package org.fnives.tiktokdownloader.ui.permission + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import kotlinx.coroutines.CompletableDeferred +import org.fnives.tiktokdownloader.R + +class PermissionRationaleDialogFactory { + + fun show(context: Context, onOkClicked: () -> Unit, onCanceled: () -> Unit) { + var okClicked = false + AlertDialog.Builder(context) + .setTitle(R.string.permission_request) + .setMessage(R.string.permission_rationale) + .setPositiveButton(R.string.ok) { dialog, _ -> + okClicked = true + onOkClicked() + dialog.dismiss() + } + .setOnDismissListener { + if (!okClicked) { + onCanceled() + } + } + .show() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/permission/PermissionRequester.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/permission/PermissionRequester.kt new file mode 100644 index 0000000..549f49c --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/permission/PermissionRequester.kt @@ -0,0 +1,21 @@ +package org.fnives.tiktokdownloader.ui.permission + +import android.os.Build +import androidx.appcompat.app.AppCompatActivity + +interface PermissionRequester { + + operator fun invoke(activity: AppCompatActivity) + + fun isGranted(activity: AppCompatActivity): Boolean + + class Factory(private val permissionRationaleDialogFactory: PermissionRationaleDialogFactory) { + + fun invoke(): PermissionRequester = + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + PermissionRequesterAbove28() + } else { + PermissionRequesterBelow28(permissionRationaleDialogFactory) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/permission/PermissionRequesterAbove28.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/permission/PermissionRequesterAbove28.kt new file mode 100644 index 0000000..a42987f --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/permission/PermissionRequesterAbove28.kt @@ -0,0 +1,12 @@ +package org.fnives.tiktokdownloader.ui.permission + +import androidx.appcompat.app.AppCompatActivity + +class PermissionRequesterAbove28 : PermissionRequester { + + override fun invoke(activity: AppCompatActivity) { + // nothing to do, no permission is required + } + + override fun isGranted(activity: AppCompatActivity): Boolean = true +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/permission/PermissionRequesterBelow28.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/permission/PermissionRequesterBelow28.kt new file mode 100644 index 0000000..5ded3b5 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/permission/PermissionRequesterBelow28.kt @@ -0,0 +1,91 @@ +package org.fnives.tiktokdownloader.ui.permission + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import org.fnives.tiktokdownloader.R +import org.fnives.tiktokdownloader.ui.permission.PermissionRequesterBelow28.Companion.hasPermission + +class PermissionRequesterBelow28( + private val permissionRationaleDialogFactory: PermissionRationaleDialogFactory +) : PermissionRequester { + + override operator fun invoke(activity: AppCompatActivity) { + val interactor = PermissionLauncherInteractor(activity, permissionRationaleDialogFactory) + interactor.attach() + } + + override fun isGranted(activity: AppCompatActivity): Boolean = + activity.hasPermission(STORAGE_PERMISSION) + + + private class PermissionLauncherInteractor( + private val activity: AppCompatActivity, + private val permissionRationaleDialogFactory: PermissionRationaleDialogFactory + ) : LifecycleEventObserver { + val requestPermissionLauncher = activity.registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (!isGranted && activity.shouldShowRequestPermissionRationale(STORAGE_PERMISSION)) { + showRationale() + } else { + onFinalResponse(isGranted) + } + } + + fun attach() { + activity.lifecycle.addObserver(this) + } + + private fun checkOrRequestPermission() { + when { + activity.hasPermission(STORAGE_PERMISSION) -> onFinalResponse(true) + activity.shouldShowRequestPermissionRationale(STORAGE_PERMISSION) -> showRationale() + else -> requestPermissionLauncher.launch(STORAGE_PERMISSION) + } + } + + private fun showRationale() { + permissionRationaleDialogFactory.show( + activity, + onOkClicked = { + requestPermissionLauncher.launch(STORAGE_PERMISSION) + }, + onCanceled = { + onFinalResponse(false) + } + ) + } + + private fun onFinalResponse(isGranted: Boolean) { + if (!isGranted) { + Toast.makeText(activity, R.string.cant_operate_without_permission, Toast.LENGTH_LONG).show() + activity.finishAffinity() + } + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_START) { + checkOrRequestPermission() + } else if (event == Lifecycle.Event.ON_DESTROY) { + source.lifecycle.removeObserver(this) + requestPermissionLauncher.unregister() + } + } + } + + companion object { + + private const val STORAGE_PERMISSION = Manifest.permission.WRITE_EXTERNAL_STORAGE + + private fun Context.hasPermission(permission: String): Boolean = + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/service/DownloadIntentReceiverActivity.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/service/DownloadIntentReceiverActivity.kt new file mode 100644 index 0000000..399d0b5 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/service/DownloadIntentReceiverActivity.kt @@ -0,0 +1,27 @@ +package org.fnives.tiktokdownloader.ui.service + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import org.fnives.tiktokdownloader.di.ServiceLocator +import org.fnives.tiktokdownloader.ui.main.MainActivity +import org.fnives.tiktokdownloader.ui.permission.PermissionRequester + +class DownloadIntentReceiverActivity : AppCompatActivity() { + + private val permissionRequester: PermissionRequester by lazy { + ServiceLocator.permissionModule.permissionRequester + } + + override fun onCreate(savedInstanceState: Bundle?) { + intent.getStringExtra(Intent.EXTRA_TEXT)?.let { url -> + if (permissionRequester.isGranted(this)) { + startService(QueueService.buildIntent(this, url)) + } else { + startActivity(MainActivity.buildIntent(this, url)) + } + } + super.onCreate(savedInstanceState) + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/service/NotificationState.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/service/NotificationState.kt new file mode 100644 index 0000000..a98bc36 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/service/NotificationState.kt @@ -0,0 +1,9 @@ +package org.fnives.tiktokdownloader.ui.service + +import androidx.annotation.StringRes + +sealed class NotificationState { + data class Processing(val url: String) : NotificationState() + data class Error(@StringRes val errorRes: Int) : NotificationState() + object Finish: NotificationState() +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/service/QueueService.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/service/QueueService.kt new file mode 100644 index 0000000..8a1b248 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/service/QueueService.kt @@ -0,0 +1,133 @@ +package org.fnives.tiktokdownloader.ui.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import org.fnives.tiktokdownloader.R +import org.fnives.tiktokdownloader.di.ServiceLocator +import org.fnives.tiktokdownloader.ui.main.MainActivity + + +class QueueService : Service() { + + private val viewModel: QueueServiceViewModel by lazy { ServiceLocator.queueServiceViewModel } + private val serviceLifecycle = ServiceLifecycle() + + override fun onCreate() { + super.onCreate() + serviceLifecycle.lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + viewModel.notificationState.observe(serviceLifecycle) { + updateNotification(it) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.url?.let(viewModel::onUrlReceived) + startForeground() + return super.onStartCommand(intent, flags, startId) + } + + private fun startForeground() { + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationManager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + getString(R.string.tik_tok_downloader_notification_channel), + NotificationManager.IMPORTANCE_MIN + ) + ) + } + startForeground( + SERVICE_NOTIFICATION_ID, + NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.tik_tok_downloader_started)) + .setSmallIcon(R.drawable.ic_download) + .build() + ) + } + + private fun updateNotification(notificationState: NotificationState) { + if (notificationState is NotificationState.Finish) { + stopSelf() + return + } + val (id, notification) = when (notificationState) { + is NotificationState.Processing -> + SERVICE_NOTIFICATION_ID to NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.tik_tok_downloader_processing, notificationState.url)) + .setSmallIcon(R.drawable.ic_download) + .setProgress(0, 10, true) + .build() + is NotificationState.Error -> + NOTIFICATION_ID to NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(notificationState.errorRes)) + .setSmallIcon(R.drawable.ic_download) + .setContentIntent(buildMainPendingIntent(this)) + .setAutoCancel(true) + .setNotificationSilent() + .build() + NotificationState.Finish -> { + stopSelf() + return + } + } + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(id, notification) + if (id == NOTIFICATION_ID) { + stopSelf() + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + super.onDestroy() + serviceLifecycle.lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + viewModel.onClear() + } + + private class ServiceLifecycle : LifecycleOwner { + val lifecycleRegistry = LifecycleRegistry(this) + override fun getLifecycle(): Lifecycle = lifecycleRegistry + } + + companion object { + + private const val CHANNEL_ID = "org.fnives.tiktokdownloader.CHANNEL_ID" + private const val NOTIFICATION_ID = 420 + private const val SERVICE_NOTIFICATION_ID = 421 + private const val NOTIFICATION_PENDING_INTENT_REQUEST_CODE = 422 + + private var Intent.url: String + get() = getStringExtra("URL").orEmpty() + set(value) { + putExtra("URL", value) + } + + fun buildIntent(context: Context, url: String) = + Intent(context, QueueService::class.java).also { it.url = url } + + fun buildIntent(context: Context) = + Intent(context, QueueService::class.java) + + private fun buildMainPendingIntent(context: Context): PendingIntent = + PendingIntent.getActivity( + context, + NOTIFICATION_PENDING_INTENT_REQUEST_CODE, + MainActivity.buildIntent(context), + FLAG_UPDATE_CURRENT + ) + } + +} diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/service/QueueServiceViewModel.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/service/QueueServiceViewModel.kt new file mode 100644 index 0000000..0f5e4f4 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/service/QueueServiceViewModel.kt @@ -0,0 +1,63 @@ +package org.fnives.tiktokdownloader.ui.service + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.fnives.tiktokdownloader.R +import org.fnives.tiktokdownloader.data.usecase.AddVideoToQueueUseCase +import org.fnives.tiktokdownloader.data.usecase.VideoDownloadingProcessorUseCase +import org.fnives.tiktokdownloader.data.model.ProcessState + +class QueueServiceViewModel( + private val addVideoToQueueUseCase: AddVideoToQueueUseCase, + private val videoDownloadingProcessorUseCase: VideoDownloadingProcessorUseCase +) { + + private val parentJob = Job() + private val viewModelScope = CoroutineScope(parentJob + Dispatchers.Main) + private var listeningJob: Job? = null + + private val _notificationState = MutableLiveData() + val notificationState: LiveData = _notificationState + + fun onUrlReceived(url: String) { + if (!addVideoToQueueUseCase(url)){ + return + } + if (listeningJob == null) { + listeningJob = startListening() + } + videoDownloadingProcessorUseCase.fetchVideoInState() + } + + private fun startListening(): Job = viewModelScope.launch { + videoDownloadingProcessorUseCase.processState.collect { + val value = when (it) { + is ProcessState.Processing -> + NotificationState.Processing(it.videoInPending.url) + is ProcessState.Processed -> + NotificationState.Processing(it.videoDownloaded.url) + ProcessState.NetworkError -> + NotificationState.Error(R.string.network_error) + ProcessState.ParsingError -> + NotificationState.Error(R.string.parsing_error) + ProcessState.StorageError -> + NotificationState.Error(R.string.storage_error) + ProcessState.UnknownError -> + NotificationState.Error(R.string.unexpected_error) + ProcessState.Finished -> NotificationState.Finish + ProcessState.CaptchaError -> + NotificationState.Error(R.string.captcha_error) + } + _notificationState.postValue(value) + } + } + + fun onClear() { + parentJob.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/shared/Event.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/shared/Event.kt new file mode 100644 index 0000000..5248a85 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/shared/Event.kt @@ -0,0 +1,11 @@ +package org.fnives.tiktokdownloader.ui.shared + +data class Event(private val data: D) { + + var consume: Boolean = false + private set + + val item: D? get() = data?.takeUnless { consume }.also { consume = true } + + fun peek(): D = data +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/shared/LiveDataExtensions.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/shared/LiveDataExtensions.kt new file mode 100644 index 0000000..2dc87cb --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/shared/LiveDataExtensions.kt @@ -0,0 +1,16 @@ +package org.fnives.tiktokdownloader.ui.shared + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData + +fun combineNullable( + liveData1: LiveData, + liveData2: LiveData, + mapper: (T1?, T2?) -> R? +): LiveData { + val mediatorLiveData = MediatorLiveData() + val update = { mediatorLiveData.value = mapper(liveData1.value, liveData2.value) } + mediatorLiveData.addSource(liveData1) { update() } + mediatorLiveData.addSource(liveData2) { update() } + return mediatorLiveData +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/shared/ViewExtensions.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/shared/ViewExtensions.kt new file mode 100644 index 0000000..56ff73d --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/shared/ViewExtensions.kt @@ -0,0 +1,20 @@ +package org.fnives.tiktokdownloader.ui.shared + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.annotation.LayoutRes +import androidx.core.net.toUri +import com.bumptech.glide.Glide + +val View.inflater get() = LayoutInflater.from(context) + +fun ViewGroup.inflate(@LayoutRes layoutRes: Int, addToParent: Boolean = false) : View = + inflater.inflate(layoutRes, this, addToParent) + +fun ImageView.loadUri(uri: String) { + Glide.with(this) + .load(uri.toUri()) + .into(this) +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/shared/ViewModelExtensions.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/shared/ViewModelExtensions.kt new file mode 100644 index 0000000..a93ef45 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/shared/ViewModelExtensions.kt @@ -0,0 +1,22 @@ +package org.fnives.tiktokdownloader.ui.shared + +import androidx.fragment.app.Fragment +import androidx.lifecycle.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlin.properties.ReadOnlyProperty + +fun CoroutineScope.asLiveData(flow: Flow): LiveData { + val liveData = MutableLiveData() + launch { + flow.collect { + liveData.postValue(it) + } + } + + return liveData +} + +fun ViewModel.asLiveData(flow: Flow): LiveData = viewModelScope.asLiveData(flow) \ No newline at end of file diff --git a/app/src/main/res/animator/card_elevation_animator.xml b/app/src/main/res/animator/card_elevation_animator.xml new file mode 100644 index 0000000..df4f84c --- /dev/null +++ b/app/src/main/res/animator/card_elevation_animator.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/color/outline_stroke_color.xml b/app/src/main/res/color/outline_stroke_color.xml new file mode 100644 index 0000000..e1074c1 --- /dev/null +++ b/app/src/main/res/color/outline_stroke_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..b200a62 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..0a386f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..991b3c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_round_refresh.xml b/app/src/main/res/drawable/ic_round_refresh.xml new file mode 100644 index 0000000..5cdbeb0 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_refresh.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_twotone_help.xml b/app/src/main/res/drawable/ic_twotone_help.xml new file mode 100644 index 0000000..26dee4c --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_help.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_image.xml b/app/src/main/res/drawable/ic_twotone_image.xml new file mode 100644 index 0000000..ceb2f71 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_image.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..f2a1096 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_help.xml b/app/src/main/res/layout/fragment_help.xml new file mode 100644 index 0000000..ff6eec3 --- /dev/null +++ b/app/src/main/res/layout/fragment_help.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_queue.xml b/app/src/main/res/layout/fragment_queue.xml new file mode 100644 index 0000000..b390636 --- /dev/null +++ b/app/src/main/res/layout/fragment_queue.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_downloaded.xml b/app/src/main/res/layout/item_downloaded.xml new file mode 100644 index 0000000..3e12a45 --- /dev/null +++ b/app/src/main/res/layout/item_downloaded.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main_navigation.xml b/app/src/main/res/menu/main_navigation.xml new file mode 100644 index 0000000..28065e6 --- /dev/null +++ b/app/src/main/res/menu/main_navigation.xml @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..3e54b21 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..351dbb0 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e2f4682 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..7cd2530 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..ff7acab Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..6958433 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..1c18add --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF000000 + #FFFFFFFF + #FF0F0F0F + #FFF0F0F0 + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..89047e7 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,11 @@ + + + 24dp + 16dp + 8dp + 2dp + 4dp + 4dp + 64dp + 88dp + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..bcbbb26 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,45 @@ + + TikTokDownloader + + TikTok Downloader Started + Downloading: %s + Downloader Progress + + Queue + Help + + Copy link here from TikTok to download it + Start Download + Start + + Pending + Downloaded + + Network Error + Parsing Error + Failed to Store Video + Unexpected Error + Permission Needed + External Storage permission is needed in order to save the video to your device + OK + Can\'t operate without external storage permission + Captcha seems necessary, try later! + TikTok Link + How To Use In App + - Open Queue Screen. + \n- Copy the link from TikTok into the input field. + \n- Press start. + \n- If error happens retry with the refresh button. + \n\nNote: Clicking on video will open the Gallery app. + How To Use From TikTok + - In TikTok press share. + \n- Select Other. + \n- Select TikTok Downloader. + \n- You receive a notification about the state of download. + \n- If you need to retry, open the App. + + Additional Information + The download may fail for various reasons. In such case on the Queue screen you will see a refresh icon. Pressing that will retry the download. + It\'s possible if you try to download too many videos at the same time captcha will be triggered. Since we don\'t want to overload the server, in such case you will need to wait a couple hours to properly retry the download. If that still doesn\'t work you may need to verify a captcha on the same network. + This is an open source project. You can see the repository clicking here. + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..bc21337 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/queue_motion_description.xml b/app/src/main/res/xml/queue_motion_description.xml new file mode 100644 index 0000000..3d53717 --- /dev/null +++ b/app/src/main/res/xml/queue_motion_description.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/data/local/CaptchaTimeoutLocalSourceTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/data/local/CaptchaTimeoutLocalSourceTest.kt new file mode 100644 index 0000000..3ad607b --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/data/local/CaptchaTimeoutLocalSourceTest.kt @@ -0,0 +1,42 @@ +package org.fnives.tiktokdownloader.data.local + +import com.nhaarman.mockitokotlin2.spy +import org.fnives.tiktokdownloader.data.local.persistent.SharedPreferencesManager +import org.fnives.tiktokdownloader.helper.mock.InMemorySharedPreferencesManager +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +class CaptchaTimeoutLocalSourceTest { + + private lateinit var mockSharedPreferencesManager: SharedPreferencesManager + private lateinit var sut: CaptchaTimeoutLocalSource + + @BeforeEach + fun setup(){ + mockSharedPreferencesManager = spy(InMemorySharedPreferencesManager()) + sut = CaptchaTimeoutLocalSource(mockSharedPreferencesManager, 60) + } + + @Test + fun GIVEN_initialized_WHEN_isInCacheTimeout_THEN_its_false() { + Assertions.assertFalse(sut.isInCaptchaTimeout(), "By default not in Captcha timeout") + } + + @Test + fun GIVEN_initialized_saved_cache_timeout_WHEN_not_enough_time_passed_THEN_it_is__NOT_InCacheTimeout() { + sut.onCaptchaResponseReceived() + Thread.sleep(1) + + Assertions.assertTrue(sut.isInCaptchaTimeout(), "By default not in Captcha timeout") + } + + @Test + fun GIVEN_initialized_saved_cache_timeout_WHEN_enough_time_passed_THEN_it_is__InCacheTimeout() { + sut.onCaptchaResponseReceived() + Thread.sleep(60) + + Assertions.assertFalse(sut.isInCaptchaTimeout(), "By default not in Captcha timeout") + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/data/local/VideoDownloadedLocalSourceTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/data/local/VideoDownloadedLocalSourceTest.kt new file mode 100644 index 0000000..3f4d0c4 --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/data/local/VideoDownloadedLocalSourceTest.kt @@ -0,0 +1,200 @@ +package org.fnives.tiktokdownloader.data.local + +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import org.fnives.tiktokdownloader.data.local.exceptions.StorageException +import org.fnives.tiktokdownloader.data.local.persistent.SharedPreferencesManager +import org.fnives.tiktokdownloader.data.local.save.video.SaveVideoFile +import org.fnives.tiktokdownloader.data.local.verify.exists.VerifyFileForUriExists +import org.fnives.tiktokdownloader.data.model.VideoDownloaded +import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile +import org.fnives.tiktokdownloader.helper.mock.InMemorySharedPreferencesManager +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.InputStream + +@Suppress("TestFunctionName") +class VideoDownloadedLocalSourceTest { + + private lateinit var sut: VideoDownloadedLocalSource + private lateinit var sharedPreferencesManager: SharedPreferencesManager + private lateinit var mockSaveVideoFile: SaveVideoFile + private lateinit var mockVerifyFileForUriExists: VerifyFileForUriExists + + @BeforeEach + fun setup() { + sharedPreferencesManager = InMemorySharedPreferencesManager() + mockSaveVideoFile = mock() + mockVerifyFileForUriExists = mock() + + sut = VideoDownloadedLocalSource( + saveVideoFile = mockSaveVideoFile, + sharedPreferencesManagerImpl = sharedPreferencesManager, + verifyFileForUriExists = mockVerifyFileForUriExists + ) + } + + @Test + fun GIVEN_observing_saved_videos_WHEN_initialized_THEN_emptylist_is_emitted() = runBlocking { + Assertions.assertEquals(emptyList(), sut.savedVideos.first()) + verifyZeroInteractions(mockSaveVideoFile) + verifyZeroInteractions(mockVerifyFileForUriExists) + } + + @Test + fun GIVEN_observing_saved_videos_WAITING_for_2_items_WHEN_initialization_THEN_it_timesOut() { + Assertions.assertThrows(TimeoutCancellationException::class.java) { + runBlocking { + withTimeout(300) { + sut.savedVideos.take(2).collect { } + } + } + } + } + + @Test + fun GIVEN_observing_saved_videos_AND_uri_still_exists_WHEN_video_is_saved_THEN_videoDownloaded_is_sent_out() = + runBlocking { + val videoInSavingIntoFile = VideoInSavingIntoFile( + id = "id", + url = "alma", + contentType = VideoInSavingIntoFile.ContentType("a", "b"), + byteStream = FalseInputStream() + ) + val expectedDir = "TikTok_Downloader" + val expectedFileName = videoInSavingIntoFile.id + ".b" + whenever(mockSaveVideoFile.invoke(expectedDir, expectedFileName, videoInSavingIntoFile)).doReturn("almaUri") + whenever(mockVerifyFileForUriExists.invoke("almaUri")).doReturn(true) + + val actual = async(coroutineContext) { + sut.savedVideos.take(2).toList() + } + yield() + sut.saveVideo(videoInSavingIntoFile) + + Assertions.assertEquals(listOf(VideoDownloaded("id", "alma", "almaUri")), sut.savedVideos.first()) + Assertions.assertEquals(listOf(emptyList(), listOf(VideoDownloaded("id", "alma", "almaUri"))), actual.await()) + } + + @Test + fun GIVEN_observing_saved_videos_AND_uri_doesnt_exists_WHEN_video_is_saved_THEN_videoDownloaded_is_sent_out() = + runBlocking { + val videoInSavingIntoFile = VideoInSavingIntoFile( + id = "id", + url = "alma", + contentType = VideoInSavingIntoFile.ContentType("a", "b"), + byteStream = FalseInputStream() + ) + val expectedDir = "TikTok_Downloader" + val expectedFileName = videoInSavingIntoFile.id + ".b" + whenever(mockSaveVideoFile.invoke(expectedDir, expectedFileName, videoInSavingIntoFile)).doReturn("almaUri") + whenever(mockVerifyFileForUriExists.invoke("almaUri")).doReturn(false) + + val actual = async(coroutineContext) { + sut.savedVideos.take(1).toList() + } + yield() + sut.saveVideo(videoInSavingIntoFile) + + Assertions.assertEquals(emptyList(), sut.savedVideos.first()) + Assertions.assertEquals(listOf(emptyList()), actual.await()) + } + + @Test + fun GIVEN_observing_saved_videos_for_TWO_AND_uri_doesnt_exists_WHEN_video_is_saved_THEN_videoDownloaded_is_sent_out() { + Assertions.assertThrows(CancellationException::class.java) { + runBlocking { + val videoInSavingIntoFile = VideoInSavingIntoFile( + id = "id", + url = "alma", + contentType = VideoInSavingIntoFile.ContentType("a", "b"), + byteStream = FalseInputStream() + ) + val expectedDir = "TikTok_Downloader" + val expectedFileName = videoInSavingIntoFile.id + ".b" + whenever(mockSaveVideoFile.invoke(expectedDir, expectedFileName, videoInSavingIntoFile)).doReturn("almaUri") + whenever(mockVerifyFileForUriExists.invoke("almaUri")).doReturn(false) + + val actual = async(coroutineContext) { + sut.savedVideos.take(2).toList() + } + yield() + sut.saveVideo(videoInSavingIntoFile) + + withTimeout(300) { actual.await() } + } + } + } + + @Test + fun GIVEN_exception_from_savingUseCase_WHEN_saving_video_THEN_the_exception_is_wrapped_into_FileException() { + Assertions.assertThrows(StorageException::class.java) { + runBlocking { + val videoInSavingIntoFile = VideoInSavingIntoFile( + id = "id", + url = "alma", + contentType = VideoInSavingIntoFile.ContentType("a", "b"), + byteStream = FalseInputStream() + ) + val expectedDir = "TikTok_Downloader" + val expectedFileName = videoInSavingIntoFile.id + ".b" + whenever(mockSaveVideoFile.invoke(expectedDir, expectedFileName, videoInSavingIntoFile)) + .then { throw Throwable() } + + sut.saveVideo(videoInSavingIntoFile) + } + } + } + + @Test + fun GIVEN_observing_saved_videos_WHEN_saving_2_videos_THEN_then_both_of_them_are_emitted_in_reverse_order() = + runBlocking { + val videoInSavingIntoFile1 = VideoInSavingIntoFile( + id = "id1", + url = "alma1", + contentType = VideoInSavingIntoFile.ContentType("a1", "b1"), + byteStream = FalseInputStream() + ) + val videoInSavingIntoFile2 = VideoInSavingIntoFile( + id = "id2", + url = "alma2", + contentType = VideoInSavingIntoFile.ContentType("a2", "b2"), + byteStream = FalseInputStream() + ) + whenever(mockVerifyFileForUriExists.invoke(anyOrNull())).doReturn(true) + whenever(mockSaveVideoFile.invoke(anyOrNull(), anyOrNull(), anyOrNull())).then { + "uri: " + (it.arguments[1] as String) + } + val expectedModel1 = VideoDownloaded(id = "id1", url = "alma1", uri = "uri: id1.b1") + val expectedModel2 = VideoDownloaded(id = "id2", url = "alma2", uri = "uri: id2.b2") + val expected = listOf(emptyList(), listOf(expectedModel1), listOf(expectedModel2, expectedModel1)) + val actual = async(coroutineContext) { sut.savedVideos.take(3).toList() } + + yield() + sut.saveVideo(videoInSavingIntoFile1) + delay(100) + yield() + sut.saveVideo(videoInSavingIntoFile2) + + Assertions.assertEquals(expected, actual.await()) + } + + class FalseInputStream : InputStream() { + override fun read(): Int = 0 + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/data/local/VideoInPendingLocalSourceTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/data/local/VideoInPendingLocalSourceTest.kt new file mode 100644 index 0000000..e0300bc --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/data/local/VideoInPendingLocalSourceTest.kt @@ -0,0 +1,95 @@ +package org.fnives.tiktokdownloader.data.local + +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import org.fnives.tiktokdownloader.helper.mock.InMemorySharedPreferencesManager +import org.fnives.tiktokdownloader.data.local.persistent.SharedPreferencesManager +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +class VideoInPendingLocalSourceTest { + + private lateinit var sut: VideoInPendingLocalSource + private lateinit var sharedPreferencesManager: SharedPreferencesManager + + @BeforeEach + fun setup() { + sharedPreferencesManager = InMemorySharedPreferencesManager() + sut = VideoInPendingLocalSource(sharedPreferencesManager) + } + + @Test + fun GIVEN_observing_PendingVideos_WHEN_initialized_THEN_EmptySet_is_sent_out() = runBlocking { + Assertions.assertEquals(emptyList(), sut.pendingVideos.first()) + } + + @Test + fun GIVEN_observing_PendingVideos_for_2_values_WHEN_initialized_THEN_it_times_out() { + Assertions.assertThrows(TimeoutCancellationException::class.java) { + runBlocking { + withTimeout(300) { + sut.pendingVideos.take(2).toList() + } + } + } + } + + @Test + fun GIVEN_observing_PendingVideos_WHEN_marking_a_video_pending_THEN_it_is_sent_out() = runBlocking { + runBlocking { + val actual = async(coroutineContext) { + sut.pendingVideos.take(2).toList() + } + yield() + sut.saveUrlIntoQueue(VideoInPending("id", "alma")) + + Assertions.assertEquals(listOf(VideoInPending("id", "alma")), sut.pendingVideos.first()) + Assertions.assertEquals(listOf(emptyList(), listOf(VideoInPending("id", "alma"))), actual.await()) + } + } + + @Test + fun GIVEN_observing_PendingVideos_AND_video_marked_as_pending_WHEN_saving_video_THEN_its_no_longer_sent_out_as_pending() = + runBlocking { + val videoInPending = VideoInPending("id", "alma") + sut.saveUrlIntoQueue(videoInPending) + + val actual = async(coroutineContext) { + sut.pendingVideos.take(2).toList() + } + yield() + sut.removeVideoFromQueue(videoInPending) + + Assertions.assertEquals(listOf(listOf(VideoInPending("id", "alma")), emptyList()), actual.await()) + } + + @Test + fun GIVEN_observing_PendingVideos_WHEN_2_video_marked_as_pending_THEN_both_of_them_are_sent_out_in_correct_reverse_order() = + runBlocking { + val videoInPending1 = VideoInPending("id1", "alma1") + val videoInPending2 = VideoInPending("id2", "alma2") + val expected = listOf(emptyList(), listOf(videoInPending1), listOf(videoInPending2, videoInPending1)) + + val actual = async(coroutineContext) { + sut.pendingVideos.take(3).toList() + } + yield() + sut.saveUrlIntoQueue(videoInPending1) + delay(10) + yield() + sut.saveUrlIntoQueue(videoInPending2) + + Assertions.assertEquals(expected, actual.await()) + } + +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/data/local/VideoInProgressLocalSourceTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/data/local/VideoInProgressLocalSourceTest.kt new file mode 100644 index 0000000..ed071ad --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/data/local/VideoInProgressLocalSourceTest.kt @@ -0,0 +1,88 @@ +package org.fnives.tiktokdownloader.data.local + +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.fnives.tiktokdownloader.data.model.VideoInProgress +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +class VideoInProgressLocalSourceTest { + + private lateinit var sut: VideoInProgressLocalSource + + @BeforeEach + fun setup() { + sut = VideoInProgressLocalSource() + } + + // region VIDEO IN PROGRESS + @Test + fun GIVEN_observing_videoInProcessFlow_WHEN_initialization_THEN_it_returns_null() = + runBlocking { + Assertions.assertEquals(null, sut.videoInProcessFlow.first()) + } + + @Test + fun GIVEN_observing_videoInProcessFlow_WAITING_for_2_items_WHEN_initialization_THEN_it_timesOut() { + Assertions.assertThrows(TimeoutCancellationException::class.java) { + runBlocking { + withTimeout(300) { + sut.videoInProcessFlow.take(2).toList() + } + } + } + } + + @Test + fun GIVEN_observing_from_videoInProcessFlow_WHEN_markedVideoAsInProgress_THEN_it_is_returned() = + runBlocking { + val actual = async(coroutineContext) { + sut.videoInProcessFlow.take(2).toList() + } + yield() + sut.markVideoAsInProgress(VideoInPending("id", "alma")) + + Assertions.assertEquals(VideoInProgress("id", "alma"), sut.videoInProcessFlow.first()) + Assertions.assertEquals(listOf(null, VideoInProgress("id", "alma")), actual.await()) + } + + @Test + fun GIVEN_observing_videoInProcessFlow_AND_markedVideoAsInProcess_WHEN_unmarking_THEN_null_is_returned() = + runBlocking { + sut.markVideoAsInProgress(VideoInPending("id", "alma")) + + val actual = async(coroutineContext) { + sut.videoInProcessFlow.take(2).toList() + } + yield() + sut.removeVideoAsInProgress(VideoInPending("id", "alma")) + + Assertions.assertEquals(null, sut.videoInProcessFlow.first()) + Assertions.assertEquals(listOf(VideoInProgress("id", "alma"), null), actual.await()) + } + + @Test + fun GIVEN_observing_videoInProcessFlow_AND_markedVideoAsInProcess_WHEN_unmarking_different_one_THEN_nothing_is_sent_out() = + runBlocking { + sut.markVideoAsInProgress(VideoInPending("id", "alma")) + + val actual = async(coroutineContext) { + sut.videoInProcessFlow.take(1).toList() + } + yield() + sut.removeVideoAsInProgress(VideoInPending("-----id------", "alma")) + + Assertions.assertEquals(VideoInProgress("id", "alma"), sut.videoInProcessFlow.first()) + Assertions.assertEquals(listOf(VideoInProgress("id", "alma")), actual.await()) + } + // endregion +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSourceTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSourceTest.kt new file mode 100644 index 0000000..0d8b1da --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSourceTest.kt @@ -0,0 +1,292 @@ +package org.fnives.tiktokdownloader.data.network + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile +import org.fnives.tiktokdownloader.data.network.exceptions.CaptchaRequiredException +import org.fnives.tiktokdownloader.data.network.exceptions.NetworkException +import org.fnives.tiktokdownloader.data.network.exceptions.ParsingException +import org.fnives.tiktokdownloader.di.module.NetworkModule +import org.fnives.tiktokdownloader.helper.readResourceFile +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +@Suppress("TestFunctionName") +class TikTokDownloadRemoteSourceTest { + + private lateinit var mockWebServer: MockWebServer + private lateinit var sut: TikTokDownloadRemoteSource + + @BeforeEach + fun setup() { + mockWebServer = MockWebServer().apply { + start(PORT) + } + sut = NetworkModule(1).tikTokDownloadRemoteSource + } + + @AfterEach + fun tearDown() { + mockWebServer.shutdown() + } + + @Test + fun GIVEN_request_finished_with_cookies_WHEN_request_is_called_THEN_cookieStore_is_clear_and_request_doesnt_contain_cookies() { + val cookieToSet = "that-is-a-cookie" + val shortenedUrlResponseBody = readResourceFileShortenedUrlResponse() + mockWebServer.enqueue(MockResponse() + .setResponseCode(200) + .setBody(shortenedUrlResponseBody) + .setHeader("Set-Cookie", cookieToSet) + ) + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + try { + runBlocking { sut.getVideo(VideoInPending("", TEST_URL)) } + } catch (ignored: Throwable) { + } + // after cookie is saved call again + try { + runBlocking { sut.getVideo(VideoInPending("", TEST_URL)) } + } catch (ignored: Throwable) { + } + + val cookieSavingRequest = mockWebServer.takeRequest() + val failingRequestAfterCookieSave = mockWebServer.takeRequest() + val newFirstRequest = mockWebServer.takeRequest() + + Assertions.assertEquals(null, cookieSavingRequest.headers["Cookie"]) + Assertions.assertEquals(cookieToSet, failingRequestAfterCookieSave.headers["Cookie"]) + Assertions.assertEquals(null, newFirstRequest.headers["Cookie"]) + } + + @Test + fun GIVEN_cookie_in_first_response_THEN_it_is_used_in_the_following_requests() { + val expectedCookie = "that-is-a-cookie" + val shortenedUrlResponseBody = readResourceFileShortenedUrlResponse() + val mainPageVariant1Response = readResourceFileMainPageVariant1Response() + mockWebServer.enqueue( + MockResponse().setResponseCode(200).setHeader("Set-Cookie", expectedCookie).setBody(shortenedUrlResponseBody) + ) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(mainPageVariant1Response)) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("banan")) + val videoInPending = VideoInPending("alma", TEST_URL) + + runBlocking { sut.getVideo(videoInPending) } + + val firstRequest = mockWebServer.takeRequest() + val secondRequest = mockWebServer.takeRequest() + val thirdRequest = mockWebServer.takeRequest() + + Assertions.assertEquals(null, firstRequest.headers["Cookie"]) + Assertions.assertEquals(expectedCookie, secondRequest.headers["Cookie"]) + Assertions.assertEquals(expectedCookie, thirdRequest.headers["Cookie"]) + } + + @Test + fun GIVEN_cookie_with_extra_info_in_first_response_THEN_it_is_used_in_the_following_request_without_the_extra() { + val cookieWithExtra = "that-is-a-cookie; and-some-extra-unnecessary; things" + val expectedCookie = "that-is-a-cookie" + val shortenedUrlResponseBody = readResourceFileShortenedUrlResponse() + mockWebServer.enqueue( + MockResponse().setResponseCode(200).setHeader("Set-Cookie", cookieWithExtra).setBody(shortenedUrlResponseBody) + ) + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + val videoInPending = VideoInPending("alma", TEST_URL) + + try { + runBlocking { sut.getVideo(videoInPending) } + } catch (ignore: Throwable) { + } + + val firstRequest = mockWebServer.takeRequest() + val secondRequest = mockWebServer.takeRequest() + + Assertions.assertEquals(null, firstRequest.headers["Cookie"]) + Assertions.assertEquals(expectedCookie, secondRequest.headers["Cookie"]) + } + + @Test + fun GIVEN_proper_responses_as_variant1_THEN_parsed_properly() = runBlocking { + val expectedId = "alma" + val expectedUrl = TEST_URL + val expectedContentType = VideoInSavingIntoFile.ContentType("video", "mp4") + val shortenedUrlResponseBody = readResourceFileShortenedUrlResponse() + val mainPageVariant1Response = readResourceFileMainPageVariant1Response() + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(shortenedUrlResponseBody)) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(mainPageVariant1Response)) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setHeader("Content-Type", "video/mp4").setBody("banan")) + val videoInPending = VideoInPending("alma", TEST_URL) + + + val response = sut.getVideo(videoInPending) + Assertions.assertEquals(expectedId, response.id) + Assertions.assertEquals(expectedUrl, response.url) + Assertions.assertEquals(expectedContentType, response.contentType) + Assertions.assertEquals("banan", response.byteStream.reader().readText()) + } + + @Test + fun GIVEN_proper_responses_as_variant2_THEN_parsed_properly() = runBlocking { + val expectedId = "e-alma" + val expectedUrl = TEST_URL + val expectedContentType = VideoInSavingIntoFile.ContentType("citrom", "mp4") + val shortenedUrlResponseBody = readResourceFileShortenedUrlResponse() + val mainPageVariant1Response = readResourceFileMainPageVariant2Response() + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(shortenedUrlResponseBody)) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(mainPageVariant1Response)) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setHeader("Content-Type", "citrom/mp4").setBody("a-banan")) + val videoInPending = VideoInPending("e-alma", TEST_URL) + + + val response = sut.getVideo(videoInPending) + Assertions.assertEquals(expectedId, response.id) + Assertions.assertEquals(expectedUrl, response.url) + Assertions.assertEquals(expectedContentType, response.contentType) + Assertions.assertEquals("a-banan", response.byteStream.reader().readText()) + } + + @Test + fun GIVEN_incorrect_first_response_THEN_NetworkException_is_thrown() { + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + + sut.getVideo(VideoInPending("", TEST_URL)) + } + } + } + + @Test + fun GIVEN_proper_first_incorrect_second_response_THEN_NetworkException_is_thrown() { + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { + val shortenedUrlResponseBody = readResourceFileShortenedUrlResponse() + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(shortenedUrlResponseBody)) + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + + sut.getVideo(VideoInPending("", TEST_URL)) + } + } + } + + @Test + fun GIVEN_proper_first_and_second_but_incorrect_video_response_THEN_NetworkException_is_thrown() { + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { + val shortenedUrlResponseBody = readResourceFileShortenedUrlResponse() + val mainPageVariant1Response = readResourceFileMainPageVariant2Response() + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(shortenedUrlResponseBody)) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(mainPageVariant1Response)) + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + + sut.getVideo(VideoInPending("", TEST_URL)) + } + } + } + + @Test + fun GIVEN_not_expected_response_body_as_first_THEN_ParsingException_is_thrown() { + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("this cannot be parsed")) + + sut.getVideo(VideoInPending("", TEST_URL)) + } + } + } + + @Test + fun GIVEN_proper_first_but_not_expected_response_body_as_second_THEN_ParsingException_is_thrown() { + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { + val shortenedUrlResponseBody = readResourceFileShortenedUrlResponse() + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(shortenedUrlResponseBody)) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("this cannot be parsed")) + + sut.getVideo(VideoInPending("", TEST_URL)) + } + } + } + + @MethodSource("captchaResponses") + @ParameterizedTest(name = "GIVEN_{0}_captcha_response_at_first_THEN_CaptchaRequiredException_is_thrown") + fun GIVEN_captcha_response_at_first_THEN_CaptchaRequiredException_is_thrown( + captchaResponseFileName: String, + response: String + ) { + Assertions.assertThrows(CaptchaRequiredException::class.java) { + runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(response)) + + sut.getVideo(VideoInPending("", TEST_URL)) + } + } + } + + @MethodSource("captchaResponses") + @ParameterizedTest(name = "GIVEN_proper_response_first_THEN_{0}_captcha_response_THEN_CaptchaRequiredException_is_thrown") + fun GIVEN_proper_response_first_THEN_captcha_response_THEN_CaptchaRequiredException_is_thrown( + captchaResponseFileName: String, + response: String + ) { + Assertions.assertThrows(CaptchaRequiredException::class.java) { + runBlocking { + val shortenedUrlResponseBody = readResourceFileShortenedUrlResponse() + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(shortenedUrlResponseBody)) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(response)) + + sut.getVideo(VideoInPending("", TEST_URL)) + } + } + } + + companion object { + private const val SHORTENED_URL_RESPONSE = "response/shortened_url_response.html" + private const val CAPTCHA_REQUIRED_RESPONSE_ONE = "response/captcha_required_one.html" + private const val CAPTCHA_REQUIRED_RESPONSE_TWO = "response/captcha_required_two.html" + private const val MAIN_PAGE_VARIANT_1_RESPONSE = "response/main_page_v1.html" + private const val MAIN_PAGE_VARIANT_2_RESPONSE = "response/main_page_v1.html" + private const val PORT = 8080 + private const val TEST_URL = "http://127.0.0.1:$PORT" + + private fun Any.readResourceFileShortenedUrlResponse() = + readResourceFile(SHORTENED_URL_RESPONSE) + .replace("https://www.tiktok.com/@ieclauuu/video/6887614455967010049", TEST_URL) + + private fun Any.readResourceFileMainPageVariant1Response() = + readResourceFile(MAIN_PAGE_VARIANT_1_RESPONSE) + .replace( + "https://v16-web.tiktok.com/video/tos/alisg/tos-alisg-pve-0037c001/9ddfc12f43b04f6596f9953c9a9ca072/?a=1988\\u0026br=1534\\u0026bt=767\\u0026cr=0\\u0026cs=0\\u0026cv=1\\u0026dr=0\\u0026ds=3\\u0026er=\\u0026expire=1603682739\\u0026l=20201025212533010189074225590A080D\\u0026lr=tiktok_m\\u0026mime_type=video_mp4\\u0026policy=2\\u0026qs=0\\u0026rc=amlxbmV1O291eDMzMzczM0ApZDRoZDQ3Nzw1N2U5Nzs3O2dicW1vL2AxZV5fLS1iMTRzczA2Y2NgYTQ2LmE1Y2E0My46Yw%3D%3D\\u0026signature=cce079fd02e4dde94c1c93cfdbd1d100\\u0026tk=tt_webid_v2\\u0026vl=\\u0026vr=", + TEST_URL + ) + + private fun Any.readResourceFileMainPageVariant2Response() = + readResourceFile(MAIN_PAGE_VARIANT_2_RESPONSE) + .replace( + "https://v16-web.tiktok.com/video/tos/alisg/tos-alisg-pve-0037c001/9ddfc12f43b04f6596f9953c9a9ca072/?a=1988\\u0026br=1534\\u0026bt=767\\u0026cr=0\\u0026cs=0\\u0026cv=1\\u0026dr=0\\u0026ds=3\\u0026er=\\u0026expire=1603682739\\u0026l=20201025212533010189074225590A080D\\u0026lr=tiktok_m\\u0026mime_type=video_mp4\\u0026policy=2\\u0026qs=0\\u0026rc=amlxbmV1O291eDMzMzczM0ApZDRoZDQ3Nzw1N2U5Nzs3O2dicW1vL2AxZV5fLS1iMTRzczA2Y2NgYTQ2LmE1Y2E0My46Yw%3D%3D\\u0026signature=cce079fd02e4dde94c1c93cfdbd1d100\\u0026tk=tt_webid_v2\\u0026vl=\\u0026vr=", + TEST_URL + ) + private fun Any.readCaptchaOneResponse() = + readResourceFile(CAPTCHA_REQUIRED_RESPONSE_ONE) + .replace("https://www.tiktok.com/@ieclauuu/video/6887614455967010049", TEST_URL) + + private fun Any.readCaptchaTwoResponse() = + readResourceFile(CAPTCHA_REQUIRED_RESPONSE_TWO) + .replace("https://www.tiktok.com/@ieclauuu/video/6887614455967010049", TEST_URL) + + @JvmStatic + private fun captchaResponses() = Stream.of( + Arguments.of(CAPTCHA_REQUIRED_RESPONSE_ONE, readCaptchaOneResponse()), + Arguments.of(CAPTCHA_REQUIRED_RESPONSE_TWO, readCaptchaTwoResponse()) + ) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSourceUpToDateTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSourceUpToDateTest.kt new file mode 100644 index 0000000..f985611 --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSourceUpToDateTest.kt @@ -0,0 +1,53 @@ +package org.fnives.tiktokdownloader.data.network + +import kotlinx.coroutines.runBlocking +import org.apache.commons.io.FileUtils +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.fnives.tiktokdownloader.di.module.NetworkModule +import org.fnives.tiktokdownloader.helper.getResourceFile +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import java.io.File + +/** + * This is not a usual unit test: + * Since the website may change anytime without any notice, this test verifies with actual request going out. + * However this makes the test shaky, because if the device has no proper connection it may fail. + */ +@Suppress("TestFunctionName") +class TikTokDownloadRemoteSourceUpToDateTest { + + private lateinit var sut: TikTokDownloadRemoteSource + + @BeforeEach + fun setup() { + sut = NetworkModule(1).tikTokDownloadRemoteSource + } + + @Disabled("Can trigger captcha, so only run it separately") + @Test + fun GIVEN_actualVideo_WHEN_downloading_THEN_the_file_matching_with_the_previously_loaded_video() { + val parameter = VideoInPending("123", SUBJECT_VIDEO_URL) + val actualFile = File(ACTUAL_FILE_PATH) + actualFile.delete() + actualFile.createNewFile() + actualFile.deleteOnExit() + val expectedFile = getResourceFile(EXPECTED_FILE_PATH) + actualFile.writeText("") + + runBlocking { sut.getVideo(parameter).byteStream }.use { inputStream -> + actualFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + Assertions.assertTrue(FileUtils.contentEquals(expectedFile, actualFile), "The Downloaded file Is Not Matching the expected") + } + + companion object { + private const val ACTUAL_FILE_PATH = "actual.mp4" + private const val EXPECTED_FILE_PATH = "video/expected.mp4" + private const val SUBJECT_VIDEO_URL = "https://vm.tiktok.com/ZSQG7SMf/" + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/data/usecase/AddVideoToQueueUseCaseTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/data/usecase/AddVideoToQueueUseCaseTest.kt new file mode 100644 index 0000000..66a048f --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/data/usecase/AddVideoToQueueUseCaseTest.kt @@ -0,0 +1,88 @@ +package org.fnives.tiktokdownloader.data.usecase + +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import org.fnives.tiktokdownloader.data.local.VideoInPendingLocalSource +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +class AddVideoToQueueUseCaseTest { + + private lateinit var sut: AddVideoToQueueUseCase + private lateinit var mockUrlVerificationUseCase: UrlVerificationUseCase + private lateinit var mockVideoInPendingLocalSource: VideoInPendingLocalSource + + @BeforeEach + fun setup() { + mockUrlVerificationUseCase = mock() + mockVideoInPendingLocalSource = mock() + sut = AddVideoToQueueUseCase(mockUrlVerificationUseCase, mockVideoInPendingLocalSource) + } + + @Test + fun GIVEN_no_action_THEN_the_local_source_and_verifier_is_not_touched() { + verifyZeroInteractions(mockUrlVerificationUseCase) + verifyZeroInteractions(mockVideoInPendingLocalSource) + } + + @Test + fun GIVEN_url_WHEN_calling_add_to_queue_THEN_it_is_delegated_to_storage() { + val expectedUrl = "https://unquie.url" + lateinit var argument : VideoInPending + whenever(mockUrlVerificationUseCase.invoke(anyOrNull())).doReturn(true) + whenever(mockVideoInPendingLocalSource.saveUrlIntoQueue(anyOrNull())).then { + argument = it.arguments.first() as VideoInPending + Unit + } + + val actual = sut.invoke(expectedUrl) + + Assertions.assertTrue(actual, "Url is NOT Saved while it should be") + verify(mockUrlVerificationUseCase, times(1)).invoke(expectedUrl) + verifyNoMoreInteractions(mockUrlVerificationUseCase) + verify(mockVideoInPendingLocalSource, times(1)).saveUrlIntoQueue(argument) + verifyNoMoreInteractions(mockVideoInPendingLocalSource) + Assertions.assertEquals(VideoInPending(argument.id, url = expectedUrl), argument) + Assertions.assertTrue(argument.id.isNotBlank(), "Created VideoInPending Id is BLANK") + } + + @Test + fun GIVEN_throwing_saveURl_WHEN_calling_add_to_queue_THEN_it_throws_the_same_exception() { + Assertions.assertThrows(Throwable::class.java) { + val expectedException = Throwable() + whenever(mockUrlVerificationUseCase.invoke(anyOrNull())).doReturn(true) + whenever(mockVideoInPendingLocalSource.saveUrlIntoQueue(anyOrNull())).then { + throw expectedException + } + + try { + sut.invoke("alma") + } catch (throwable: Throwable) { + Assertions.assertSame(expectedException, throwable) + throw throwable + } + } + } + + @Test + fun GIVEN_url_WHEN_verification_fails_THEN_it_is_not_saved_into_localSource() { + val expectedUrl = "https://unquie.url" + whenever(mockUrlVerificationUseCase.invoke(anyOrNull())).doReturn(false) + + val actual = sut.invoke(expectedUrl) + + Assertions.assertFalse(actual, "Url is Saved while it should NOT be") + verify(mockUrlVerificationUseCase, times(1)).invoke(expectedUrl) + verifyNoMoreInteractions(mockUrlVerificationUseCase) + verifyZeroInteractions(mockVideoInPendingLocalSource) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/data/usecase/StateOfVideosObservableUseCaseTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/data/usecase/StateOfVideosObservableUseCaseTest.kt new file mode 100644 index 0000000..26f77f9 --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/data/usecase/StateOfVideosObservableUseCaseTest.kt @@ -0,0 +1,242 @@ +package org.fnives.tiktokdownloader.data.usecase + +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineDispatcher +import org.fnives.tiktokdownloader.data.local.VideoDownloadedLocalSource +import org.fnives.tiktokdownloader.data.local.VideoInPendingLocalSource +import org.fnives.tiktokdownloader.data.local.VideoInProgressLocalSource +import org.fnives.tiktokdownloader.data.model.VideoDownloaded +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.fnives.tiktokdownloader.data.model.VideoInProgress +import org.fnives.tiktokdownloader.data.model.VideoState +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +class StateOfVideosObservableUseCaseTest { + + private lateinit var testDispatcher: TestCoroutineDispatcher + private lateinit var mockVideoInProgressLocalSource: VideoInProgressLocalSource + private lateinit var mockVideoInPendingLocalSource: VideoInPendingLocalSource + private lateinit var mockVideoDownloadedLocalSource: VideoDownloadedLocalSource + private lateinit var videoInProgressMutableFlow: MutableStateFlow + private lateinit var videoInPendingMutableFlow: MutableStateFlow> + private lateinit var videoDownloadedMutableFlow: MutableStateFlow> + private lateinit var sut: StateOfVideosObservableUseCase + + @BeforeEach + fun setup() { + mockVideoInProgressLocalSource = mock() + mockVideoInPendingLocalSource = mock() + mockVideoDownloadedLocalSource = mock() + videoInProgressMutableFlow = MutableStateFlow(null) + videoInPendingMutableFlow = MutableStateFlow(emptyList()) + videoDownloadedMutableFlow = MutableStateFlow(emptyList()) + whenever(mockVideoInProgressLocalSource.videoInProcessFlow).doReturn(videoInProgressMutableFlow) + whenever(mockVideoInPendingLocalSource.pendingVideos).doReturn(videoInPendingMutableFlow) + whenever(mockVideoDownloadedLocalSource.savedVideos).doReturn(videoDownloadedMutableFlow) + testDispatcher = TestCoroutineDispatcher() + sut = StateOfVideosObservableUseCase( + videoInProgressLocalSource = mockVideoInProgressLocalSource, + videoInPendingLocalSource = mockVideoInPendingLocalSource, + videoDownloadedLocalSource = mockVideoDownloadedLocalSource, + dispatcher = testDispatcher + ) + } + + @Test + fun WHEN_no_invoke_is_called_THEN_no_dependency_is_called() { + verifyZeroInteractions(mockVideoDownloadedLocalSource) + verifyZeroInteractions(mockVideoInPendingLocalSource) + verifyZeroInteractions(mockVideoInProgressLocalSource) + } + + @Test + fun GIVEN_no_inProgress_AND_empty_pending_AND_empty_saved_THEN_emptyList_is_emitted() = runBlocking(testDispatcher) { + videoInProgressMutableFlow.value = null + videoInPendingMutableFlow.value = emptyList() + videoDownloadedMutableFlow.value = emptyList() + + val result = async(testDispatcher) { sut.invoke().take(1).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(listOf(emptyList()), result.await()) + verify(mockVideoDownloadedLocalSource, times(1)).savedVideos + verify(mockVideoInPendingLocalSource, times(1)).pendingVideos + verify(mockVideoInProgressLocalSource, times(1)).videoInProcessFlow + verifyNoMoreInteractions(mockVideoDownloadedLocalSource) + verifyNoMoreInteractions(mockVideoInPendingLocalSource) + verifyNoMoreInteractions(mockVideoInProgressLocalSource) + } + + @Test + fun GIVEN_inProgress_AND_empty_pending_AND_empty_saved_THEN_inProgress_is_emitted() = runBlocking(testDispatcher) { + val videoInProgress = VideoInProgress("alma", "url") + val expected = listOf(VideoState.InProcess(videoInProgress)) + val expectedList = listOf(emptyList(), expected) + + val resultList = async(testDispatcher) { sut.invoke().take(2).toList() } + testDispatcher.advanceUntilIdle() + + videoInProgressMutableFlow.value = videoInProgress + videoInPendingMutableFlow.value = emptyList() + videoDownloadedMutableFlow.value = emptyList() + + val result = async(testDispatcher) { sut.invoke().first() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, result.await()) + Assertions.assertEquals(expectedList, resultList.await()) + verify(mockVideoDownloadedLocalSource, times(1)).savedVideos + verify(mockVideoInPendingLocalSource, times(1)).pendingVideos + verify(mockVideoInProgressLocalSource, times(1)).videoInProcessFlow + verifyNoMoreInteractions(mockVideoDownloadedLocalSource) + verifyNoMoreInteractions(mockVideoInPendingLocalSource) + verifyNoMoreInteractions(mockVideoInProgressLocalSource) + } + + @Test + fun GIVEN_inProgress_AND_pendingWithSameId_AND_empty_saved_THEN_inProgress_is_emitted() = runBlocking(testDispatcher) { + val videoInProgress = VideoInProgress("alma", "url") + val videoInPending = VideoInPending(id = videoInProgress.id, url = videoInProgress.url) + val expected = listOf(VideoState.InProcess(videoInProgress)) + val expectedList = listOf(emptyList(), expected) + + val resultList = async(testDispatcher) { sut.invoke().take(2).toList() } + testDispatcher.advanceUntilIdle() + + videoInProgressMutableFlow.value = videoInProgress + videoInPendingMutableFlow.value = listOf(videoInPending) + videoDownloadedMutableFlow.value = emptyList() + + val result = async(testDispatcher) { sut.invoke().first() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, result.await()) + Assertions.assertEquals(expectedList, resultList.await()) + } + + @Test + fun GIVEN_in_pending_AND_nothing_inprogress_AND_empty_saved_THEN_inPending_is_emitted() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending(id = "alma", url = "url") + val expected = listOf(VideoState.InPending(videoInPending)) + val expectedList = listOf(emptyList(), expected) + + val resultList = async(testDispatcher) { sut.invoke().take(2).toList() } + testDispatcher.advanceUntilIdle() + + videoInProgressMutableFlow.value = null + videoInPendingMutableFlow.value = listOf(videoInPending) + videoDownloadedMutableFlow.value = emptyList() + + val result = async(testDispatcher) { sut.invoke().first() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, result.await()) + Assertions.assertEquals(expectedList, resultList.await()) + } + + @Test + fun GIVEN_inProgress_AND_pendingWithSameId_AND_savedWithSameId_THEN_inProgress_And_saved_is_emitted() = + runBlocking(testDispatcher) { + val videoInProgress = VideoInProgress("alma", "url") + val videoInPending = VideoInPending(id = videoInProgress.id, url = videoInProgress.url) + val videoDownloaded = VideoDownloaded(id = videoInProgress.id, url = videoInProgress.url, uri = "uri") + val expected = listOf(VideoState.InProcess(videoInProgress), VideoState.Downloaded(videoDownloaded)) + val expectedList = listOf(emptyList(), expected) + + val resultList = async(testDispatcher) { sut.invoke().take(2).toList() } + testDispatcher.advanceUntilIdle() + + videoInProgressMutableFlow.value = videoInProgress + videoInPendingMutableFlow.value = listOf(videoInPending) + videoDownloadedMutableFlow.value = listOf(videoDownloaded) + + val result = async(testDispatcher) { sut.invoke().first() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, result.await()) + Assertions.assertEquals(expectedList, resultList.await()) + } + + @Test + fun GIVEN_new_item_faster_than_debounce_THEN_only_the_last_items_are_emitted() = runBlocking(testDispatcher) { + val videoInProgress = VideoInProgress("alma", "url") + val expected = listOf(VideoState.InProcess(videoInProgress)) + val expectedList = listOf(expected) + + videoInProgressMutableFlow.value = null + videoInPendingMutableFlow.value = emptyList() + videoDownloadedMutableFlow.value = emptyList() + val resultList = async(testDispatcher) { sut.invoke().take(1).toList() } + testDispatcher.advanceTimeBy(199) + + videoInProgressMutableFlow.value = videoInProgress + + val result = async(testDispatcher) { sut.invoke().first() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, result.await()) + Assertions.assertEquals(expectedList, resultList.await()) + } + + @Test + fun GIVEN_new_item_slower_than_debounce_THEN_both_list_is_emitted() = runBlocking(testDispatcher) { + val videoInProgress = VideoInProgress("alma", "url") + val expected = listOf(VideoState.InProcess(videoInProgress)) + val expectedList = listOf(emptyList(), expected) + + videoInProgressMutableFlow.value = null + videoInPendingMutableFlow.value = emptyList() + videoDownloadedMutableFlow.value = emptyList() + val resultList = async(testDispatcher) { sut.invoke().take(2).toList() } + testDispatcher.advanceTimeBy(200) + + videoInProgressMutableFlow.value = videoInProgress + + val result = async(testDispatcher) { sut.invoke().first() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, result.await()) + Assertions.assertEquals(expectedList, resultList.await()) + } + + @Test + fun GIVEN_processing_LATER_pendingWithSameId_THEN_no_new_emition_happens() = runBlocking(testDispatcher) { + val videoInProgress = VideoInProgress("alma", "url") + val videoInPendingSameAsProgress = VideoInPending(id = videoInProgress.id, url = videoInProgress.url) + val videoInPendingOther = VideoInPending(id = "alma2", url = "url2") + val expectedStart = listOf(VideoState.InProcess(videoInProgress)) + val expectedEnd = listOf(VideoState.InProcess(videoInProgress), VideoState.InPending(videoInPendingOther)) + val expectedList = listOf(expectedStart, expectedEnd) + + videoInProgressMutableFlow.value = videoInProgress + videoInPendingMutableFlow.value = emptyList() + videoDownloadedMutableFlow.value = emptyList() + val resultList = async(testDispatcher) { sut.invoke().take(2).toList() } + testDispatcher.advanceUntilIdle() + + videoInPendingMutableFlow.value = listOf(videoInPendingSameAsProgress) + testDispatcher.advanceUntilIdle() + videoInPendingMutableFlow.value = listOf(videoInPendingOther) + + val result = async(testDispatcher) { sut.invoke().first() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedEnd, result.await()) + Assertions.assertEquals(expectedList, resultList.await()) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/data/usecase/UrlVerificationUseCaseTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/data/usecase/UrlVerificationUseCaseTest.kt new file mode 100644 index 0000000..bf53159 --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/data/usecase/UrlVerificationUseCaseTest.kt @@ -0,0 +1,30 @@ +package org.fnives.tiktokdownloader.data.usecase + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +class UrlVerificationUseCaseTest { + + private lateinit var sut: UrlVerificationUseCase + + @BeforeEach + fun setup() { + sut = UrlVerificationUseCase() + } + + @Test + fun GIVEN_url_containing_tiktok_THEN_it_is_ok() { + val actual = sut.invoke("https://vm.tiktok.com/d42dfsf") + + Assertions.assertTrue(actual, "Url is considered NOT valid") + } + + @Test + fun GIVEN_url_NOT_containing_tiktok_THEN_it_is_ok() { + val actual = sut.invoke("https://vm.t.com/d42dfsf") + + Assertions.assertFalse(actual, "Url is considered valid") + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/data/usecase/VideoDownloadingProcessorUseCaseTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/data/usecase/VideoDownloadingProcessorUseCaseTest.kt new file mode 100644 index 0000000..bc39e8c --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/data/usecase/VideoDownloadingProcessorUseCaseTest.kt @@ -0,0 +1,483 @@ +package org.fnives.tiktokdownloader.data.usecase + +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.tiktokdownloader.data.local.CaptchaTimeoutLocalSource +import org.fnives.tiktokdownloader.data.local.VideoDownloadedLocalSource +import org.fnives.tiktokdownloader.data.local.VideoInPendingLocalSource +import org.fnives.tiktokdownloader.data.local.VideoInProgressLocalSource +import org.fnives.tiktokdownloader.data.local.exceptions.StorageException +import org.fnives.tiktokdownloader.data.model.ProcessState +import org.fnives.tiktokdownloader.data.model.VideoDownloaded +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.fnives.tiktokdownloader.data.model.VideoInProgress +import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile +import org.fnives.tiktokdownloader.data.network.TikTokDownloadRemoteSource +import org.fnives.tiktokdownloader.data.network.exceptions.CaptchaRequiredException +import org.fnives.tiktokdownloader.data.network.exceptions.NetworkException +import org.fnives.tiktokdownloader.data.network.exceptions.ParsingException +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.InputStream + +@Suppress("TestFunctionName") +class VideoDownloadingProcessorUseCaseTest { + + private lateinit var testDispatcher: TestCoroutineDispatcher + private lateinit var mockVideoInProgressLocalSource: VideoInProgressLocalSource + private lateinit var mockVideoInPendingLocalSource: VideoInPendingLocalSource + private lateinit var mockVideoDownloadedLocalSource: VideoDownloadedLocalSource + private lateinit var mockTikTokDownloadRemoteSource: TikTokDownloadRemoteSource + private lateinit var mockCaptchaTimeoutLocalSource: CaptchaTimeoutLocalSource + private lateinit var videoInProgressMutableFlow: MutableStateFlow + private lateinit var videoInPendingMutableFlow: MutableStateFlow> + private lateinit var videoDownloadedMutableFlow: MutableStateFlow> + private lateinit var sut: VideoDownloadingProcessorUseCase + + @BeforeEach + fun setup() { + mockVideoInProgressLocalSource = mock() + mockVideoInPendingLocalSource = mock() + mockVideoDownloadedLocalSource = mock() + mockTikTokDownloadRemoteSource = mock() + mockCaptchaTimeoutLocalSource = mock() + videoInProgressMutableFlow = MutableStateFlow(null) + videoInPendingMutableFlow = MutableStateFlow(emptyList()) + videoDownloadedMutableFlow = MutableStateFlow(emptyList()) + whenever(mockVideoInProgressLocalSource.videoInProcessFlow).doReturn(videoInProgressMutableFlow) + whenever(mockVideoInPendingLocalSource.pendingVideos).doReturn(videoInPendingMutableFlow) + whenever(mockVideoDownloadedLocalSource.savedVideos).doReturn(videoDownloadedMutableFlow) + testDispatcher = TestCoroutineDispatcher() + sut = VideoDownloadingProcessorUseCase( + videoInProgressLocalSource = mockVideoInProgressLocalSource, + videoInPendingLocalSource = mockVideoInPendingLocalSource, + videoDownloadedLocalSource = mockVideoDownloadedLocalSource, + tikTokDownloadRemoteSource = mockTikTokDownloadRemoteSource, + captchaTimeoutLocalSource = mockCaptchaTimeoutLocalSource, + dispatcher = testDispatcher + ) + } + + @Test + fun WHEN_no_method_invoked_THEN_no_interaction_with_dependencies() { + verifyZeroInteractions(mockVideoInProgressLocalSource) + verifyZeroInteractions(mockVideoInPendingLocalSource) + verifyZeroInteractions(mockVideoDownloadedLocalSource) + verifyZeroInteractions(mockTikTokDownloadRemoteSource) + } + + @Test + fun GIVEN_not_observing_WHEN_fetching_THEN_nothing_happens() { + sut.fetchVideoInState() + + verifyZeroInteractions(mockVideoInProgressLocalSource) + verifyZeroInteractions(mockVideoInPendingLocalSource) + verifyZeroInteractions(mockVideoDownloadedLocalSource) + verifyZeroInteractions(mockTikTokDownloadRemoteSource) + } + + @Test + fun GIVEN_empty_pendingVideos_WHEN_observing_THEN_error_is_emited() = runBlocking(testDispatcher) { + videoInPendingMutableFlow.value = emptyList() + val expected = ProcessState.Finished + val expectedList = listOf(expected) + + val resultList = async(testDispatcher) { sut.processState.take(1).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + verify(mockVideoInPendingLocalSource, times(1)).pendingVideos + verifyNoMoreInteractions(mockVideoInPendingLocalSource) + verifyZeroInteractions(mockVideoInPendingLocalSource) + verifyZeroInteractions(mockVideoDownloadedLocalSource) + verifyZeroInteractions(mockTikTokDownloadRemoteSource) + } + + @Test + fun GIVEN_one_pending_video_AND_network_error_WHEN_observing_THEN_error_is_emited() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).then { throw NetworkException() } + val expected = ProcessState.NetworkError + val expectedList = listOf(ProcessState.Processing(videoInPending), expected) + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + verify(mockVideoInPendingLocalSource, times(1)).pendingVideos + verifyNoMoreInteractions(mockVideoInPendingLocalSource) + verify(mockTikTokDownloadRemoteSource, times(1)).getVideo(videoInPending) + verify(mockVideoDownloadedLocalSource, times(1)).savedVideos + verifyNoMoreInteractions(mockTikTokDownloadRemoteSource) + verifyNoMoreInteractions(mockVideoDownloadedLocalSource) + verifyZeroInteractions(mockVideoInPendingLocalSource) + } + + @Test + fun GIVEN_one_pending_video_AND_parsing_error_WHEN_observing_THEN_parsingError_is_emited() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).then { throw ParsingException() } + val expected = ProcessState.ParsingError + val expectedList = listOf(ProcessState.Processing(videoInPending), expected) + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + } + + @Test + fun GIVEN_one_pending_video_AND_unexpected_error_WHEN_observing_THEN_unknown_error_is_emitted() = runBlockingTest(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).then { throw Throwable() } + val expected = ProcessState.UnknownError + val expectedList = listOf(ProcessState.Processing(videoInPending), expected) + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + } + + @Test + fun GIVEN_one_pending_video_AND_network_errors_WHILE_observing_WHEN_fetching_THEN_it_retries() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + var specificException = true + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).then { + throw if (specificException) NetworkException().also { specificException = false } else Throwable() + } + val inProgressItem = ProcessState.Processing(videoInPending) + val expectedList = listOf(inProgressItem, ProcessState.NetworkError, inProgressItem, ProcessState.UnknownError) + + val resultList = async(testDispatcher) { sut.processState.take(4).toList() } + testDispatcher.advanceUntilIdle() + sut.fetchVideoInState() + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + } + + @Test + fun GIVEN_one_pending_video_AND_parsing_errors_WHILE_observing_WHEN_fetching_THEN_it_retries() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + var specificException = true + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).then { + throw if (specificException) ParsingException().also { specificException = false } else Throwable() + } + val inProgressItem = ProcessState.Processing(videoInPending) + val expectedList = listOf(inProgressItem, ProcessState.ParsingError, inProgressItem, ProcessState.UnknownError) + + val resultList = async(testDispatcher) { sut.processState.take(4).toList() } + testDispatcher.advanceUntilIdle() + sut.fetchVideoInState() + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + } + + @Test + fun GIVEN_one_pending_video_AND_unknown_errors_WHILE_observing_WHEN_fetching_THEN_it_retries() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + var specificException = true + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).then { + throw if (specificException) Throwable().also { specificException = false } else NetworkException() + } + val inProgressItem = ProcessState.Processing(videoInPending) + val expectedList = listOf(inProgressItem, ProcessState.UnknownError, inProgressItem, ProcessState.NetworkError) + + val resultList = async(testDispatcher) { sut.processState.take(4).toList() } + testDispatcher.advanceUntilIdle() + sut.fetchVideoInState() + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + } + + // verify that fetching even while request is running doesn't matter, only after error is emitted + @Test + fun GIVEN_one_pending_video_AND_delaying_until_fetch_WHILE_observing_WHEN_fetching_THEN_emition_happens_only_once() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + var specificException = true + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).then { + throw if (specificException) { + sut.fetchVideoInState() + specificException = false + + NetworkException() + } else { + Throwable() + } + } + val inProgressItem = ProcessState.Processing(videoInPending) + val expectedList = listOf(inProgressItem, ProcessState.NetworkError) + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + verify(mockTikTokDownloadRemoteSource, times(1)).getVideo(videoInPending) + verifyNoMoreInteractions(mockTikTokDownloadRemoteSource) + } + + @Test + fun GIVEN_one_pending_video_AND_failing_request_WHEN_observing_THEN_video_is_marked_processing_then_unprocessing() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).then { + throw NetworkException() + } + val inProgressItem = ProcessState.Processing(videoInPending) + val expectedList = listOf(inProgressItem, ProcessState.NetworkError) + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + verify(mockVideoInProgressLocalSource, times(1)).markVideoAsInProgress(videoInPending) + verify(mockVideoInProgressLocalSource, times(1)).removeVideoAsInProgress(videoInPending) + verifyNoMoreInteractions(mockVideoInProgressLocalSource) + } + + @Test + fun GIVEN_one_pending_video_AND_successful_request_AND_storage_error_WHEN_observing_THEN_video_is_saved_called_and_error_is_propogated() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + val videoInSavingIntoFile = VideoInSavingIntoFile("x","u",VideoInSavingIntoFile.ContentType("a","b"), FalseInputStream()) + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).doReturn(videoInSavingIntoFile) + whenever(mockVideoDownloadedLocalSource.saveVideo(anyOrNull())).then { + throw StorageException() + } + val expected = ProcessState.StorageError + val expectedList = listOf(ProcessState.Processing(videoInPending), expected) + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + verify(mockVideoDownloadedLocalSource, times(1)).saveVideo(videoInSavingIntoFile) + verify(mockVideoDownloadedLocalSource, times(1)).savedVideos + verifyNoMoreInteractions(mockVideoDownloadedLocalSource) + } + + @Test + fun GIVEN_one_pending_video_AND_successful_request_AND_unexpected_error_WHEN_observing_THEN_video_is_saved_called_and_error_is_propogated() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + val videoInSavingIntoFile = VideoInSavingIntoFile("x","u",VideoInSavingIntoFile.ContentType("a","b"), FalseInputStream()) + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).doReturn(videoInSavingIntoFile) + whenever(mockVideoDownloadedLocalSource.saveVideo(anyOrNull())).then { + throw Throwable() + } + val expected = ProcessState.UnknownError + val expectedList = listOf(ProcessState.Processing(videoInPending), expected) + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + verify(mockVideoDownloadedLocalSource, times(1)).saveVideo(videoInSavingIntoFile) + verify(mockVideoDownloadedLocalSource, times(1)).savedVideos + verifyNoMoreInteractions(mockVideoDownloadedLocalSource) + } + + @Test + fun GIVEN_one_pending_video_AND_successful_request_AND_successful_file_save_WHEN_observing_THEN_pending_is_removed() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + val videoDownloaded = VideoDownloaded("zz","yy","xx") + videoInPendingMutableFlow.value = listOf(videoInPending) + val videoInSavingIntoFile = VideoInSavingIntoFile("x","u",VideoInSavingIntoFile.ContentType("a","b"), FalseInputStream()) + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).doReturn(videoInSavingIntoFile) + whenever(mockVideoDownloadedLocalSource.saveVideo(anyOrNull())).doReturn(videoDownloaded) + val expectedList = listOf( + ProcessState.Processing(videoInPending), + ProcessState.Processed(videoDownloaded) + ) + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + verify(mockVideoInPendingLocalSource, times(1)).removeVideoFromQueue(videoInPending) + verify(mockVideoInPendingLocalSource, times(1)).pendingVideos + verifyNoMoreInteractions(mockVideoInPendingLocalSource) + } + + @Test + fun GIVEN_one_pending_video_AND_captcha_timeout_WHEN_observing_THEN_captcha_timeout_is_emited() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + whenever(mockCaptchaTimeoutLocalSource.isInCaptchaTimeout()).doReturn(true) + val expectedList = listOf( + ProcessState.Processing(videoInPending), + ProcessState.CaptchaError + ) + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + verify(mockVideoInProgressLocalSource, times(1)).removeVideoAsInProgress(videoInPending) + verify(mockVideoInPendingLocalSource, times(1)).pendingVideos + verifyNoMoreInteractions(mockVideoInPendingLocalSource) + } + + @Test + fun GIVEN_one_pending_video_AND_successful_request_AND_successful_file_save_WHEN_observing_with_2_THEN_pending_is_removed_AND_only_once_executed() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + val videoDownloaded = VideoDownloaded("zz","yy","xx") + videoInPendingMutableFlow.value = listOf(videoInPending) + val videoInSavingIntoFile = VideoInSavingIntoFile("x","u",VideoInSavingIntoFile.ContentType("a","b"), FalseInputStream()) + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).doReturn(videoInSavingIntoFile) + whenever(mockVideoDownloadedLocalSource.saveVideo(anyOrNull())).doReturn(videoDownloaded) + val expectedList = listOf( + ProcessState.Processing(videoInPending), + ProcessState.Processed(videoDownloaded) + ) + + val resultList1 = async(testDispatcher) { sut.processState.take(2).toList() } + val resultList2 = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList1.await()) + Assertions.assertEquals(expectedList, resultList2.await()) + verify(mockVideoInPendingLocalSource, times(1)).removeVideoFromQueue(videoInPending) + verify(mockVideoInPendingLocalSource, times(1)).pendingVideos + verifyNoMoreInteractions(mockVideoInPendingLocalSource) + } + + @Test + fun GIVEN_one_pending_video_BUT_already_downloaded_WHEN_observing_THEN_processed_is_emitted_but_no_request_call() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + val videoDownloaded = VideoDownloaded("alma","banan","xx") + videoInPendingMutableFlow.value = listOf(videoInPending) + videoDownloadedMutableFlow.value = listOf(videoDownloaded) + val expectedList = listOf( + ProcessState.Processing(videoInPending), + ProcessState.Processed(videoDownloaded) + ) + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + verify(mockVideoDownloadedLocalSource, times(1)).savedVideos + verify(mockVideoInPendingLocalSource, times(1)).removeVideoFromQueue(videoInPending) + verify(mockVideoInPendingLocalSource, times(1)).pendingVideos + verify(mockVideoInProgressLocalSource, times(1)).removeVideoAsInProgress(videoInPending) + verifyNoMoreInteractions(mockVideoInPendingLocalSource) + verifyNoMoreInteractions(mockVideoDownloadedLocalSource) + verifyNoMoreInteractions(mockVideoInProgressLocalSource) + verifyZeroInteractions(mockTikTokDownloadRemoteSource) + } + + @Test + fun GIVEN_one_pending_video_BUT_already_downloaded_AND_captcha_timeout_WHEN_observing_THEN_processed_is_emitted_but_no_request_call() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + val videoDownloaded = VideoDownloaded("alma","banan","xx") + videoInPendingMutableFlow.value = listOf(videoInPending) + videoDownloadedMutableFlow.value = listOf(videoDownloaded) + whenever(mockCaptchaTimeoutLocalSource.isInCaptchaTimeout()).doReturn(true) + val expectedList = listOf( + ProcessState.Processing(videoInPending), + ProcessState.Processed(videoDownloaded) + ) + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + verify(mockVideoDownloadedLocalSource, times(1)).savedVideos + verify(mockVideoInPendingLocalSource, times(1)).removeVideoFromQueue(videoInPending) + verify(mockVideoInPendingLocalSource, times(1)).pendingVideos + verify(mockVideoInProgressLocalSource, times(1)).removeVideoAsInProgress(videoInPending) + verifyNoMoreInteractions(mockVideoInPendingLocalSource) + verifyNoMoreInteractions(mockVideoDownloadedLocalSource) + verifyNoMoreInteractions(mockVideoInProgressLocalSource) + verifyZeroInteractions(mockTikTokDownloadRemoteSource) + } + + @Test + fun GIVEN_one_pending_video_BUT_CaptchaTimeoutException_WHEN_observing_THEN_its_saved_and_captchaError_emitted() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + videoDownloadedMutableFlow.value = listOf() + val expectedList = listOf( + ProcessState.Processing(videoInPending), + ProcessState.CaptchaError + ) + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).then { + throw CaptchaRequiredException() + } + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expectedList, resultList.await()) + verify(mockVideoInPendingLocalSource, times(1)).pendingVideos + verify(mockVideoInProgressLocalSource, times(1)).removeVideoAsInProgress(videoInPending) + verify(mockVideoInProgressLocalSource, times(1)).markVideoAsInProgress(videoInPending) + verify(mockVideoInProgressLocalSource, times(1)).removeVideoAsInProgress(videoInPending) + verify(mockVideoDownloadedLocalSource, times(1)).savedVideos + verify(mockTikTokDownloadRemoteSource, times(1)).getVideo(anyOrNull()) + verify(mockCaptchaTimeoutLocalSource, times(1)).isInCaptchaTimeout() + verify(mockCaptchaTimeoutLocalSource, times(1)).onCaptchaResponseReceived() + verifyNoMoreInteractions(mockVideoInPendingLocalSource) + verifyNoMoreInteractions(mockVideoDownloadedLocalSource) + verifyNoMoreInteractions(mockVideoInProgressLocalSource) + verifyNoMoreInteractions(mockTikTokDownloadRemoteSource) + verifyNoMoreInteractions(mockCaptchaTimeoutLocalSource) + } + + @Test + fun GIVEN_one_pending_video_AND_not_advancing_enough_WHILE_observing_WHEN_fetching_THEN_nothing_is_called() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).then { throw NetworkException() } + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceTimeBy(199) + + verifyZeroInteractions(mockTikTokDownloadRemoteSource) + resultList.cancelAndJoin() + } + + @Test + fun GIVEN_one_pending_video_AND_but_advancing_enough_WHILE_observing_WHEN_fetching_THEN_nothing_is_called() = runBlocking(testDispatcher) { + val videoInPending = VideoInPending("alma", "banan") + videoInPendingMutableFlow.value = listOf(videoInPending) + whenever(mockTikTokDownloadRemoteSource.getVideo(videoInPending)).then { throw NetworkException() } + + val resultList = async(testDispatcher) { sut.processState.take(2).toList() } + testDispatcher.advanceTimeBy(200) + + verify(mockTikTokDownloadRemoteSource, times(1)).getVideo(videoInPending) + verifyNoMoreInteractions(mockTikTokDownloadRemoteSource) + resultList.cancelAndJoin() + } + + class FalseInputStream : InputStream() { + override fun read(): Int = 0 + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/di/ServiceLocatorTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/di/ServiceLocatorTest.kt new file mode 100644 index 0000000..b362019 --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/di/ServiceLocatorTest.kt @@ -0,0 +1,51 @@ +package org.fnives.tiktokdownloader.di + +import android.content.Context +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.fnives.tiktokdownloader.helper.mock.MockSavedStateRegistryOwner +import org.fnives.tiktokdownloader.ui.main.MainViewModel +import org.fnives.tiktokdownloader.ui.main.queue.QueueViewModel +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ServiceLocatorTest { + + private lateinit var mockContext : Context + + @BeforeEach + fun setup() { + Dispatchers.setMain(Dispatchers.Unconfined) + mockContext = mock() + whenever(mockContext.applicationContext).doReturn(mockContext) + whenever(mockContext.contentResolver).doReturn(mock()) + whenever(mockContext.getSharedPreferences(anyOrNull(), anyOrNull())).doReturn(mock()) + ServiceLocator.start(mockContext) + } + + @AfterEach + fun tearDown(){ + Dispatchers.resetMain() + } + + @Test + fun verifyQueueDownloadViewModelCanBeCreated() { + ServiceLocator.queueServiceViewModel + } + + @Test + fun verifyQueueViewModelCanBeCreated() { + ServiceLocator.viewModelFactory(MockSavedStateRegistryOwner(), mock()).create(QueueViewModel::class.java) + } + + @Test + fun verifyMainViewModelCanBeCreated() { + ServiceLocator.viewModelFactory(MockSavedStateRegistryOwner(), mock()).create(MainViewModel::class.java) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/helper/AccessResourceFileHelper.kt b/app/src/test/java/org/fnives/tiktokdownloader/helper/AccessResourceFileHelper.kt new file mode 100644 index 0000000..290e111 --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/helper/AccessResourceFileHelper.kt @@ -0,0 +1,9 @@ +package org.fnives.tiktokdownloader.helper + +import java.io.File + +fun Any.getResourceFile(path: String): File = + File(this.javaClass.classLoader!!.getResource(path).file) + +fun Any.readResourceFile(path: String): String = + File(this.javaClass.classLoader!!.getResource(path).file).readText() \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/helper/junit/rule/InstantExecutorExtension.kt b/app/src/test/java/org/fnives/tiktokdownloader/helper/junit/rule/InstantExecutorExtension.kt new file mode 100644 index 0000000..6c39c74 --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/helper/junit/rule/InstantExecutorExtension.kt @@ -0,0 +1,26 @@ +package org.fnives.tiktokdownloader.helper.junit.rule + +import androidx.arch.core.executor.ArchTaskExecutor +import androidx.arch.core.executor.TaskExecutor +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance() + .setDelegate(object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + + override fun postToMainThread(runnable: Runnable) = runnable.run() + + override fun isMainThread(): Boolean = true + }) + } + + override fun afterEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance().setDelegate(null) + } + +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/helper/junit/rule/MainDispatcherExtension.kt b/app/src/test/java/org/fnives/tiktokdownloader/helper/junit/rule/MainDispatcherExtension.kt new file mode 100644 index 0000000..796d823 --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/helper/junit/rule/MainDispatcherExtension.kt @@ -0,0 +1,19 @@ +package org.fnives.tiktokdownloader.helper.junit.rule + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class MainDispatcherExtension : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext?) { + Dispatchers.setMain(Dispatchers.Unconfined) + } + + override fun afterEach(context: ExtensionContext?) { + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/helper/mock/InMemorySharedPreferencesManager.kt b/app/src/test/java/org/fnives/tiktokdownloader/helper/mock/InMemorySharedPreferencesManager.kt new file mode 100644 index 0000000..da3529b --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/helper/mock/InMemorySharedPreferencesManager.kt @@ -0,0 +1,24 @@ +package org.fnives.tiktokdownloader.helper.mock + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import org.fnives.tiktokdownloader.data.local.persistent.SharedPreferencesManager + +class InMemorySharedPreferencesManager : SharedPreferencesManager { + + private val _pendingVideosFlow = MutableStateFlow>(emptySet()) + override val pendingVideosFlow: Flow> = _pendingVideosFlow + override var captchaTimeoutUntil: Long = 0 + override var pendingVideos: Set + get() = _pendingVideosFlow.value + set(value) { + _pendingVideosFlow.value = value + } + private val _downloadedVideosFlow = MutableStateFlow>(emptySet()) + override val downloadedVideosFlow: Flow> = _downloadedVideosFlow + override var downloadedVideos: Set + get() = _downloadedVideosFlow.value + set(value) { + _downloadedVideosFlow.value = value + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/helper/mock/MockLifecycle.kt b/app/src/test/java/org/fnives/tiktokdownloader/helper/mock/MockLifecycle.kt new file mode 100644 index 0000000..3ca2d23 --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/helper/mock/MockLifecycle.kt @@ -0,0 +1,14 @@ +package org.fnives.tiktokdownloader.helper.mock + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver + +class MockLifecycle : Lifecycle() { + override fun addObserver(observer: LifecycleObserver) { + } + + override fun removeObserver(observer: LifecycleObserver) { + } + + override fun getCurrentState(): State = State.CREATED +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/helper/mock/MockSavedStateRegistryOwner.kt b/app/src/test/java/org/fnives/tiktokdownloader/helper/mock/MockSavedStateRegistryOwner.kt new file mode 100644 index 0000000..d190c46 --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/helper/mock/MockSavedStateRegistryOwner.kt @@ -0,0 +1,16 @@ +package org.fnives.tiktokdownloader.helper.mock + +import androidx.lifecycle.Lifecycle +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryOwner +import com.nhaarman.mockitokotlin2.mock + +class MockSavedStateRegistryOwner( + private val lifecycle: Lifecycle = MockLifecycle(), + private val mockSavedStateRegistry: SavedStateRegistry = mock() +) : SavedStateRegistryOwner { + + override fun getLifecycle(): Lifecycle = lifecycle + + override fun getSavedStateRegistry(): SavedStateRegistry = mockSavedStateRegistry +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/ui/main/MainViewModelTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/ui/main/MainViewModelTest.kt new file mode 100644 index 0000000..0614996 --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/ui/main/MainViewModelTest.kt @@ -0,0 +1,201 @@ +package org.fnives.tiktokdownloader.ui.main + +import androidx.lifecycle.SavedStateHandle +import com.jraska.livedata.test +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.coroutines.flow.asFlow +import org.fnives.tiktokdownloader.data.model.ProcessState +import org.fnives.tiktokdownloader.data.model.VideoDownloaded +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.fnives.tiktokdownloader.data.usecase.AddVideoToQueueUseCase +import org.fnives.tiktokdownloader.data.usecase.VideoDownloadingProcessorUseCase +import org.fnives.tiktokdownloader.helper.junit.rule.InstantExecutorExtension +import org.fnives.tiktokdownloader.helper.junit.rule.MainDispatcherExtension +import org.fnives.tiktokdownloader.ui.shared.Event +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +@Suppress("TestFunctionName") +@ExtendWith(InstantExecutorExtension::class, MainDispatcherExtension::class) +class MainViewModelTest { + + private lateinit var conflatedBroadcastChannel: ConflatedBroadcastChannel + private lateinit var mockVideoDownloadingProcessorUseCase: VideoDownloadingProcessorUseCase + private lateinit var mockAddVideoToQueueUseCase: AddVideoToQueueUseCase + private lateinit var sut: MainViewModel + + @BeforeEach + fun setup() { + conflatedBroadcastChannel = ConflatedBroadcastChannel() + mockVideoDownloadingProcessorUseCase = mock() + mockAddVideoToQueueUseCase = mock() + whenever(mockVideoDownloadingProcessorUseCase.processState).doReturn(conflatedBroadcastChannel.asFlow()) + sut = MainViewModel(mockVideoDownloadingProcessorUseCase, mockAddVideoToQueueUseCase, SavedStateHandle()) + } + + @Test + fun GIVEN_url_in_savedStateHandle_THEN_its_saved_into_addVideoToQueue() { + val savedStateHandle = mock() + whenever(savedStateHandle.get(MainActivity.INTENT_EXTRA_URL)).doReturn("alma.c") + + MainViewModel(mockVideoDownloadingProcessorUseCase, mockAddVideoToQueueUseCase, savedStateHandle) + + verify(mockAddVideoToQueueUseCase, times(1)).invoke("alma.c") + verifyNoMoreInteractions(mockAddVideoToQueueUseCase) + verify(savedStateHandle, times(1)).get(MainActivity.INTENT_EXTRA_URL) + verify(savedStateHandle, times(1)).set(MainActivity.INTENT_EXTRA_URL, null) + verifyNoMoreInteractions(mockAddVideoToQueueUseCase) + } + + @Test + fun GIVEN_onScreenSet_Queue_WHEN_observing_refresh_action_THEN_it_is_false() { + sut.onScreenSelected(MainViewModel.Screen.QUEUE) + + sut.refreshActionVisibility.test() + .assertHasValue() + .assertValue(false) + + verify(mockVideoDownloadingProcessorUseCase, times(1)).processState + verify(mockVideoDownloadingProcessorUseCase, times(1)).fetchVideoInState() + verifyNoMoreInteractions(mockVideoDownloadingProcessorUseCase) + } + + @Test + fun GIVEN_onScreenSet_Help_WHEN_observing_refreshAction_THEN_it_is_false() { + sut.onScreenSelected(MainViewModel.Screen.HELP) + + sut.refreshActionVisibility.test() + .assertHasValue() + .assertValue(false) + + verify(mockVideoDownloadingProcessorUseCase, times(1)).processState + verify(mockVideoDownloadingProcessorUseCase, times(1)).fetchVideoInState() + verifyNoMoreInteractions(mockVideoDownloadingProcessorUseCase) + } + + @ParameterizedTest(name = "GIVEN_{0}_WHEN_observing_refreshAction_AND_{1}_THEN_its_{2}") + @MethodSource("refreshActionParameters") + fun GIVEN_SpecificScreen_WHEN_observing_refreshAction_AND_SpecificProcessState_THEN_its_correct( + screen: MainViewModel.Screen, + processState: ProcessState, + expected: Boolean + ) { + sut.onScreenSelected(screen) + + val testObserver = sut.refreshActionVisibility.test() + + conflatedBroadcastChannel.offer(processState) + + testObserver.assertHistorySize(2).assertValueHistory(false, expected) + verify(mockVideoDownloadingProcessorUseCase, times(1)).processState + verify(mockVideoDownloadingProcessorUseCase, times(1)).fetchVideoInState() + verifyNoMoreInteractions(mockVideoDownloadingProcessorUseCase) + } + + @ParameterizedTest(name = "GIVEN_{0}_WHEN_observing_refreshAction_AND_{1}_THEN_its_{2}") + @MethodSource("errorMEssageParameters") + fun GIVEN_SpecificScreen_WHEN_observing_errorMessage_AND_SpecificProcessState_THEN_its_correct( + screen: MainViewModel.Screen, + processState: ProcessState, + expected: MainViewModel.ErrorMessage? + ) { + sut.onScreenSelected(screen) + + val testObserver = sut.errorMessage.test() + + conflatedBroadcastChannel.offer(processState) + + testObserver.assertHistorySize(2).assertValueHistory(null, expected?.let(::Event)) + verify(mockVideoDownloadingProcessorUseCase, times(1)).processState + verify(mockVideoDownloadingProcessorUseCase, times(1)).fetchVideoInState() + verifyNoMoreInteractions(mockVideoDownloadingProcessorUseCase) + } + + @Test + fun GIVEN_onScreenSet_Queue_WHEN_observing_errorMessage_THEN_its_null() { + sut.onScreenSelected(MainViewModel.Screen.QUEUE) + + sut.errorMessage.test() + .assertHasValue() + .assertValue(null) + } + + @Test + fun GIVEN_onScreenSet_Help_WHEN_observing_errorMessage_THEN_its_null() { + sut.onScreenSelected(MainViewModel.Screen.HELP) + + sut.errorMessage.test() + .assertHasValue() + .assertValue(null) + } + + @Test + fun GIVEN_initialized_WHEN_onFetchDownloadClicked_is_called_THEN_processor_is_notified() { + sut.onFetchDownloadClicked() + + verify(mockVideoDownloadingProcessorUseCase, times(2)).fetchVideoInState() + verify(mockVideoDownloadingProcessorUseCase, times(1)).processState + verifyNoMoreInteractions(mockVideoDownloadingProcessorUseCase) + } + + @Test + fun GIVEN_initialized_WHEN_onFetchDownloadClicked_is_called_multiple_times_THEN_processor_is_notified() { + sut.onFetchDownloadClicked() + sut.onFetchDownloadClicked() + sut.onFetchDownloadClicked() + + verify(mockVideoDownloadingProcessorUseCase, times(4)).fetchVideoInState() + verify(mockVideoDownloadingProcessorUseCase, times(1)).processState + verifyNoMoreInteractions(mockVideoDownloadingProcessorUseCase) + } + + companion object { + + @JvmStatic + private fun refreshActionParameters() = Stream.of( + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.Processing(VideoInPending("", "")), false), + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.Processed(VideoDownloaded("", "", "")), false), + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.Finished, false), + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.NetworkError, true), + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.ParsingError, true), + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.StorageError, true), + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.UnknownError, true), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.Processing(VideoInPending("", "")), false), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.Processed(VideoDownloaded("", "", "")), false), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.Finished, false), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.NetworkError, false), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.ParsingError, false), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.StorageError, false), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.UnknownError, false) + ) + + @JvmStatic + private fun errorMEssageParameters() = Stream.of( + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.Processing(VideoInPending("", "")), null), + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.Processed(VideoDownloaded("", "", "")), null), + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.Finished, null), + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.NetworkError, MainViewModel.ErrorMessage.NETWORK), + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.ParsingError, MainViewModel.ErrorMessage.PARSING), + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.StorageError, MainViewModel.ErrorMessage.STORAGE), + Arguments.of(MainViewModel.Screen.QUEUE, ProcessState.UnknownError, MainViewModel.ErrorMessage.UNKNOWN), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.Processing(VideoInPending("", "")), null), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.Processed(VideoDownloaded("", "", "")), null), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.Finished, null), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.NetworkError, null), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.ParsingError, null), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.StorageError, null), + Arguments.of(MainViewModel.Screen.HELP, ProcessState.UnknownError, null) + ) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/ui/main/queue/QueueViewModelTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/ui/main/queue/QueueViewModelTest.kt new file mode 100644 index 0000000..04a3268 --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/ui/main/queue/QueueViewModelTest.kt @@ -0,0 +1,130 @@ +package org.fnives.tiktokdownloader.ui.main.queue + +import com.jraska.livedata.test +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.coroutines.flow.asFlow +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.fnives.tiktokdownloader.data.model.VideoState +import org.fnives.tiktokdownloader.data.usecase.AddVideoToQueueUseCase +import org.fnives.tiktokdownloader.data.usecase.StateOfVideosObservableUseCase +import org.fnives.tiktokdownloader.data.usecase.VideoDownloadingProcessorUseCase +import org.fnives.tiktokdownloader.helper.junit.rule.InstantExecutorExtension +import org.fnives.tiktokdownloader.helper.junit.rule.MainDispatcherExtension +import org.fnives.tiktokdownloader.ui.shared.Event +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.math.exp + +@Suppress("TestFunctionName") +@ExtendWith(InstantExecutorExtension::class, MainDispatcherExtension::class) +class QueueViewModelTest { + + private lateinit var stateOfVideosConflatedBroadcastChannel: ConflatedBroadcastChannel> + private lateinit var mockStateOfVideosObservableUseCase: StateOfVideosObservableUseCase + private lateinit var mockAddVideoToQueueUseCase: AddVideoToQueueUseCase + private lateinit var mockVideoDownloadingProcessorUseCase: VideoDownloadingProcessorUseCase + private lateinit var sut: QueueViewModel + + @BeforeEach + fun setup() { + stateOfVideosConflatedBroadcastChannel = ConflatedBroadcastChannel() + mockStateOfVideosObservableUseCase = mock() + whenever(mockStateOfVideosObservableUseCase.invoke()).doReturn(stateOfVideosConflatedBroadcastChannel.asFlow()) + mockAddVideoToQueueUseCase = mock() + mockVideoDownloadingProcessorUseCase = mock() + sut = QueueViewModel(mockStateOfVideosObservableUseCase, mockAddVideoToQueueUseCase, mockVideoDownloadingProcessorUseCase) + } + + @Test + fun GIVEN_initialized_WHEN_observing_THEN_no_downloads_item_is_emitted() { + sut.downloads.test().assertNoValue() + verify(mockStateOfVideosObservableUseCase, times(1)).invoke() + verifyNoMoreInteractions(mockStateOfVideosObservableUseCase) + verifyZeroInteractions(mockAddVideoToQueueUseCase) + verifyZeroInteractions(mockVideoDownloadingProcessorUseCase) + } + + @Test + fun GIVEN_initialized_AND_observing_WHEN_emitting_a_emptyList_THEN_it_is_sent_out() { + val expected = listOf() + stateOfVideosConflatedBroadcastChannel.offer(expected) + + sut.downloads.test().assertValue(expected) + verify(mockStateOfVideosObservableUseCase, times(1)).invoke() + verifyNoMoreInteractions(mockStateOfVideosObservableUseCase) + verifyZeroInteractions(mockAddVideoToQueueUseCase) + verifyZeroInteractions(mockVideoDownloadingProcessorUseCase) + } + + @Test + fun GIVEN_initialized_AND_observing_WHEN_emitting_two_list_THEN_both_are_sent_out_in_order() { + val expected1 = listOf(VideoState.InPending(VideoInPending("a1","b1"))) + val expected2 = listOf(VideoState.InPending(VideoInPending("a2","b2"))) + val testObserver = sut.downloads.test() + + stateOfVideosConflatedBroadcastChannel.offer(expected1) + stateOfVideosConflatedBroadcastChannel.offer(expected2) + + testObserver.assertHistorySize(2).assertHasValue() + .assertValueHistory(expected1, expected2) + verify(mockStateOfVideosObservableUseCase, times(1)).invoke() + verifyNoMoreInteractions(mockStateOfVideosObservableUseCase) + verifyZeroInteractions(mockAddVideoToQueueUseCase) + verifyZeroInteractions(mockVideoDownloadingProcessorUseCase) + } + + @Test + fun GIVEN_initialized_WHEN_onSaveClicked_THEN_addVideoToQueueUseCase_is_called() { + sut.onSaveClicked("alma.com") + + verify(mockStateOfVideosObservableUseCase, times(1)).invoke() + verifyNoMoreInteractions(mockStateOfVideosObservableUseCase) + verify(mockAddVideoToQueueUseCase, times(1)).invoke("alma.com") + verifyNoMoreInteractions(mockAddVideoToQueueUseCase) + verify(mockVideoDownloadingProcessorUseCase, times(1)).fetchVideoInState() + verifyNoMoreInteractions(mockVideoDownloadingProcessorUseCase) + } + + @Test + fun GIVEN_initialized_WHEN_onSaveClicked_twice_THEN_addVideoToQueueUseCase_is_called_twice() { + sut.onSaveClicked("alma.com") + sut.onSaveClicked("banan.org") + + verify(mockStateOfVideosObservableUseCase, times(1)).invoke() + verifyNoMoreInteractions(mockStateOfVideosObservableUseCase) + verify(mockAddVideoToQueueUseCase, times(1)).invoke("alma.com") + verify(mockAddVideoToQueueUseCase, times(1)).invoke("banan.org") + verifyNoMoreInteractions(mockAddVideoToQueueUseCase) + verify(mockVideoDownloadingProcessorUseCase, times(2)).fetchVideoInState() + verifyNoMoreInteractions(mockVideoDownloadingProcessorUseCase) + } + + @Test + fun GIVEN_initialized_THEN_no_navigation_event_is_sent_out() { + sut.navigationEvent.test().assertNoValue() + } + + @Test + fun GIVEN_initialized_WHEN_url_clicked_THEN_navigation_event_is_sent_out() { + sut.onUrlClicked("alma.com") + sut.navigationEvent.test() + .assertHistorySize(1) + .assertValue(Event(QueueViewModel.NavigationEvent.OpenBrowser("alma.com"))) + } + + @Test + fun GIVEN_initialized_WHEN_item_clicked_THEN_navigation_event_is_sent_out() { + sut.onItemClicked("alma.com") + sut.navigationEvent.test() + .assertHistorySize(1) + .assertValue(Event(QueueViewModel.NavigationEvent.OpenGallery("alma.com"))) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/tiktokdownloader/ui/service/QueueServiceViewModelTest.kt b/app/src/test/java/org/fnives/tiktokdownloader/ui/service/QueueServiceViewModelTest.kt new file mode 100644 index 0000000..08c080b --- /dev/null +++ b/app/src/test/java/org/fnives/tiktokdownloader/ui/service/QueueServiceViewModelTest.kt @@ -0,0 +1,127 @@ +package org.fnives.tiktokdownloader.ui.service + +import com.jraska.livedata.test +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.coroutines.flow.asFlow +import org.fnives.tiktokdownloader.R +import org.fnives.tiktokdownloader.data.model.ProcessState +import org.fnives.tiktokdownloader.data.model.VideoDownloaded +import org.fnives.tiktokdownloader.data.model.VideoInPending +import org.fnives.tiktokdownloader.data.usecase.AddVideoToQueueUseCase +import org.fnives.tiktokdownloader.data.usecase.VideoDownloadingProcessorUseCase +import org.fnives.tiktokdownloader.helper.junit.rule.InstantExecutorExtension +import org.fnives.tiktokdownloader.helper.junit.rule.MainDispatcherExtension +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +@Suppress("TestFunctionName") +@ExtendWith(InstantExecutorExtension::class, MainDispatcherExtension::class) +class QueueServiceViewModelTest { + + private lateinit var videoDownloadingProcessorChannel: ConflatedBroadcastChannel + private lateinit var mockVideoDownloadingProcessorUseCase: VideoDownloadingProcessorUseCase + private lateinit var mockAddVideoToQueueUseCase: AddVideoToQueueUseCase + private lateinit var sut: QueueServiceViewModel + + @BeforeEach + fun setup() { + videoDownloadingProcessorChannel = ConflatedBroadcastChannel() + mockVideoDownloadingProcessorUseCase = mock() + whenever(mockVideoDownloadingProcessorUseCase.processState).doReturn(videoDownloadingProcessorChannel.asFlow()) + mockAddVideoToQueueUseCase = mock() + sut = QueueServiceViewModel(mockAddVideoToQueueUseCase, mockVideoDownloadingProcessorUseCase) + } + + @Test + fun GIVEN_initialized_WHEN_valid_url_received_THEN_addVideoToQueue_is_called() { + whenever(mockAddVideoToQueueUseCase.invoke(anyOrNull())).doReturn(true) + sut.onUrlReceived("url.com") + + verify(mockAddVideoToQueueUseCase, times(1)).invoke("url.com") + verifyNoMoreInteractions(mockAddVideoToQueueUseCase) + verify(mockVideoDownloadingProcessorUseCase, times(1)).processState + verify(mockVideoDownloadingProcessorUseCase, times(1)).fetchVideoInState() + verifyNoMoreInteractions(mockVideoDownloadingProcessorUseCase) + } + + @Test + fun GIVEN_initialized_WHEN_invalid_url_received_THEN_addVideoToQueue_is_called_and_finished_emited() { + whenever(mockAddVideoToQueueUseCase.invoke(anyOrNull())).doReturn(false) + sut.onUrlReceived("url.com") + + verify(mockAddVideoToQueueUseCase, times(1)).invoke("url.com") + verifyNoMoreInteractions(mockAddVideoToQueueUseCase) + verifyZeroInteractions(mockVideoDownloadingProcessorUseCase) + } + + @Test + fun GIVEN_initialized_WHEN_more_url_received_THEN_addVideoToQueue_is_called_with_all() { + whenever(mockAddVideoToQueueUseCase.invoke(anyOrNull())).doReturn(true) + sut.onUrlReceived("url.com") + sut.onUrlReceived("other.org") + + verify(mockAddVideoToQueueUseCase, times(1)).invoke("url.com") + verify(mockAddVideoToQueueUseCase, times(1)).invoke("other.org") + verifyNoMoreInteractions(mockAddVideoToQueueUseCase) + verify(mockVideoDownloadingProcessorUseCase, times(1)).processState + verify(mockVideoDownloadingProcessorUseCase, times(2)).fetchVideoInState() + verifyNoMoreInteractions(mockVideoDownloadingProcessorUseCase) + } + + @ParameterizedTest(name = "GIVEN_initialized_URL_added_WHEN_{0}_sent_THEN_notificationState_is_{1}") + @MethodSource("processStateObserveState") + fun GIVEN_initialized_URL_added_WHEN_SpecificProcessState_sent_THEN_notificationState_is_correct( + processState: ProcessState, + expected: NotificationState + ) { + whenever(mockAddVideoToQueueUseCase.invoke(anyOrNull())).doReturn(true) + sut.onUrlReceived("") + videoDownloadingProcessorChannel.offer(processState) + + sut.notificationState.test() + .assertHistorySize(1) + .assertValue(expected) + } + + @Test + fun GIVEN_onCleared_WHEN_item_is_emited_THEN_it_is_no_longer_observed_and_cannot_be() { + sut.onClear() + + sut.onUrlReceived("alma") + videoDownloadingProcessorChannel.offer(ProcessState.UnknownError) + + sut.notificationState.test().assertNoValue() + } + + companion object { + @JvmStatic + private fun processStateObserveState() = Stream.of( + Arguments.of( + ProcessState.Processing(VideoInPending("", "this-is-url")), + NotificationState.Processing("this-is-url") + ), + Arguments.of( + ProcessState.Processed(VideoDownloaded("", "this-is-url", "")), + NotificationState.Processing("this-is-url") + ), + Arguments.of(ProcessState.NetworkError, NotificationState.Error(R.string.network_error)), + Arguments.of(ProcessState.UnknownError, NotificationState.Error(R.string.unexpected_error)), + Arguments.of(ProcessState.StorageError, NotificationState.Error(R.string.storage_error)), + Arguments.of(ProcessState.ParsingError, NotificationState.Error(R.string.parsing_error)), + Arguments.of(ProcessState.Finished, NotificationState.Finish), + ) + } +} \ No newline at end of file diff --git a/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/app/src/test/resources/response/captcha_required_one.html b/app/src/test/resources/response/captcha_required_one.html new file mode 100644 index 0000000..e69de29 diff --git a/app/src/test/resources/response/captcha_required_two.html b/app/src/test/resources/response/captcha_required_two.html new file mode 100644 index 0000000..c06cf21 --- /dev/null +++ b/app/src/test/resources/response/captcha_required_two.html @@ -0,0 +1,2110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #cat #catsoftiktok #fyp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TikTok + + + + + + + + + + Upload video + + + Login + + + + + + + + + + + + + + + For You + + + + + + Following + Log in to follow creators, like videos, and view comments. + Login + + + + + HomeAboutNewsroomCareersByteDance + + HelpSafetyCommunity GuidelinesTermsPrivacyCopyright + © 2020 TikTok + English + + + + + Bahasa Indonesia + Deutsch + English + Español + Français + Italiano + Polski + Português + Tiếng Việt + Türkçe + Русский + हिन्दी + 한국어 + 日本語 + 繁體中文 + العربية + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/resources/response/main_page_v1.html b/app/src/test/resources/response/main_page_v1.html new file mode 100644 index 0000000..20473c4 --- /dev/null +++ b/app/src/test/resources/response/main_page_v1.html @@ -0,0 +1,2094 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #cat #catsoftiktok #fyp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TikTok + + + + + + + + + + Upload video + + + Login + + + + + + + + + + + + + + + For You + + + + + + Following + Log in to follow creators, like videos, and view comments. + Login + + + + + HomeAboutNewsroomCareersByteDance + + HelpSafetyCommunity GuidelinesTermsPrivacyCopyright + © 2020 TikTok + English + + + + + Bahasa Indonesia + Deutsch + English + Español + Français + Italiano + Polski + Português + Tiếng Việt + Türkçe + Русский + हिन्दी + 한국어 + 日本語 + 繁體中文 + العربية + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/resources/response/main_page_v2.html b/app/src/test/resources/response/main_page_v2.html new file mode 100644 index 0000000..cfa56be --- /dev/null +++ b/app/src/test/resources/response/main_page_v2.html @@ -0,0 +1,2652 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #cat #catsoftiktok #fyp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TikTok + + + + + + + + + + Upload video + + + Login + + + + + + + + + + + + + + + + For You + + + + + + Following + Log in to follow creators, like videos, and view comments. + Login + + + + + HomeAboutNewsroomCareersByteDance + + HelpSafetyCommunity GuidelinesTermsPrivacyCopyright + © 2020 TikTok + English + + + + + Bahasa Indonesia + Deutsch + English + Español + Français + Italiano + Polski + Português + Tiếng Việt + Türkçe + Русский + हिन्दी + 한국어 + 日本語 + 繁體中文 + العربية + + + + + + + + + + + + + + + ieclauuuBoxyyy · 3h ago #cat #catsoftiktok #fyp original sound - abby leighReport637252324Browse more For You videos1989alexandraAlexandra Bodi · 1d ago sunet original - Alexandra BodiReport11.1K41068gabrielken1𝓖𝓪𝓫𝓻𝓲𝓮𝓵𝓚𝓮𝓷🤴 · 1d ago Celelalt este in curs de verificare 😂🤦🏻♂️original sound - Florin ChintaReport57.5K253114 + + + + + + + + + + + + + + + + + + + We and our partners use cookies and other + technologies to analyze traffic and optimize your experience. View more info and control your cookies settings at + any time in our Cookies Policy. + Cookies + Policy + Accept + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/resources/response/shortened_url_response.html b/app/src/test/resources/response/shortened_url_response.html new file mode 100644 index 0000000..7203e7f --- /dev/null +++ b/app/src/test/resources/response/shortened_url_response.html @@ -0,0 +1,2109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #cat #catsoftiktok #fyp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TikTok + + + + + + + + + + Upload video + + + Login + + + + + + + + + + + + + + + For You + + + + + + Following + Log in to follow creators, like videos, and view comments. + Login + + + + + HomeAboutNewsroomCareersByteDance + + HelpSafetyCommunity GuidelinesTermsPrivacyCopyright + © 2020 TikTok + English + + + + + Bahasa Indonesia + Deutsch + English + Español + Français + Italiano + Polski + Português + Tiếng Việt + Türkçe + Русский + हिन्दी + 한국어 + 日本語 + 繁體中文 + العربية + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/resources/video/actual.mp4 b/app/src/test/resources/video/actual.mp4 new file mode 100644 index 0000000..e69de29 diff --git a/app/src/test/resources/video/expected.mp4 b/app/src/test/resources/video/expected.mp4 new file mode 100644 index 0000000..37c7fc5 Binary files /dev/null and b/app/src/test/resources/video/expected.mp4 differ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..24d1382 --- /dev/null +++ b/build.gradle @@ -0,0 +1,26 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + ext.kotlin_version = "1.4.10" + repositories { + google() + jcenter() + } + dependencies { + classpath "com.android.tools.build:gradle:4.1.0" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..98bed16 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4c9104 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sat Oct 31 22:42:23 EET 2020 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/secret_video_qr_code.png b/secret_video_qr_code.png new file mode 100644 index 0000000..fb397ad Binary files /dev/null and b/secret_video_qr_code.png differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..7835e48 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +include ':app' +rootProject.name = "TikTokDownloader" \ No newline at end of file diff --git a/tiktok_downloader_apk_qr_code.png b/tiktok_downloader_apk_qr_code.png new file mode 100644 index 0000000..7112e45 Binary files /dev/null and b/tiktok_downloader_apk_qr_code.png differ
English
We and our partners use cookies and other + technologies to analyze traffic and optimize your experience. View more info and control your cookies settings at + any time in our Cookies Policy.