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. + +![QR Code for APK](tiktok_downloader_apk_qr_code.png) + +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 + +![QR Code for Secret](secret_video_qr_code.png) 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 + + + +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + +
+
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + +
+
+
+ +
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + +
+
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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