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

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)