git initial commit with version v1.0.0

This commit is contained in:
fknives 2020-11-01 19:40:37 +02:00
parent dcc9b4d8f7
commit cfc732712b
138 changed files with 14786 additions and 84 deletions

101
.gitignore vendored
View file

@ -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/

View file

@ -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.
![QR Code for APK](tiktok_downloader_apk_qr_code.png)
There is also a medium post about the project, feel free to check it out here. (yet to be published and link added here)
Here is another Secret Video QR code just for the curious minded
![QR Code for Secret](secret_video_qr_code.png)

BIN
app-release-v1-0-0.apk Normal file

Binary file not shown.

78
app/.gitignore vendored Normal file
View 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
View 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
View 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

View file

@ -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)
}
}

View 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>

View 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)
}
}

View file

@ -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
}
}

View file

@ -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"}"
}
}

View file

@ -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)
}
}
}

View file

@ -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
}
}
}

View file

@ -0,0 +1,3 @@
package org.fnives.tiktokdownloader.data.local.exceptions
class StorageException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause)

View file

@ -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
}

View file

@ -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>>
}

View file

@ -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
)
)
}
}

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -0,0 +1,6 @@
package org.fnives.tiktokdownloader.data.local.verify.exists
interface VerifyFileForUriExists {
suspend operator fun invoke(uri: String): Boolean
}

View file

@ -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() }
}
}

View file

@ -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()
}

View file

@ -0,0 +1,3 @@
package org.fnives.tiktokdownloader.data.model
data class VideoDownloaded(val id: String, val url: String, val uri: String)

View file

@ -0,0 +1,3 @@
package org.fnives.tiktokdownloader.data.model
data class VideoInPending(val id: String, val url: String)

View file

@ -0,0 +1,3 @@
package org.fnives.tiktokdownloader.data.model
data class VideoInProgress(val id: String, val url: String)

View file

@ -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"
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -0,0 +1,3 @@
package org.fnives.tiktokdownloader.data.network.exceptions
class CaptchaRequiredException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause)

View file

@ -0,0 +1,3 @@
package org.fnives.tiktokdownloader.data.network.exceptions
class NetworkException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause)

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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?
}

View file

@ -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")
}
}
}

View file

@ -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
}
}
}

View file

@ -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())
}

View file

@ -0,0 +1,3 @@
package org.fnives.tiktokdownloader.data.network.parsing.response
class ActualVideoPageUrl(val url: String)

View file

@ -0,0 +1,3 @@
package org.fnives.tiktokdownloader.data.network.parsing.response
class VideoFileUrl(val videoFileUrl: String)

View file

@ -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)

View file

@ -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("; ")
}
}
}

View file

@ -0,0 +1,8 @@
package org.fnives.tiktokdownloader.data.network.session
interface CookieStore {
var cookie: String?
fun clear()
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,6 @@
package org.fnives.tiktokdownloader.data.usecase
class UrlVerificationUseCase {
operator fun invoke(url: String) = url.contains("tiktok")
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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
}
}

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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()
}

View file

@ -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
)
}
}

View file

@ -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
)
}

View file

@ -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()
}
}
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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())
}
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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()
}

View file

@ -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
)
}
}

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View 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>

View 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>

View 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>

View 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>

View 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