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
101
.gitignore
vendored
101
.gitignore
vendored
|
|
@ -1,85 +1,20 @@
|
||||||
# Built application files
|
|
||||||
*.apk
|
|
||||||
*.aar
|
|
||||||
*.ap_
|
|
||||||
*.aab
|
|
||||||
|
|
||||||
# Files for the ART/Dalvik VM
|
|
||||||
*.dex
|
|
||||||
|
|
||||||
# Java class files
|
|
||||||
*.class
|
|
||||||
|
|
||||||
# Generated files
|
|
||||||
bin/
|
|
||||||
gen/
|
|
||||||
out/
|
|
||||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
|
||||||
# release/
|
|
||||||
|
|
||||||
# Gradle files
|
|
||||||
.gradle/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
|
||||||
local.properties
|
|
||||||
|
|
||||||
# Proguard folder generated by Eclipse
|
|
||||||
proguard/
|
|
||||||
|
|
||||||
# Log Files
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Android Studio Navigation editor temp files
|
|
||||||
.navigation/
|
|
||||||
|
|
||||||
# Android Studio captures folder
|
|
||||||
captures/
|
|
||||||
|
|
||||||
# IntelliJ
|
|
||||||
*.iml
|
*.iml
|
||||||
.idea/workspace.xml
|
.gradle
|
||||||
.idea/tasks.xml
|
/.css
|
||||||
.idea/gradle.xml
|
/.idea
|
||||||
.idea/assetWizardSettings.xml
|
/.img
|
||||||
.idea/dictionaries
|
/org**
|
||||||
.idea/libraries
|
/index*
|
||||||
# Android Studio 3 in .gitignore file.
|
/local.properties
|
||||||
.idea/caches
|
/.idea/caches
|
||||||
.idea/modules.xml
|
/.idea/libraries
|
||||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
/.idea/modules.xml
|
||||||
.idea/navEditor.xml
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
# Keystore files
|
/.idea/assetWizardSettings.xml
|
||||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
.DS_Store
|
||||||
#*.jks
|
/build
|
||||||
#*.keystore
|
/captures
|
||||||
|
|
||||||
# External native build folder generated in Android Studio 2.2 and later
|
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx/
|
.cxx
|
||||||
|
local.properties
|
||||||
# Google Services (e.g. APIs or Firebase)
|
|
||||||
# google-services.json
|
|
||||||
|
|
||||||
# Freeline
|
|
||||||
freeline.py
|
|
||||||
freeline/
|
|
||||||
freeline_project_description.json
|
|
||||||
|
|
||||||
# fastlane
|
|
||||||
fastlane/report.xml
|
|
||||||
fastlane/Preview.html
|
|
||||||
fastlane/screenshots
|
|
||||||
fastlane/test_output
|
|
||||||
fastlane/readme.md
|
|
||||||
|
|
||||||
# Version control
|
|
||||||
vcs.xml
|
|
||||||
|
|
||||||
# lint
|
|
||||||
lint/intermediates/
|
|
||||||
lint/generated/
|
|
||||||
lint/outputs/
|
|
||||||
lint/tmp/
|
|
||||||
# lint/reports/
|
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -1,2 +1,16 @@
|
||||||
# TikTok-Downloader
|
# TikTok-Downloader
|
||||||
Simple Open Source App to Download TikTok Videos using share link.
|
Simple Open Source App to Download TikTok Videos using share feature of the App.
|
||||||
|
|
||||||
|
The idea is that some of the videos cannot be downloaded within the app, or one simply doesn't want tiktok grant access to external storage, but they can be downloaded from the Website. This small project automates that process, by levreging the share / other feature of the TikTok app.
|
||||||
|
|
||||||
|
There is already plenty of applications in the Play Store doing the same so this is not released. Probably won't be updated as often as they are. However feel free to look over the inner workings of the code if your heath desires.
|
||||||
|
|
||||||
|
If you wish to try it out, here is the QR code to download the application.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
There is also a medium post about the project, feel free to check it out here. (yet to be published and link added here)
|
||||||
|
|
||||||
|
Here is another Secret Video QR code just for the curious minded
|
||||||
|
|
||||||
|

|
||||||
|
|
|
||||||
BIN
app-release-v1-0-0.apk
Normal file
BIN
app-release-v1-0-0.apk
Normal file
Binary file not shown.
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>
|
||||||
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