git initial commit with version v1.0.0
This commit is contained in:
parent
dcc9b4d8f7
commit
cfc732712b
138 changed files with 14786 additions and 84 deletions
78
app/.gitignore
vendored
Normal file
78
app/.gitignore
vendored
Normal file
|
|
@ -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
|
||||
|
||||
91
app/build.gradle
Normal file
91
app/build.gradle
Normal file
|
|
@ -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"
|
||||
}
|
||||
21
app/proguard-rules.pro
vendored
Normal file
21
app/proguard-rules.pro
vendored
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
41
app/src/main/AndroidManifest.xml
Normal file
41
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="org.fnives.tiktokdownloader">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:fullBackupContent="false"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.TikTokDownloader">
|
||||
<activity android:name=".ui.main.MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name=".ui.service.DownloadIntentReceiverActivity"
|
||||
android:theme="@style/NoDisplayTheme">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="message/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".ui.service.QueueService" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
12
app/src/main/java/org/fnives/tiktokdownloader/App.kt
Normal file
12
app/src/main/java/org/fnives/tiktokdownloader/App.kt
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<VideoDownloaded>>
|
||||
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"}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<VideoInPending>>
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VideoInProgress?>(null)
|
||||
val videoInProcessFlow: Flow<VideoInProgress?> = _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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.fnives.tiktokdownloader.data.local.exceptions
|
||||
|
||||
class StorageException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause)
|
||||
|
|
@ -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<String>.joinNormalized() : String =
|
||||
map { it.normalize() }.joinToString("$SEPARATOR$SEPARATOR")
|
||||
|
||||
fun String.separateIntoDenormalized() : List<String> =
|
||||
split("$SEPARATOR$SEPARATOR").map { it.denormalize() }
|
||||
|
||||
fun String.addTimeAtStart(index: Int = 0) =
|
||||
"${System.currentTimeMillis() + index}$TIME_SEPARATOR$this"
|
||||
|
||||
fun String.getTimeAndOriginal(): Pair<Long, String> {
|
||||
val time = takeWhile { it != TIME_SEPARATOR }.toLong()
|
||||
val original = dropWhile { it != TIME_SEPARATOR }.drop(1)
|
||||
return time to original
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.fnives.tiktokdownloader.data.local.persistent
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface SharedPreferencesManager {
|
||||
var captchaTimeoutUntil: Long
|
||||
var pendingVideos: Set<String>
|
||||
val pendingVideosFlow: Flow<Set<String>>
|
||||
var downloadedVideos: Set<String>
|
||||
val downloadedVideosFlow: Flow<Set<String>>
|
||||
}
|
||||
|
|
@ -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<String> by StringSetDelegate(PENDING_VIDEO_KEY)
|
||||
override val pendingVideosFlow by StringSetFlowDelegate(PENDING_VIDEO_KEY)
|
||||
override var downloadedVideos: Set<String> by StringSetDelegate(DOWNLOADED_VIDEO_KEY)
|
||||
override val downloadedVideosFlow by StringSetFlowDelegate(DOWNLOADED_VIDEO_KEY)
|
||||
|
||||
class LongDelegate(private val key: String) : ReadWriteProperty<SharedPreferencesManagerImpl, Long> {
|
||||
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<SharedPreferencesManagerImpl, Set<String>> {
|
||||
override fun setValue(thisRef: SharedPreferencesManagerImpl, property: KProperty<*>, value: Set<String>) {
|
||||
thisRef.sharedPreferences.edit().putStringSet(key, value).apply()
|
||||
}
|
||||
|
||||
override fun getValue(thisRef: SharedPreferencesManagerImpl, property: KProperty<*>): Set<String> =
|
||||
thisRef.sharedPreferences.getStringSet(key, emptySet()).orEmpty()
|
||||
}
|
||||
|
||||
class StringSetFlowDelegate(private val key: String) : ReadOnlyProperty<SharedPreferencesManagerImpl, Flow<Set<String>>> {
|
||||
override fun getValue(thisRef: SharedPreferencesManagerImpl, property: KProperty<*>): Flow<Set<String>> =
|
||||
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<String> =
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.fnives.tiktokdownloader.data.local.verify.exists
|
||||
|
||||
interface VerifyFileForUriExists {
|
||||
|
||||
suspend operator fun invoke(uri: String): Boolean
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.fnives.tiktokdownloader.data.model
|
||||
|
||||
data class VideoDownloaded(val id: String, val url: String, val uri: String)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.fnives.tiktokdownloader.data.model
|
||||
|
||||
data class VideoInPending(val id: String, val url: String)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.fnives.tiktokdownloader.data.model
|
||||
|
||||
data class VideoInProgress(val id: String, val url: String)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <T> wrapIntoProperException(request: suspend () -> T): T =
|
||||
try {
|
||||
request()
|
||||
} catch (parsingException: ParsingException) {
|
||||
throw parsingException
|
||||
} catch (captchaRequiredException: CaptchaRequiredException) {
|
||||
throw captchaRequiredException
|
||||
} catch (throwable: Throwable) {
|
||||
throw NetworkException(cause = throwable)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.fnives.tiktokdownloader.data.network.exceptions
|
||||
|
||||
class CaptchaRequiredException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.fnives.tiktokdownloader.data.network.exceptions
|
||||
|
||||
class NetworkException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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<Annotation>,
|
||||
retrofit: Retrofit
|
||||
): Converter<ResponseBody, *>? =
|
||||
when (type) {
|
||||
ActualVideoPageUrl::class.java -> ActualVideoPageUrlConverter(throwIfIsCaptchaResponse)
|
||||
VideoFileUrl::class.java -> VideoFileUrlConverter(throwIfIsCaptchaResponse)
|
||||
VideoResponse::class.java -> VideoResponseConverter()
|
||||
else -> super.responseBodyConverter(type, annotations, retrofit)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ActualVideoPageUrl>() {
|
||||
|
||||
@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)
|
||||
}
|
||||
|
|
@ -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<T> : Converter<ResponseBody, T> {
|
||||
|
||||
@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?
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VideoFileUrl>() {
|
||||
|
||||
@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("<video")) {
|
||||
html.split("<video")[1]
|
||||
.split("</video>")[0]
|
||||
.split("src")[1]
|
||||
.dropWhile { it != '=' }
|
||||
.dropWhile { it != '\"' }.drop(1)
|
||||
.takeWhile { it != '\"' }
|
||||
.replace("\\u0026", "&")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VideoResponse>() {
|
||||
|
||||
override fun convertSafely(value: ResponseBody): VideoResponse? =
|
||||
VideoResponse(value.contentType(), value.byteStream())
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.fnives.tiktokdownloader.data.network.parsing.response
|
||||
|
||||
class ActualVideoPageUrl(val url: String)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.fnives.tiktokdownloader.data.network.parsing.response
|
||||
|
||||
class VideoFileUrl(val videoFileUrl: String)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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("; ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package org.fnives.tiktokdownloader.data.network.session
|
||||
|
||||
interface CookieStore {
|
||||
|
||||
var cookie: String?
|
||||
|
||||
fun clear()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<VideoState>> = videoStateFlow
|
||||
|
||||
private suspend fun combineTogether(
|
||||
videoInProgress: VideoInProgress?,
|
||||
pendingVideos: List<VideoInPending>,
|
||||
downloaded: List<VideoDownloaded>
|
||||
): List<VideoState> = withContext(dispatcher) {
|
||||
val result = mutableListOf<VideoState>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.fnives.tiktokdownloader.data.usecase
|
||||
|
||||
class UrlVerificationUseCase {
|
||||
|
||||
operator fun invoke(url: String) = url.contains("tiktok")
|
||||
}
|
||||
|
|
@ -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<ProcessState> get() = _processState
|
||||
|
||||
fun fetchVideoInState() {
|
||||
fetch.value = ProcessingState.RUNNING
|
||||
}
|
||||
|
||||
private fun finishedOrProcessItem(videoInPending: VideoInPending?): Flow<ProcessState> =
|
||||
if (videoInPending == null) {
|
||||
flowOf(ProcessState.Finished)
|
||||
} else {
|
||||
processItemFlow(videoInPending)
|
||||
}
|
||||
|
||||
private fun processItemFlow(videoInPending: VideoInPending): Flow<ProcessState> =
|
||||
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 <T, R> combineIntoPair(flow1: Flow<T>, flow2: Flow<R>): Flow<Pair<T, R>> =
|
||||
combine(flow1, flow2) { item1, item2 -> item1 to item2 }
|
||||
|
||||
private fun VideoInPendingLocalSource.observeLastPendingVideo(): Flow<VideoInPending?> =
|
||||
pendingVideos.map { it.lastOrNull() }.distinctUntilChanged()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <reified VM : ViewModel> ComponentActivity.provideViewModels() =
|
||||
ViewModelLazy(
|
||||
viewModelClass = VM::class,
|
||||
storeProducer = { viewModelStore },
|
||||
factoryProducer = { createViewModelFactory() }
|
||||
)
|
||||
|
||||
inline fun <reified VM : ViewModel> 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)
|
||||
|
|
@ -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 <T : ViewModel?> create(key: String, modelClass: Class<T>, 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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<MainViewModel>()
|
||||
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<BottomNavigationView>(R.id.bottom_navigation)
|
||||
val downloadFab = findViewById<FloatingActionButton>(R.id.download_fab)
|
||||
val snackBarAnchor = findViewById<CoordinatorLayout>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Boolean>()
|
||||
private val currentScreen = MutableLiveData<Screen>()
|
||||
val refreshActionVisibility: LiveData<Boolean?> = combineNullable(_refreshActionVisibility, currentScreen) { refreshVisibility, screen ->
|
||||
refreshVisibility == true && screen == Screen.QUEUE
|
||||
}
|
||||
private val _errorMessage = MutableLiveData<Event<ErrorMessage>>()
|
||||
val errorMessage: LiveData<Event<ErrorMessage>?> = combineNullable(_errorMessage, currentScreen) { event, screen ->
|
||||
event?.takeIf { screen == Screen.QUEUE }
|
||||
}
|
||||
|
||||
init {
|
||||
savedStateHandle.get<String>(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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<YouTubePlayerView>(R.id.in_app_youtube_view)
|
||||
val inTikTokYoutubeView = view.findViewById<YouTubePlayerView>(R.id.in_tiktok_youtube_view)
|
||||
viewLifecycleOwner.lifecycle.addObserver(inAppYoutubeView)
|
||||
viewLifecycleOwner.lifecycle.addObserver(inTikTokYoutubeView)
|
||||
val repositoryLinkView = view.findViewById<TextView>(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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<QueueViewModel>()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val recycler = view.findViewById<RecyclerView>(R.id.recycler)
|
||||
val adapter = QueueItemAdapter(
|
||||
itemClicked = viewModel::onItemClicked,
|
||||
urlClicked = viewModel::onUrlClicked
|
||||
)
|
||||
recycler.adapter = adapter
|
||||
val saveUrlCta = view.findViewById<Button>(R.id.save_cta)
|
||||
val input = view.findViewById<EditText>(R.id.download_url_input)
|
||||
input.doAfterTextChanged {
|
||||
saveUrlCta.isEnabled = it?.isNotBlank() == true
|
||||
}
|
||||
saveUrlCta.setOnClickListener {
|
||||
recycler.smoothScrollToPosition(0)
|
||||
viewModel.onSaveClicked(input.text?.toString().orEmpty())
|
||||
input.setText("")
|
||||
}
|
||||
|
||||
viewModel.navigationEvent.observe(viewLifecycleOwner, Observer {
|
||||
val intent = when (val data = it.item) {
|
||||
is QueueViewModel.NavigationEvent.OpenBrowser -> {
|
||||
createBrowserIntent(data.url)
|
||||
}
|
||||
is QueueViewModel.NavigationEvent.OpenGallery ->
|
||||
createGalleryIntent(data.uri)
|
||||
null -> return@Observer
|
||||
}
|
||||
startActivity(intent)
|
||||
})
|
||||
|
||||
viewModel.downloads.observe(viewLifecycleOwner, { videoStates ->
|
||||
adapter.submitList(videoStates, Runnable {
|
||||
val indexToScrollTo = videoStates.indexOfFirst { it is VideoState.InProcess }
|
||||
.takeIf { it != -1 } ?: return@Runnable
|
||||
recycler.smoothScrollToPosition(indexToScrollTo)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance(): QueueFragment = QueueFragment()
|
||||
|
||||
fun createBrowserIntent(url: String): Intent =
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
|
||||
fun createGalleryIntent(uri: String): Intent =
|
||||
Intent(Intent.ACTION_VIEW, uri.toUri())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package org.fnives.tiktokdownloader.ui.main.queue
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.fnives.tiktokdownloader.R
|
||||
import org.fnives.tiktokdownloader.data.model.VideoState
|
||||
import org.fnives.tiktokdownloader.ui.shared.inflate
|
||||
import org.fnives.tiktokdownloader.ui.shared.loadUri
|
||||
|
||||
class QueueItemAdapter(
|
||||
private val itemClicked: (path: String) -> Unit,
|
||||
private val urlClicked: (url: String) -> Unit
|
||||
) :
|
||||
ListAdapter<VideoState, QueueItemAdapter.DownloadActionsViewHolder>(DiffUtilItemCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadActionsViewHolder =
|
||||
DownloadActionsViewHolder(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: DownloadActionsViewHolder, position: Int) {
|
||||
val item = getItem(position)
|
||||
holder.urlView.text = item.url
|
||||
val statusTextRes = when (item) {
|
||||
is VideoState.InPending -> R.string.status_pending
|
||||
is VideoState.Downloaded -> R.string.status_finished
|
||||
is VideoState.InProcess -> R.string.status_pending
|
||||
}
|
||||
holder.statusView.isInvisible = item is VideoState.InProcess
|
||||
holder.progress.isVisible = item is VideoState.InProcess
|
||||
if (item is VideoState.Downloaded) {
|
||||
holder.itemView.setOnClickListener {
|
||||
itemClicked(item.videoDownloaded.uri)
|
||||
}
|
||||
holder.itemView.isEnabled = true
|
||||
} else {
|
||||
holder.itemView.isEnabled = false
|
||||
}
|
||||
holder.urlView.setOnClickListener {
|
||||
urlClicked(item.url)
|
||||
}
|
||||
holder.statusView.setText(statusTextRes)
|
||||
when (item) {
|
||||
is VideoState.InProcess,
|
||||
is VideoState.InPending -> holder.thumbNailView.setImageResource(R.drawable.ic_twotone_image)
|
||||
is VideoState.Downloaded ->
|
||||
holder.thumbNailView.loadUri(item.videoDownloaded.uri)
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadActionsViewHolder(parent: ViewGroup) :
|
||||
RecyclerView.ViewHolder(parent.inflate(R.layout.item_downloaded)) {
|
||||
val urlView: TextView = itemView.findViewById(R.id.url)
|
||||
val statusView: TextView = itemView.findViewById(R.id.status)
|
||||
val thumbNailView: ImageView = itemView.findViewById(R.id.thumbnail)
|
||||
val progress: ProgressBar = itemView.findViewById(R.id.progress)
|
||||
}
|
||||
|
||||
class DiffUtilItemCallback : DiffUtil.ItemCallback<VideoState>() {
|
||||
override fun areItemsTheSame(oldItem: VideoState, newItem: VideoState): Boolean =
|
||||
oldItem.id == newItem.id
|
||||
|
||||
override fun areContentsTheSame(oldItem: VideoState, newItem: VideoState): Boolean =
|
||||
oldItem == newItem
|
||||
|
||||
override fun getChangePayload(oldItem: VideoState, newItem: VideoState): Any? =
|
||||
this
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package org.fnives.tiktokdownloader.ui.main.queue
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.fnives.tiktokdownloader.data.usecase.AddVideoToQueueUseCase
|
||||
import org.fnives.tiktokdownloader.data.usecase.StateOfVideosObservableUseCase
|
||||
import org.fnives.tiktokdownloader.data.usecase.VideoDownloadingProcessorUseCase
|
||||
import org.fnives.tiktokdownloader.ui.shared.Event
|
||||
import org.fnives.tiktokdownloader.ui.shared.asLiveData
|
||||
|
||||
class QueueViewModel(
|
||||
stateOfVideosObservableUseCase: StateOfVideosObservableUseCase,
|
||||
private val addVideoToQueueUseCase: AddVideoToQueueUseCase,
|
||||
private val videoDownloadingProcessorUseCase: VideoDownloadingProcessorUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
val downloads = asLiveData(stateOfVideosObservableUseCase())
|
||||
private val _navigationEvent = MutableLiveData<Event<NavigationEvent>>()
|
||||
val navigationEvent: LiveData<Event<NavigationEvent>> = _navigationEvent
|
||||
|
||||
fun onSaveClicked(url: String) {
|
||||
addVideoToQueueUseCase(url)
|
||||
videoDownloadingProcessorUseCase.fetchVideoInState()
|
||||
}
|
||||
|
||||
fun onItemClicked(path: String) {
|
||||
_navigationEvent.value = Event(NavigationEvent.OpenGallery(path))
|
||||
}
|
||||
|
||||
fun onUrlClicked(url: String) {
|
||||
_navigationEvent.value = Event(NavigationEvent.OpenBrowser(url))
|
||||
}
|
||||
|
||||
sealed class NavigationEvent {
|
||||
data class OpenBrowser(val url: String) : NavigationEvent()
|
||||
data class OpenGallery(val uri: String) : NavigationEvent()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package org.fnives.tiktokdownloader.ui.permission
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import org.fnives.tiktokdownloader.R
|
||||
|
||||
class PermissionRationaleDialogFactory {
|
||||
|
||||
fun show(context: Context, onOkClicked: () -> Unit, onCanceled: () -> Unit) {
|
||||
var okClicked = false
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle(R.string.permission_request)
|
||||
.setMessage(R.string.permission_rationale)
|
||||
.setPositiveButton(R.string.ok) { dialog, _ ->
|
||||
okClicked = true
|
||||
onOkClicked()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setOnDismissListener {
|
||||
if (!okClicked) {
|
||||
onCanceled()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package org.fnives.tiktokdownloader.ui.permission
|
||||
|
||||
import android.os.Build
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
interface PermissionRequester {
|
||||
|
||||
operator fun invoke(activity: AppCompatActivity)
|
||||
|
||||
fun isGranted(activity: AppCompatActivity): Boolean
|
||||
|
||||
class Factory(private val permissionRationaleDialogFactory: PermissionRationaleDialogFactory) {
|
||||
|
||||
fun invoke(): PermissionRequester =
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
|
||||
PermissionRequesterAbove28()
|
||||
} else {
|
||||
PermissionRequesterBelow28(permissionRationaleDialogFactory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package org.fnives.tiktokdownloader.ui.permission
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
class PermissionRequesterAbove28 : PermissionRequester {
|
||||
|
||||
override fun invoke(activity: AppCompatActivity) {
|
||||
// nothing to do, no permission is required
|
||||
}
|
||||
|
||||
override fun isGranted(activity: AppCompatActivity): Boolean = true
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package org.fnives.tiktokdownloader.ui.permission
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.fnives.tiktokdownloader.R
|
||||
import org.fnives.tiktokdownloader.ui.permission.PermissionRequesterBelow28.Companion.hasPermission
|
||||
|
||||
class PermissionRequesterBelow28(
|
||||
private val permissionRationaleDialogFactory: PermissionRationaleDialogFactory
|
||||
) : PermissionRequester {
|
||||
|
||||
override operator fun invoke(activity: AppCompatActivity) {
|
||||
val interactor = PermissionLauncherInteractor(activity, permissionRationaleDialogFactory)
|
||||
interactor.attach()
|
||||
}
|
||||
|
||||
override fun isGranted(activity: AppCompatActivity): Boolean =
|
||||
activity.hasPermission(STORAGE_PERMISSION)
|
||||
|
||||
|
||||
private class PermissionLauncherInteractor(
|
||||
private val activity: AppCompatActivity,
|
||||
private val permissionRationaleDialogFactory: PermissionRationaleDialogFactory
|
||||
) : LifecycleEventObserver {
|
||||
val requestPermissionLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted: Boolean ->
|
||||
if (!isGranted && activity.shouldShowRequestPermissionRationale(STORAGE_PERMISSION)) {
|
||||
showRationale()
|
||||
} else {
|
||||
onFinalResponse(isGranted)
|
||||
}
|
||||
}
|
||||
|
||||
fun attach() {
|
||||
activity.lifecycle.addObserver(this)
|
||||
}
|
||||
|
||||
private fun checkOrRequestPermission() {
|
||||
when {
|
||||
activity.hasPermission(STORAGE_PERMISSION) -> onFinalResponse(true)
|
||||
activity.shouldShowRequestPermissionRationale(STORAGE_PERMISSION) -> showRationale()
|
||||
else -> requestPermissionLauncher.launch(STORAGE_PERMISSION)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showRationale() {
|
||||
permissionRationaleDialogFactory.show(
|
||||
activity,
|
||||
onOkClicked = {
|
||||
requestPermissionLauncher.launch(STORAGE_PERMISSION)
|
||||
},
|
||||
onCanceled = {
|
||||
onFinalResponse(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun onFinalResponse(isGranted: Boolean) {
|
||||
if (!isGranted) {
|
||||
Toast.makeText(activity, R.string.cant_operate_without_permission, Toast.LENGTH_LONG).show()
|
||||
activity.finishAffinity()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
if (event == Lifecycle.Event.ON_START) {
|
||||
checkOrRequestPermission()
|
||||
} else if (event == Lifecycle.Event.ON_DESTROY) {
|
||||
source.lifecycle.removeObserver(this)
|
||||
requestPermissionLauncher.unregister()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val STORAGE_PERMISSION = Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
|
||||
private fun Context.hasPermission(permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package org.fnives.tiktokdownloader.ui.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.fnives.tiktokdownloader.di.ServiceLocator
|
||||
import org.fnives.tiktokdownloader.ui.main.MainActivity
|
||||
import org.fnives.tiktokdownloader.ui.permission.PermissionRequester
|
||||
|
||||
class DownloadIntentReceiverActivity : AppCompatActivity() {
|
||||
|
||||
private val permissionRequester: PermissionRequester by lazy {
|
||||
ServiceLocator.permissionModule.permissionRequester
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
intent.getStringExtra(Intent.EXTRA_TEXT)?.let { url ->
|
||||
if (permissionRequester.isGranted(this)) {
|
||||
startService(QueueService.buildIntent(this, url))
|
||||
} else {
|
||||
startActivity(MainActivity.buildIntent(this, url))
|
||||
}
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package org.fnives.tiktokdownloader.ui.service
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
sealed class NotificationState {
|
||||
data class Processing(val url: String) : NotificationState()
|
||||
data class Error(@StringRes val errorRes: Int) : NotificationState()
|
||||
object Finish: NotificationState()
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
package org.fnives.tiktokdownloader.ui.service
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import org.fnives.tiktokdownloader.R
|
||||
import org.fnives.tiktokdownloader.di.ServiceLocator
|
||||
import org.fnives.tiktokdownloader.ui.main.MainActivity
|
||||
|
||||
|
||||
class QueueService : Service() {
|
||||
|
||||
private val viewModel: QueueServiceViewModel by lazy { ServiceLocator.queueServiceViewModel }
|
||||
private val serviceLifecycle = ServiceLifecycle()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
serviceLifecycle.lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
viewModel.notificationState.observe(serviceLifecycle) {
|
||||
updateNotification(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
intent?.url?.let(viewModel::onUrlReceived)
|
||||
startForeground()
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
private fun startForeground() {
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.tik_tok_downloader_notification_channel),
|
||||
NotificationManager.IMPORTANCE_MIN
|
||||
)
|
||||
)
|
||||
}
|
||||
startForeground(
|
||||
SERVICE_NOTIFICATION_ID,
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.tik_tok_downloader_started))
|
||||
.setSmallIcon(R.drawable.ic_download)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateNotification(notificationState: NotificationState) {
|
||||
if (notificationState is NotificationState.Finish) {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
val (id, notification) = when (notificationState) {
|
||||
is NotificationState.Processing ->
|
||||
SERVICE_NOTIFICATION_ID to NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.tik_tok_downloader_processing, notificationState.url))
|
||||
.setSmallIcon(R.drawable.ic_download)
|
||||
.setProgress(0, 10, true)
|
||||
.build()
|
||||
is NotificationState.Error ->
|
||||
NOTIFICATION_ID to NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(notificationState.errorRes))
|
||||
.setSmallIcon(R.drawable.ic_download)
|
||||
.setContentIntent(buildMainPendingIntent(this))
|
||||
.setAutoCancel(true)
|
||||
.setNotificationSilent()
|
||||
.build()
|
||||
NotificationState.Finish -> {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
}
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.notify(id, notification)
|
||||
if (id == NOTIFICATION_ID) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
serviceLifecycle.lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
|
||||
viewModel.onClear()
|
||||
}
|
||||
|
||||
private class ServiceLifecycle : LifecycleOwner {
|
||||
val lifecycleRegistry = LifecycleRegistry(this)
|
||||
override fun getLifecycle(): Lifecycle = lifecycleRegistry
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val CHANNEL_ID = "org.fnives.tiktokdownloader.CHANNEL_ID"
|
||||
private const val NOTIFICATION_ID = 420
|
||||
private const val SERVICE_NOTIFICATION_ID = 421
|
||||
private const val NOTIFICATION_PENDING_INTENT_REQUEST_CODE = 422
|
||||
|
||||
private var Intent.url: String
|
||||
get() = getStringExtra("URL").orEmpty()
|
||||
set(value) {
|
||||
putExtra("URL", value)
|
||||
}
|
||||
|
||||
fun buildIntent(context: Context, url: String) =
|
||||
Intent(context, QueueService::class.java).also { it.url = url }
|
||||
|
||||
fun buildIntent(context: Context) =
|
||||
Intent(context, QueueService::class.java)
|
||||
|
||||
private fun buildMainPendingIntent(context: Context): PendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
NOTIFICATION_PENDING_INTENT_REQUEST_CODE,
|
||||
MainActivity.buildIntent(context),
|
||||
FLAG_UPDATE_CURRENT
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package org.fnives.tiktokdownloader.ui.service
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fnives.tiktokdownloader.R
|
||||
import org.fnives.tiktokdownloader.data.usecase.AddVideoToQueueUseCase
|
||||
import org.fnives.tiktokdownloader.data.usecase.VideoDownloadingProcessorUseCase
|
||||
import org.fnives.tiktokdownloader.data.model.ProcessState
|
||||
|
||||
class QueueServiceViewModel(
|
||||
private val addVideoToQueueUseCase: AddVideoToQueueUseCase,
|
||||
private val videoDownloadingProcessorUseCase: VideoDownloadingProcessorUseCase
|
||||
) {
|
||||
|
||||
private val parentJob = Job()
|
||||
private val viewModelScope = CoroutineScope(parentJob + Dispatchers.Main)
|
||||
private var listeningJob: Job? = null
|
||||
|
||||
private val _notificationState = MutableLiveData<NotificationState>()
|
||||
val notificationState: LiveData<NotificationState> = _notificationState
|
||||
|
||||
fun onUrlReceived(url: String) {
|
||||
if (!addVideoToQueueUseCase(url)){
|
||||
return
|
||||
}
|
||||
if (listeningJob == null) {
|
||||
listeningJob = startListening()
|
||||
}
|
||||
videoDownloadingProcessorUseCase.fetchVideoInState()
|
||||
}
|
||||
|
||||
private fun startListening(): Job = viewModelScope.launch {
|
||||
videoDownloadingProcessorUseCase.processState.collect {
|
||||
val value = when (it) {
|
||||
is ProcessState.Processing ->
|
||||
NotificationState.Processing(it.videoInPending.url)
|
||||
is ProcessState.Processed ->
|
||||
NotificationState.Processing(it.videoDownloaded.url)
|
||||
ProcessState.NetworkError ->
|
||||
NotificationState.Error(R.string.network_error)
|
||||
ProcessState.ParsingError ->
|
||||
NotificationState.Error(R.string.parsing_error)
|
||||
ProcessState.StorageError ->
|
||||
NotificationState.Error(R.string.storage_error)
|
||||
ProcessState.UnknownError ->
|
||||
NotificationState.Error(R.string.unexpected_error)
|
||||
ProcessState.Finished -> NotificationState.Finish
|
||||
ProcessState.CaptchaError ->
|
||||
NotificationState.Error(R.string.captcha_error)
|
||||
}
|
||||
_notificationState.postValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
fun onClear() {
|
||||
parentJob.cancel()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.fnives.tiktokdownloader.ui.shared
|
||||
|
||||
data class Event<D>(private val data: D) {
|
||||
|
||||
var consume: Boolean = false
|
||||
private set
|
||||
|
||||
val item: D? get() = data?.takeUnless { consume }.also { consume = true }
|
||||
|
||||
fun peek(): D = data
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package org.fnives.tiktokdownloader.ui.shared
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
|
||||
fun <T1, T2, R> combineNullable(
|
||||
liveData1: LiveData<T1?>,
|
||||
liveData2: LiveData<T2?>,
|
||||
mapper: (T1?, T2?) -> R?
|
||||
): LiveData<R?> {
|
||||
val mediatorLiveData = MediatorLiveData<R?>()
|
||||
val update = { mediatorLiveData.value = mapper(liveData1.value, liveData2.value) }
|
||||
mediatorLiveData.addSource(liveData1) { update() }
|
||||
mediatorLiveData.addSource(liveData2) { update() }
|
||||
return mediatorLiveData
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package org.fnives.tiktokdownloader.ui.shared
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.core.net.toUri
|
||||
import com.bumptech.glide.Glide
|
||||
|
||||
val View.inflater get() = LayoutInflater.from(context)
|
||||
|
||||
fun ViewGroup.inflate(@LayoutRes layoutRes: Int, addToParent: Boolean = false) : View =
|
||||
inflater.inflate(layoutRes, this, addToParent)
|
||||
|
||||
fun ImageView.loadUri(uri: String) {
|
||||
Glide.with(this)
|
||||
.load(uri.toUri())
|
||||
.into(this)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package org.fnives.tiktokdownloader.ui.shared
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
|
||||
fun <T> CoroutineScope.asLiveData(flow: Flow<T>): LiveData<T> {
|
||||
val liveData = MutableLiveData<T>()
|
||||
launch {
|
||||
flow.collect {
|
||||
liveData.postValue(it)
|
||||
}
|
||||
}
|
||||
|
||||
return liveData
|
||||
}
|
||||
|
||||
fun <T> ViewModel.asLiveData(flow: Flow<T>): LiveData<T> = viewModelScope.asLiveData(flow)
|
||||
29
app/src/main/res/animator/card_elevation_animator.xml
Normal file
29
app/src/main/res/animator/card_elevation_animator.xml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2020 Halcyon Mobile
|
||||
~ https://www.halcyonmobile.com
|
||||
~ All rights reserved.
|
||||
-->
|
||||
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:state_enabled="true"
|
||||
android:state_pressed="true">
|
||||
<set>
|
||||
<objectAnimator
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:propertyName="translationZ"
|
||||
android:valueTo="8dp"
|
||||
android:valueType="floatType"/>
|
||||
</set>
|
||||
</item>
|
||||
<item>
|
||||
<set>
|
||||
<objectAnimator
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:propertyName="translationZ"
|
||||
android:valueTo="0"
|
||||
android:valueType="floatType"/>
|
||||
</set>
|
||||
</item>
|
||||
</selector>
|
||||
5
app/src/main/res/color/outline_stroke_color.xml
Normal file
5
app/src/main/res/color/outline_stroke_color.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="?attr/colorPrimary" android:state_enabled="true"/>
|
||||
<item android:color="?attr/colorOnSurface"/>
|
||||
</selector>
|
||||
17
app/src/main/res/drawable-v24/ic_launcher_foreground.xml
Normal file
17
app/src/main/res/drawable-v24/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillAlpha="0.9"
|
||||
android:fillColor="@color/purple_500"
|
||||
android:pathData="M10.3,5 l2,0 l0,8 l-5,0 l4.5,5 l4,-4, l-5,0" />
|
||||
<path
|
||||
android:fillColor="@color/purple_200"
|
||||
android:fillAlpha="0.9"
|
||||
android:pathData="M11.8,5 l2,0 l0,8 l-5,0 l3.5,5 l4.5,-5, l-5,0" />
|
||||
<path
|
||||
android:fillColor="@color/close_to_white"
|
||||
android:pathData="M11,5.3 l2,0 l0,8 l-5,0 l4,4 l4,-4, l-5,0" />
|
||||
</vector>
|
||||
15
app/src/main/res/drawable/ic_download.xml
Normal file
15
app/src/main/res/drawable/ic_download.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,4c-4.41,0 -8,3.59 -8,8s3.59,8 8,8s8,-3.59 8,-8S16.41,4 12,4zM12,16l-4,-4h3l0,-4h2l0,4h3L12,16z"
|
||||
android:strokeAlpha="0.3"
|
||||
android:fillAlpha="0.3"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,4c4.41,0 8,3.59 8,8s-3.59,8 -8,8s-8,-3.59 -8,-8S7.59,4 12,4M12,2C6.48,2 2,6.48 2,12c0,5.52 4.48,10 10,10c5.52,0 10,-4.48 10,-10C22,6.48 17.52,2 12,2L12,2zM13,12l0,-4h-2l0,4H8l4,4l4,-4H13z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="@color/close_to_black"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_round_refresh.xml
Normal file
10
app/src/main/res/drawable/ic_round_refresh.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17.65,6.35c-1.63,-1.63 -3.94,-2.57 -6.48,-2.31 -3.67,0.37 -6.69,3.35 -7.1,7.02C3.52,15.91 7.27,20 12,20c3.19,0 5.93,-1.87 7.21,-4.56 0.32,-0.67 -0.16,-1.44 -0.9,-1.44 -0.37,0 -0.72,0.2 -0.88,0.53 -1.13,2.43 -3.84,3.97 -6.8,3.31 -2.22,-0.49 -4.01,-2.3 -4.48,-4.52C5.31,9.44 8.26,6 12,6c1.66,0 3.14,0.69 4.22,1.78l-1.51,1.51c-0.63,0.63 -0.19,1.71 0.7,1.71H19c0.55,0 1,-0.45 1,-1V6.41c0,-0.89 -1.08,-1.34 -1.71,-0.71l-0.64,0.65z"/>
|
||||
</vector>
|
||||
15
app/src/main/res/drawable/ic_twotone_help.xml
Normal file
15
app/src/main/res/drawable/ic_twotone_help.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,4c-4.41,0 -8,3.59 -8,8s3.59,8 8,8 8,-3.59 8,-8 -3.59,-8 -8,-8zM13,18h-2v-2h2v2zM13,15h-2c0,-3.25 3,-3 3,-5 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,10c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,2.5 -3,2.75 -3,5z"
|
||||
android:strokeAlpha="0.3"
|
||||
android:fillAlpha="0.3"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M11,16h2v2h-2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM12,6c-2.21,0 -4,1.79 -4,4h2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2c0,2 -3,1.75 -3,5h2c0,-2.25 3,-2.5 3,-5 0,-2.21 -1.79,-4 -4,-4z"/>
|
||||
</vector>
|
||||
15
app/src/main/res/drawable/ic_twotone_image.xml
Normal file
15
app/src/main/res/drawable/ic_twotone_image.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorOnSurface">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M5,19h14L19,5L5,5v14zM9,13.14l2.14,2.58 3,-3.87L18,17L6,17l3,-3.86z"
|
||||
android:strokeAlpha="0.3"
|
||||
android:fillAlpha="0.3"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM19,19L5,19L5,5h14v14zM14.14,11.86l-3,3.86L9,13.14 6,17h12z"/>
|
||||
</vector>
|
||||
52
app/src/main/res/layout/activity_main.xml
Normal file
52
app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
tools:context=".ui.main.MainActivity">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/fragment_holder"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottom_navigation"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:id="@+id/snack_bar_anchor"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="@id/download_fab"
|
||||
app:layout_constraintEnd_toStartOf="@id/download_fab"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/download_fab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottom_navigation"
|
||||
android:layout_margin="@dimen/activity_horizontal_margin"
|
||||
android:contentDescription="@string/start_download"
|
||||
android:scaleX="0"
|
||||
android:scaleY="0"
|
||||
app:srcCompat="@drawable/ic_round_refresh"
|
||||
app:tint="?attr/colorOnSecondary" />
|
||||
|
||||
<com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
android:id="@+id/bottom_navigation"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:menu="@menu/main_navigation" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
135
app/src/main/res/layout/fragment_help.xml
Normal file
135
app/src/main/res/layout/fragment_help.xml
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="@dimen/default_padding"
|
||||
android:layout_marginStart="@dimen/activity_horizontal_margin"
|
||||
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/default_padding"
|
||||
app:cardCornerRadius="@dimen/card_corner_radius"
|
||||
app:cardElevation="@dimen/card_elevation">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_margin="@dimen/default_padding"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/how_to_use_in_app"
|
||||
android:textAppearance="?attr/textAppearanceSubtitle1" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/default_padding"
|
||||
android:text="@string/how_to_use_in_app_description" />
|
||||
|
||||
<com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.YouTubePlayerView
|
||||
android:id="@+id/in_app_youtube_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/default_padding"
|
||||
app:autoPlay="false"
|
||||
app:showFullScreenButton="false"
|
||||
app:enableAutomaticInitialization="true"
|
||||
app:handleNetworkEvents="true"
|
||||
app:videoId="NXv3JpmwA8Y" />
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/default_padding"
|
||||
app:cardCornerRadius="@dimen/card_corner_radius"
|
||||
app:cardElevation="@dimen/card_elevation">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_margin="@dimen/default_padding"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/how_to_use_from_tiktok"
|
||||
android:textAppearance="?attr/textAppearanceSubtitle1" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/default_padding"
|
||||
android:text="@string/how_to_use_from_tiktok_description" />
|
||||
|
||||
<com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.YouTubePlayerView
|
||||
android:id="@+id/in_tiktok_youtube_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/default_padding"
|
||||
app:autoPlay="false"
|
||||
app:showFullScreenButton="false"
|
||||
app:enableAutomaticInitialization="true"
|
||||
app:handleNetworkEvents="true"
|
||||
app:videoId="jxaxffE8c4c" />
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/default_padding"
|
||||
app:cardCornerRadius="@dimen/card_corner_radius"
|
||||
app:cardElevation="@dimen/card_elevation">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_margin="@dimen/default_padding"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/additional_information"
|
||||
android:textAppearance="?attr/textAppearanceSubtitle1" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/default_padding"
|
||||
android:text="@string/error_handling" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/default_padding"
|
||||
android:text="@string/captcha_handling" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/repository_link_view"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/default_padding"
|
||||
android:text="@string/link_to_repository" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
96
app/src/main/res/layout/fragment_queue.xml
Normal file
96
app/src/main/res/layout/fragment_queue.xml
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layoutDescription="@xml/queue_motion_description">
|
||||
|
||||
<View
|
||||
android:id="@+id/toolbar_background"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:background="?attr/colorSurface"
|
||||
android:elevation="@dimen/default_toolbar_elevation"
|
||||
app:layout_constraintBottom_toBottomOf="@id/toolbar_bottom_space"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<Space
|
||||
android:id="@+id/toolbar_top_space"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/medium_padding"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/activity_horizontal_margin"
|
||||
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||
android:text="@string/copy_the_link_from_tiktok_here_to_download_it"
|
||||
android:textAppearance="?attr/textAppearanceSubtitle1"
|
||||
android:translationZ="@dimen/default_toolbar_elevation"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar_top_space" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/download_url_input_layout"
|
||||
android:theme="@style/Theme.TikTokDownloader.TextInputLayout"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/medium_padding"
|
||||
android:imeOptions="actionSend"
|
||||
android:hint="@string/tik_tok_link"
|
||||
android:translationZ="@dimen/default_toolbar_elevation"
|
||||
app:layout_constraintEnd_toEndOf="@id/title"
|
||||
app:layout_constraintStart_toStartOf="@id/title"
|
||||
app:layout_constraintTop_toBottomOf="@id/title">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/download_url_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textUri"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/save_cta"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/medium_padding"
|
||||
android:enabled="false"
|
||||
android:text="@string/start"
|
||||
android:translationZ="@dimen/default_toolbar_elevation"
|
||||
app:layout_constraintEnd_toEndOf="@id/download_url_input_layout"
|
||||
app:layout_constraintTop_toBottomOf="@id/download_url_input_layout" />
|
||||
|
||||
<Space
|
||||
android:id="@+id/toolbar_bottom_space"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/medium_padding"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/save_cta" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:background="?attr/colorSurface"
|
||||
android:paddingBottom="@dimen/fab_padding"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="@dimen/activity_horizontal_margin"
|
||||
android:paddingEnd="@dimen/activity_horizontal_margin"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar_background" />
|
||||
|
||||
</androidx.constraintlayout.motion.widget.MotionLayout>
|
||||
72
app/src/main/res/layout/item_downloaded.xml
Normal file
72
app/src/main/res/layout/item_downloaded.xml
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/default_padding"
|
||||
android:stateListAnimator="@animator/card_elevation_animator"
|
||||
app:cardCornerRadius="@dimen/card_corner_radius"
|
||||
app:cardElevation="@dimen/card_elevation">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:padding="@dimen/medium_padding">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thumbnail"
|
||||
android:layout_width="@dimen/image_indicator"
|
||||
android:layout_height="@dimen/image_indicator"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_twotone_image"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/url"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/medium_padding"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:layout_marginBottom="@dimen/medium_padding"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
|
||||
app:layout_constraintBottom_toTopOf="@id/status"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginEnd="@dimen/medium_padding"
|
||||
app:layout_constraintStart_toEndOf="@id/thumbnail"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
tools:text="https://www.vm.tiktok.com/DSFAasGFD" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/medium_padding"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="@id/url"
|
||||
app:layout_constraintTop_toBottomOf="@id/url"
|
||||
tools:text="Pending" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
android:layout_marginTop="@dimen/medium_padding"
|
||||
app:layout_constraintEnd_toEndOf="@id/url"
|
||||
app:layout_constraintStart_toStartOf="@id/url"
|
||||
app:layout_constraintTop_toBottomOf="@id/url" />
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
14
app/src/main/res/menu/main_navigation.xml
Normal file
14
app/src/main/res/menu/main_navigation.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/queue_menu_item"
|
||||
android:icon="@drawable/ic_download"
|
||||
android:title="@string/queue" />
|
||||
|
||||
<item
|
||||
android:id="@+id/help_menu_item"
|
||||
android:icon="@drawable/ic_twotone_help"
|
||||
android:title="@string/help" />
|
||||
|
||||
</menu>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 821 B |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
20
app/src/main/res/values-night/themes.xml
Normal file
20
app/src/main/res/values-night/themes.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.TikTokDownloader">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_200</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/black</item>
|
||||
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorSurface</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorSurface">@color/close_to_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.TikTokDownloader.TextInputLayout" parent="ThemeOverlay.MaterialComponents">
|
||||
<item name="colorOnSurface">@color/close_to_white</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
10
app/src/main/res/values/colors.xml
Normal file
10
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="close_to_black">#FF0F0F0F</color>
|
||||
<color name="close_to_white">#FFF0F0F0</color>
|
||||
</resources>
|
||||
11
app/src/main/res/values/dimens.xml
Normal file
11
app/src/main/res/values/dimens.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<dimen name="activity_horizontal_margin">24dp</dimen>
|
||||
<dimen name="default_padding">16dp</dimen>
|
||||
<dimen name="medium_padding">8dp</dimen>
|
||||
<dimen name="card_elevation">2dp</dimen>
|
||||
<dimen name="card_corner_radius">4dp</dimen>
|
||||
<dimen name="default_toolbar_elevation">4dp</dimen>
|
||||
<dimen name="image_indicator">64dp</dimen>
|
||||
<dimen name="fab_padding">88dp</dimen>
|
||||
</resources>
|
||||
45
app/src/main/res/values/strings.xml
Normal file
45
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<resources>
|
||||
<string name="app_name">TikTokDownloader</string>
|
||||
|
||||
<string name="tik_tok_downloader_started">TikTok Downloader Started</string>
|
||||
<string name="tik_tok_downloader_processing">Downloading: %s</string>
|
||||
<string name="tik_tok_downloader_notification_channel">Downloader Progress</string>
|
||||
|
||||
<string name="queue">Queue</string>
|
||||
<string name="help">Help</string>
|
||||
|
||||
<string name="copy_the_link_from_tiktok_here_to_download_it">Copy link here from TikTok to download it</string>
|
||||
<string name="start_download">Start Download</string>
|
||||
<string name="start">Start</string>
|
||||
|
||||
<string name="status_pending">Pending</string>
|
||||
<string name="status_finished">Downloaded</string>
|
||||
|
||||
<string name="network_error">Network Error</string>
|
||||
<string name="parsing_error">Parsing Error</string>
|
||||
<string name="storage_error">Failed to Store Video</string>
|
||||
<string name="unexpected_error">Unexpected Error</string>
|
||||
<string name="permission_request">Permission Needed</string>
|
||||
<string name="permission_rationale">External Storage permission is needed in order to save the video to your device</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="cant_operate_without_permission">Can\'t operate without external storage permission</string>
|
||||
<string name="captcha_error">Captcha seems necessary, try later!</string>
|
||||
<string name="tik_tok_link">TikTok Link</string>
|
||||
<string name="how_to_use_in_app">How To Use In App</string>
|
||||
<string name="how_to_use_in_app_description">- Open Queue Screen.
|
||||
\n- Copy the link from TikTok into the input field.
|
||||
\n- Press start.
|
||||
\n- If error happens retry with the refresh button.
|
||||
\n\n<b>Note</b>: Clicking on video will open the Gallery app.</string>
|
||||
<string name="how_to_use_from_tiktok">How To Use From TikTok</string>
|
||||
<string name="how_to_use_from_tiktok_description">- In TikTok press share.
|
||||
\n- Select Other.
|
||||
\n- Select TikTok Downloader.
|
||||
\n- You receive a notification about the state of download.
|
||||
\n- If you need to retry, open the App.
|
||||
</string>
|
||||
<string name="additional_information">Additional Information</string>
|
||||
<string name="error_handling">The download may fail for various reasons. In such case on the Queue screen you will see a refresh icon. Pressing that will retry the download.</string>
|
||||
<string name="captcha_handling">It\'s possible if you try to download too many videos at the same time <b>captcha</b> will be triggered. Since we don\'t want to overload the server, in such case you will need to wait a couple hours to properly retry the download. If that still doesn\'t work you may need to verify a captcha on the same network.</string>
|
||||
<string name="link_to_repository">This is an open source project. You can see the repository clicking <u>here.</u></string>
|
||||
</resources>
|
||||
41
app/src/main/res/values/themes.xml
Normal file
41
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/purple_500</item>
|
||||
<item name="colorSecondaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnSecondary">@color/white</item>
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="materialButtonOutlinedStyle">@style/OutlineButtonStyle</item>
|
||||
</style>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.TikTokDownloader">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorSurface</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorSurface">@color/white</item>
|
||||
</style>
|
||||
|
||||
<style name="NoDisplayTheme" parent="Theme.TikTokDownloader">
|
||||
<item name="android:windowBackground">@null</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowAnimationStyle">@null</item>
|
||||
<item name="android:windowDisablePreview">true</item>
|
||||
<item name="android:windowNoDisplay">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.TikTokDownloader.TextInputLayout" parent="ThemeOverlay.MaterialComponents">
|
||||
<item name="colorOnSurface">@color/black</item>
|
||||
</style>
|
||||
|
||||
<style name="OutlineButtonStyle" parent="Widget.MaterialComponents.Button.OutlinedButton">
|
||||
<item name="strokeColor">@color/outline_stroke_color</item>
|
||||
<item name="android:textColor">@color/outline_stroke_color</item>
|
||||
</style>
|
||||
</resources>
|
||||
180
app/src/main/res/xml/queue_motion_description.xml
Normal file
180
app/src/main/res/xml/queue_motion_description.xml
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:motion="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<Transition
|
||||
motion:constraintSetEnd="@+id/end"
|
||||
motion:constraintSetStart="@+id/start"
|
||||
motion:motionInterpolator="easeInOut">
|
||||
<OnSwipe
|
||||
motion:dragDirection="dragUp"
|
||||
motion:touchAnchorId="@+id/recycler" />
|
||||
|
||||
<KeyFrameSet>
|
||||
<KeyAttribute
|
||||
android:alpha="0"
|
||||
motion:framePosition="40"
|
||||
motion:motionTarget="@id/title" />
|
||||
<KeyAttribute
|
||||
android:translationX="@dimen/default_padding"
|
||||
motion:framePosition="40"
|
||||
motion:motionTarget="@id/save_cta" />
|
||||
<KeyPosition
|
||||
motion:framePosition="40"
|
||||
motion:motionTarget="@id/save_cta"
|
||||
motion:percentY="0.2" />
|
||||
<KeyPosition
|
||||
motion:framePosition="50"
|
||||
motion:motionTarget="@id/toolbar_background"
|
||||
motion:percentHeight="0.2"
|
||||
motion:percentY="0.2" />
|
||||
<KeyPosition
|
||||
motion:framePosition="50"
|
||||
motion:motionTarget="@id/recycler"
|
||||
motion:percentY="0.2" />
|
||||
<KeyPosition
|
||||
motion:framePosition="70"
|
||||
motion:motionTarget="@id/download_url_input_layout"
|
||||
motion:percentWidth="1"
|
||||
motion:percentX="1" />
|
||||
</KeyFrameSet>
|
||||
</Transition>
|
||||
|
||||
<ConstraintSet android:id="@id/start">
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/toolbar_background"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:elevation="@dimen/default_toolbar_elevation"
|
||||
motion:layout_constraintBottom_toBottomOf="@id/toolbar_bottom_space"
|
||||
motion:layout_constraintEnd_toEndOf="parent"
|
||||
motion:layout_constraintStart_toStartOf="parent"
|
||||
motion:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/toolbar_top_space"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/medium_padding"
|
||||
motion:layout_constraintStart_toStartOf="parent"
|
||||
motion:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/activity_horizontal_margin"
|
||||
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||
android:alpha="1"
|
||||
android:translationZ="@dimen/default_toolbar_elevation"
|
||||
motion:layout_constraintEnd_toEndOf="parent"
|
||||
motion:layout_constraintStart_toStartOf="parent"
|
||||
motion:layout_constraintTop_toBottomOf="@id/toolbar_top_space" />
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/download_url_input_layout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/medium_padding"
|
||||
android:translationZ="@dimen/default_toolbar_elevation"
|
||||
motion:layout_constraintEnd_toEndOf="@id/title"
|
||||
motion:layout_constraintStart_toStartOf="@id/title"
|
||||
motion:layout_constraintTop_toBottomOf="@id/title" />
|
||||
|
||||
<Constraint
|
||||
android:id="@id/save_cta"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/medium_padding"
|
||||
android:translationZ="@dimen/default_toolbar_elevation"
|
||||
motion:layout_constraintEnd_toEndOf="@id/download_url_input_layout"
|
||||
motion:layout_constraintTop_toBottomOf="@id/download_url_input_layout"
|
||||
motion:pathMotionArc="startHorizontal" />
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/toolbar_bottom_space"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/medium_padding"
|
||||
motion:layout_constraintStart_toStartOf="parent"
|
||||
motion:layout_constraintTop_toBottomOf="@id/save_cta" />
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
motion:layout_constraintEnd_toEndOf="parent"
|
||||
motion:layout_constraintBottom_toBottomOf="parent"
|
||||
motion:layout_constraintStart_toStartOf="parent"
|
||||
motion:layout_constraintTop_toBottomOf="@id/toolbar_background" />
|
||||
|
||||
</ConstraintSet>
|
||||
<ConstraintSet android:id="@+id/end">
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/toolbar_background"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:elevation="@dimen/default_toolbar_elevation"
|
||||
motion:layout_constraintBottom_toBottomOf="@id/toolbar_bottom_space"
|
||||
motion:layout_constraintEnd_toEndOf="parent"
|
||||
motion:layout_constraintStart_toStartOf="parent"
|
||||
motion:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/toolbar_top_space"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/medium_padding"
|
||||
motion:layout_constraintStart_toStartOf="parent"
|
||||
motion:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/activity_horizontal_margin"
|
||||
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||
android:alpha="0"
|
||||
android:translationZ="@dimen/default_toolbar_elevation"
|
||||
motion:layout_constraintBottom_toTopOf="parent"
|
||||
motion:layout_constraintEnd_toEndOf="parent"
|
||||
motion:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/download_url_input_layout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/medium_padding"
|
||||
android:layout_marginEnd="@dimen/medium_padding"
|
||||
android:translationZ="@dimen/default_toolbar_elevation"
|
||||
motion:layout_constraintEnd_toStartOf="@id/save_cta"
|
||||
motion:layout_constraintStart_toStartOf="@id/title"
|
||||
motion:layout_constraintTop_toBottomOf="@id/toolbar_top_space" />
|
||||
|
||||
<Constraint
|
||||
android:id="@id/save_cta"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/medium_padding"
|
||||
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||
android:translationZ="@dimen/default_toolbar_elevation"
|
||||
motion:layout_constraintEnd_toEndOf="parent"
|
||||
motion:layout_constraintTop_toBottomOf="@id/download_url_input_layout"
|
||||
motion:layout_constraintTop_toTopOf="@id/download_url_input_layout" />
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/toolbar_bottom_space"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/default_padding"
|
||||
motion:layout_constraintStart_toStartOf="parent"
|
||||
motion:layout_constraintTop_toBottomOf="@id/download_url_input_layout" />
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
motion:layout_constraintEnd_toEndOf="parent"
|
||||
motion:layout_constraintBottom_toBottomOf="parent"
|
||||
motion:layout_constraintStart_toStartOf="parent"
|
||||
motion:layout_constraintTop_toBottomOf="@id/toolbar_background" />
|
||||
</ConstraintSet>
|
||||
</MotionScene>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package org.fnives.tiktokdownloader.data.local
|
||||
|
||||
import com.nhaarman.mockitokotlin2.spy
|
||||
import org.fnives.tiktokdownloader.data.local.persistent.SharedPreferencesManager
|
||||
import org.fnives.tiktokdownloader.helper.mock.InMemorySharedPreferencesManager
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@Suppress("TestFunctionName")
|
||||
class CaptchaTimeoutLocalSourceTest {
|
||||
|
||||
private lateinit var mockSharedPreferencesManager: SharedPreferencesManager
|
||||
private lateinit var sut: CaptchaTimeoutLocalSource
|
||||
|
||||
@BeforeEach
|
||||
fun setup(){
|
||||
mockSharedPreferencesManager = spy(InMemorySharedPreferencesManager())
|
||||
sut = CaptchaTimeoutLocalSource(mockSharedPreferencesManager, 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_initialized_WHEN_isInCacheTimeout_THEN_its_false() {
|
||||
Assertions.assertFalse(sut.isInCaptchaTimeout(), "By default not in Captcha timeout")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_initialized_saved_cache_timeout_WHEN_not_enough_time_passed_THEN_it_is__NOT_InCacheTimeout() {
|
||||
sut.onCaptchaResponseReceived()
|
||||
Thread.sleep(1)
|
||||
|
||||
Assertions.assertTrue(sut.isInCaptchaTimeout(), "By default not in Captcha timeout")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_initialized_saved_cache_timeout_WHEN_enough_time_passed_THEN_it_is__InCacheTimeout() {
|
||||
sut.onCaptchaResponseReceived()
|
||||
Thread.sleep(60)
|
||||
|
||||
Assertions.assertFalse(sut.isInCaptchaTimeout(), "By default not in Captcha timeout")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
package org.fnives.tiktokdownloader.data.local
|
||||
|
||||
import com.nhaarman.mockitokotlin2.anyOrNull
|
||||
import com.nhaarman.mockitokotlin2.doReturn
|
||||
import com.nhaarman.mockitokotlin2.mock
|
||||
import com.nhaarman.mockitokotlin2.verifyZeroInteractions
|
||||
import com.nhaarman.mockitokotlin2.whenever
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.yield
|
||||
import org.fnives.tiktokdownloader.data.local.exceptions.StorageException
|
||||
import org.fnives.tiktokdownloader.data.local.persistent.SharedPreferencesManager
|
||||
import org.fnives.tiktokdownloader.data.local.save.video.SaveVideoFile
|
||||
import org.fnives.tiktokdownloader.data.local.verify.exists.VerifyFileForUriExists
|
||||
import org.fnives.tiktokdownloader.data.model.VideoDownloaded
|
||||
import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile
|
||||
import org.fnives.tiktokdownloader.helper.mock.InMemorySharedPreferencesManager
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.InputStream
|
||||
|
||||
@Suppress("TestFunctionName")
|
||||
class VideoDownloadedLocalSourceTest {
|
||||
|
||||
private lateinit var sut: VideoDownloadedLocalSource
|
||||
private lateinit var sharedPreferencesManager: SharedPreferencesManager
|
||||
private lateinit var mockSaveVideoFile: SaveVideoFile
|
||||
private lateinit var mockVerifyFileForUriExists: VerifyFileForUriExists
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
sharedPreferencesManager = InMemorySharedPreferencesManager()
|
||||
mockSaveVideoFile = mock()
|
||||
mockVerifyFileForUriExists = mock()
|
||||
|
||||
sut = VideoDownloadedLocalSource(
|
||||
saveVideoFile = mockSaveVideoFile,
|
||||
sharedPreferencesManagerImpl = sharedPreferencesManager,
|
||||
verifyFileForUriExists = mockVerifyFileForUriExists
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_observing_saved_videos_WHEN_initialized_THEN_emptylist_is_emitted() = runBlocking<Unit> {
|
||||
Assertions.assertEquals(emptyList<VideoDownloaded>(), sut.savedVideos.first())
|
||||
verifyZeroInteractions(mockSaveVideoFile)
|
||||
verifyZeroInteractions(mockVerifyFileForUriExists)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_observing_saved_videos_WAITING_for_2_items_WHEN_initialization_THEN_it_timesOut() {
|
||||
Assertions.assertThrows(TimeoutCancellationException::class.java) {
|
||||
runBlocking<Unit> {
|
||||
withTimeout(300) {
|
||||
sut.savedVideos.take(2).collect { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_observing_saved_videos_AND_uri_still_exists_WHEN_video_is_saved_THEN_videoDownloaded_is_sent_out() =
|
||||
runBlocking<Unit> {
|
||||
val videoInSavingIntoFile = VideoInSavingIntoFile(
|
||||
id = "id",
|
||||
url = "alma",
|
||||
contentType = VideoInSavingIntoFile.ContentType("a", "b"),
|
||||
byteStream = FalseInputStream()
|
||||
)
|
||||
val expectedDir = "TikTok_Downloader"
|
||||
val expectedFileName = videoInSavingIntoFile.id + ".b"
|
||||
whenever(mockSaveVideoFile.invoke(expectedDir, expectedFileName, videoInSavingIntoFile)).doReturn("almaUri")
|
||||
whenever(mockVerifyFileForUriExists.invoke("almaUri")).doReturn(true)
|
||||
|
||||
val actual = async(coroutineContext) {
|
||||
sut.savedVideos.take(2).toList()
|
||||
}
|
||||
yield()
|
||||
sut.saveVideo(videoInSavingIntoFile)
|
||||
|
||||
Assertions.assertEquals(listOf(VideoDownloaded("id", "alma", "almaUri")), sut.savedVideos.first())
|
||||
Assertions.assertEquals(listOf(emptyList(), listOf(VideoDownloaded("id", "alma", "almaUri"))), actual.await())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_observing_saved_videos_AND_uri_doesnt_exists_WHEN_video_is_saved_THEN_videoDownloaded_is_sent_out() =
|
||||
runBlocking<Unit> {
|
||||
val videoInSavingIntoFile = VideoInSavingIntoFile(
|
||||
id = "id",
|
||||
url = "alma",
|
||||
contentType = VideoInSavingIntoFile.ContentType("a", "b"),
|
||||
byteStream = FalseInputStream()
|
||||
)
|
||||
val expectedDir = "TikTok_Downloader"
|
||||
val expectedFileName = videoInSavingIntoFile.id + ".b"
|
||||
whenever(mockSaveVideoFile.invoke(expectedDir, expectedFileName, videoInSavingIntoFile)).doReturn("almaUri")
|
||||
whenever(mockVerifyFileForUriExists.invoke("almaUri")).doReturn(false)
|
||||
|
||||
val actual = async(coroutineContext) {
|
||||
sut.savedVideos.take(1).toList()
|
||||
}
|
||||
yield()
|
||||
sut.saveVideo(videoInSavingIntoFile)
|
||||
|
||||
Assertions.assertEquals(emptyList<VideoDownloaded>(), sut.savedVideos.first())
|
||||
Assertions.assertEquals(listOf(emptyList<VideoDownloaded>()), actual.await())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_observing_saved_videos_for_TWO_AND_uri_doesnt_exists_WHEN_video_is_saved_THEN_videoDownloaded_is_sent_out() {
|
||||
Assertions.assertThrows(CancellationException::class.java) {
|
||||
runBlocking<Unit> {
|
||||
val videoInSavingIntoFile = VideoInSavingIntoFile(
|
||||
id = "id",
|
||||
url = "alma",
|
||||
contentType = VideoInSavingIntoFile.ContentType("a", "b"),
|
||||
byteStream = FalseInputStream()
|
||||
)
|
||||
val expectedDir = "TikTok_Downloader"
|
||||
val expectedFileName = videoInSavingIntoFile.id + ".b"
|
||||
whenever(mockSaveVideoFile.invoke(expectedDir, expectedFileName, videoInSavingIntoFile)).doReturn("almaUri")
|
||||
whenever(mockVerifyFileForUriExists.invoke("almaUri")).doReturn(false)
|
||||
|
||||
val actual = async(coroutineContext) {
|
||||
sut.savedVideos.take(2).toList()
|
||||
}
|
||||
yield()
|
||||
sut.saveVideo(videoInSavingIntoFile)
|
||||
|
||||
withTimeout(300) { actual.await() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_exception_from_savingUseCase_WHEN_saving_video_THEN_the_exception_is_wrapped_into_FileException() {
|
||||
Assertions.assertThrows(StorageException::class.java) {
|
||||
runBlocking<Unit> {
|
||||
val videoInSavingIntoFile = VideoInSavingIntoFile(
|
||||
id = "id",
|
||||
url = "alma",
|
||||
contentType = VideoInSavingIntoFile.ContentType("a", "b"),
|
||||
byteStream = FalseInputStream()
|
||||
)
|
||||
val expectedDir = "TikTok_Downloader"
|
||||
val expectedFileName = videoInSavingIntoFile.id + ".b"
|
||||
whenever(mockSaveVideoFile.invoke(expectedDir, expectedFileName, videoInSavingIntoFile))
|
||||
.then { throw Throwable() }
|
||||
|
||||
sut.saveVideo(videoInSavingIntoFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_observing_saved_videos_WHEN_saving_2_videos_THEN_then_both_of_them_are_emitted_in_reverse_order() =
|
||||
runBlocking<Unit> {
|
||||
val videoInSavingIntoFile1 = VideoInSavingIntoFile(
|
||||
id = "id1",
|
||||
url = "alma1",
|
||||
contentType = VideoInSavingIntoFile.ContentType("a1", "b1"),
|
||||
byteStream = FalseInputStream()
|
||||
)
|
||||
val videoInSavingIntoFile2 = VideoInSavingIntoFile(
|
||||
id = "id2",
|
||||
url = "alma2",
|
||||
contentType = VideoInSavingIntoFile.ContentType("a2", "b2"),
|
||||
byteStream = FalseInputStream()
|
||||
)
|
||||
whenever(mockVerifyFileForUriExists.invoke(anyOrNull())).doReturn(true)
|
||||
whenever(mockSaveVideoFile.invoke(anyOrNull(), anyOrNull(), anyOrNull())).then {
|
||||
"uri: " + (it.arguments[1] as String)
|
||||
}
|
||||
val expectedModel1 = VideoDownloaded(id = "id1", url = "alma1", uri = "uri: id1.b1")
|
||||
val expectedModel2 = VideoDownloaded(id = "id2", url = "alma2", uri = "uri: id2.b2")
|
||||
val expected = listOf(emptyList(), listOf(expectedModel1), listOf(expectedModel2, expectedModel1))
|
||||
val actual = async(coroutineContext) { sut.savedVideos.take(3).toList() }
|
||||
|
||||
yield()
|
||||
sut.saveVideo(videoInSavingIntoFile1)
|
||||
delay(100)
|
||||
yield()
|
||||
sut.saveVideo(videoInSavingIntoFile2)
|
||||
|
||||
Assertions.assertEquals(expected, actual.await())
|
||||
}
|
||||
|
||||
class FalseInputStream : InputStream() {
|
||||
override fun read(): Int = 0
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue